CoolFace
Apppublic

Steph680/ormuz

sourceHugging Faceupdated 4d agoView on Hugging Face
0likes
test_sar_detect.py451 linesDownload Raw Back to tests
1# -*- coding: utf-8 -*-2"""Tests de la détection des navires sur imagerie radar Sentinel-1."""3 4import asyncio5import struct6import zlib7 8import pytest9 10from app import db, imagery, sar_detect11 12 13# ── Encodeur PNG de test ─────────────────────────────────────────────────────14# Écrit indépendamment du décodeur, et volontairement capable d'émettre les15# CINQ types de filtre de ligne : c'est le seul moyen de vérifier que le16# décodeur maison les annule tous, puisque le service Copernicus choisit ses17# filtres librement et qu'une erreur ici décalerait silencieusement toutes les18# positions détectées.19 20def _encoder_png(largeur: int, hauteur: int, pixels: list, filtres=None) -> bytes:21    """pixels = [(r, v, b), ...] en lecture ligne par ligne."""22    octets_px = 323    pas = largeur * octets_px24    brut = bytearray()25    precedente = bytearray(pas)26    for y in range(hauteur):27        ligne = bytearray()28        for x in range(largeur):29            ligne.extend(pixels[y * largeur + x])30        filtre = (filtres[y % len(filtres)] if filtres else 0)31        encodee = bytearray(pas)32        for i in range(pas):33            a = ligne[i - octets_px] if i >= octets_px else 034            b = precedente[i]35            c = precedente[i - octets_px] if i >= octets_px else 036            if filtre == 0:37                encodee[i] = ligne[i]38            elif filtre == 1:39                encodee[i] = (ligne[i] - a) & 0xFF40            elif filtre == 2:41                encodee[i] = (ligne[i] - b) & 0xFF42            elif filtre == 3:43                encodee[i] = (ligne[i] - ((a + b) >> 1)) & 0xFF44            else:45                p = a + b - c46                pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)47                pred = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)48                encodee[i] = (ligne[i] - pred) & 0xFF49        brut.append(filtre)50        brut.extend(encodee)51        precedente = ligne52 53    def bloc(type_bloc, corps):54        return (struct.pack(">I", len(corps)) + type_bloc + corps55                + struct.pack(">I", zlib.crc32(type_bloc + corps) & 0xFFFFFFFF))56 57    ihdr = struct.pack(">IIBBBBB", largeur, hauteur, 8, 2, 0, 0, 0)58    return (b"\x89PNG\r\n\x1a\n" + bloc(b"IHDR", ihdr)59            + bloc(b"IDAT", zlib.compress(bytes(brut))) + bloc(b"IEND", b""))60 61 62def _image(largeur, hauteur, rouges=(), fond=(40, 40, 40), filtres=None):63    """Image grise avec des pixels « écho de navire » (rouge dominant)."""64    px = [fond] * (largeur * hauteur)65    for (x, y) in rouges:66        px[y * largeur + x] = (220, 30, 30)67    return _encoder_png(largeur, hauteur, px, filtres)68 69 70# ── Décodeur PNG ─────────────────────────────────────────────────────────────71 72def test_decodage_png_simple():73    png = _image(4, 3, rouges=[(1, 1)])74    largeur, hauteur, octets_px, pixels = sar_detect.decoder_png(png)75    assert (largeur, hauteur, octets_px) == (4, 3, 3)76    i = (1 * 4 + 1) * 377    assert (pixels[i], pixels[i + 1], pixels[i + 2]) == (220, 30, 30)78 79 80def test_decodage_png_tous_les_filtres():81    """Le service choisit ses filtres librement : les cinq doivent être annulés,82    sinon les positions détectées seraient silencieusement décalées."""83    rouges = [(2, 1), (5, 3), (7, 6)]84    png = _image(10, 8, rouges=rouges, filtres=[0, 1, 2, 3, 4])85    largeur, hauteur, octets_px, pixels = sar_detect.decoder_png(png)86    for (x, y) in rouges:87        i = (y * largeur + x) * octets_px88        assert (pixels[i], pixels[i + 1]) == (220, 30), f"filtre mal annulé en {(x, y)}"89    # Et le fond reste neutre partout ailleurs.90    i = (0 * largeur + 0) * octets_px91    assert pixels[i] == pixels[i + 1] == 4092 93 94def test_png_non_gere_leve():95    """Mieux vaut une erreur franche que des pixels faux."""96    with pytest.raises(ValueError):97        sar_detect.decoder_png(b"pas un png")98    # Niveaux de gris (couleur=0) : format non géré, doit lever.99    faux = bytearray(_image(2, 2))100    faux[25] = 0                      # octet « type de couleur » de l'IHDR101    with pytest.raises(ValueError):102        sar_detect.decoder_png(bytes(faux))103 104 105# ── Détection ────────────────────────────────────────────────────────────────106 107def test_navire_detecte_et_positionne():108    """Une tache de 4 pixels au centre de l'image doit ressortir au centre de109    la boîte du détroit."""110    largeur = hauteur = 100111    centre = [(50, 50), (51, 50), (50, 51), (51, 51)]112    res = sar_detect.detecter(_image(largeur, hauteur, rouges=centre))113    assert len(res["navires"]) == 1114    n = res["navires"][0]115    assert n["taille_px"] == 4116    lon_min, lat_min, lon_max, lat_max = imagery.BBOX117    assert abs(n["lon"] - (lon_min + lon_max) / 2) < 0.01118    assert abs(n["lat"] - (lat_min + lat_max) / 2) < 0.01119 120 121def test_la_cote_est_ecartee_par_sa_taille():122    """Cœur du tri : une côte forme une tache de plusieurs centaines de pixels123    et doit être rejetée sans qu'aucun masque géographique soit nécessaire."""124    cote = [(x, y) for x in range(30) for y in range(20)]      # 600 pixels125    navire = [(80, 80), (81, 80), (80, 81)]126    res = sar_detect.detecter(_image(100, 100, rouges=cote + navire))127    assert len(res["navires"]) == 1                # seul le navire subsiste128    assert res["navires"][0]["taille_px"] == 3129    assert res["ecartes"] == 1                     # la côte, écartée130 131 132def test_chatoiement_isole_ecarte():133    """Une tache d'un ou deux pixels est du chatoiement, pas un navire :134    un navire de 300 m en couvre environ trois."""135    res = sar_detect.detecter(_image(50, 50, rouges=[(10, 10), (11, 10)]))136    assert res["navires"] == [] and res["ecartes"] == 1137 138 139def test_pixels_gris_non_retenus():140    """Terre et mer sont grises (R = V) : aucun écho ne doit être retenu."""141    res = sar_detect.detecter(_image(40, 40))142    assert res["pixels_retenus"] == 0 and res["navires"] == []143 144 145def test_composantes_diagonales_regroupees():146    """Le 8-voisinage doit réunir une trace oblique en un seul navire."""147    res = sar_detect.detecter(_image(40, 40, rouges=[(10, 10), (11, 11), (12, 12)]))148    assert len(res["navires"]) == 1 and res["navires"][0]["taille_px"] == 3149 150 151def test_conversion_pixel_vers_position():152    """Le coin haut-gauche est le nord-ouest de la boîte : la latitude décroît153    quand on descend dans l'image."""154    lon_min, lat_min, lon_max, lat_max = imagery.BBOX155    lon, lat = sar_detect.pixel_vers_lonlat(0, 0, 1000, 1000)156    assert lon_min < lon < lon_min + 0.01 and lat_max - 0.01 < lat < lat_max157    _, lat_bas = sar_detect.pixel_vers_lonlat(0, 999, 1000, 1000)158    assert lat_bas < lat                      # plus bas dans l'image = plus au sud159 160 161# ── Qualification « navire sombre » ──────────────────────────────────────────162 163def test_sans_flux_ais_aucun_navire_n_est_dit_sombre():164    """On ne prétend pas qu'un navire est sombre faute d'avoir regardé l'AIS."""165    navires = [{"lat": 26.5, "lon": 56.5}]166    assert sar_detect.correler_ais(navires, [])[0]["sombre"] is None167 168 169def test_avec_ais_le_navire_sans_correspondance_est_sombre():170    navires = [{"lat": 26.50, "lon": 56.50}, {"lat": 26.80, "lon": 56.90}]171    ais = [{"lat": 26.501, "lon": 56.499}]172    res = sar_detect.correler_ais(navires, ais)173    assert res[0]["sombre"] is False          # concorde avec une position AIS174    assert res[1]["sombre"] is True           # écho radar sans AIS175 176 177# ── Persistance et lecture ───────────────────────────────────────────────────178 179def test_analyse_dune_scene_et_relecture(tmp_path, monkeypatch):180    monkeypatch.setenv("DB_PATH", str(tmp_path / "t.db"))181    # Dimensions du rendu réduites pour le test : la boîte géographique reste182    # la même, seul le pas de la grille change.183    monkeypatch.setattr(imagery, "RENDER_W", 100)184    monkeypatch.setattr(imagery, "RENDER_H", 100)185    png = _image(100, 100, rouges=[(50, 50), (51, 50), (50, 51),186                                   (20, 70), (21, 70), (21, 71)])187 188    async def run():189        await db.init_db()190        async with db.connect() as conn:191            await conn.execute(192                "INSERT INTO imagery_cache (mode, sensed_at, scene_name, fetched_at,"193                " content_type, data) VALUES ('radar', '2026-09-10T02:00:00',"194                " 'S1A_TEST', '2026-09-10T03:00:00', 'image/png', ?)", (png,))195            await conn.commit()196        premier = await sar_detect.refresh()197        rejoue = await sar_detect.refresh()     # scène déjà analysée198        return premier, rejoue, await sar_detect.lire()199 200    premier, rejoue, lu = asyncio.run(run())201    assert premier["etat"] == "analysé" and premier["detections"] == 2202    assert rejoue["etat"] == "à jour"           # pas de recalcul inutile203    # Le seuil et le profil sont RELUS en base au lieu de repartir vides :204    # sans cela l'auto-diagnostic annonçait « seuil retenu None » dès le205    # deuxième cycle, sur une scène pourtant calibrée.206    assert rejoue["seuil"] == premier["seuil"]207    assert rejoue["profil"] == premier["profil"]208    assert rejoue["seuil"] is not None and rejoue["profil"]209    assert lu["derniere"]["scene"] == "S1A_TEST" and lu["derniere"]["detections"] == 2210    assert len(lu["detections"]) == 2211    assert all(26.1 < d["lat"] < 27.0 and 56.0 < d["lon"] < 57.0 for d in lu["detections"])212 213 214def test_apercu_jpeg_refuse(tmp_path, monkeypatch):215    """Le repli « aperçu » n'est pas recadré sur la boîte : sa géométrie est216    inconnue, aucune position ne peut en être tirée."""217    monkeypatch.setenv("DB_PATH", str(tmp_path / "t.db"))218 219    async def run():220        await db.init_db()221        async with db.connect() as conn:222            await conn.execute(223                "INSERT INTO imagery_cache (mode, sensed_at, scene_name, fetched_at,"224                " content_type, data) VALUES ('radar', '2026-09-10T02:00:00',"225                " 'S1A_QL', '2026-09-10T03:00:00', 'image/jpeg', ?)", (b"\xff\xd8\xff",))226            await conn.commit()227        return await sar_detect.refresh()228 229    assert asyncio.run(run())["etat"] == "image non recadrée (aperçu)"230 231 232def test_png_aux_mauvaises_dimensions_refuse(tmp_path, monkeypatch):233    """Garde-fou décisif, vérifié sur données réelles : l'aperçu Copernicus est234    lui aussi un PNG (297x159 observé en base), mais il n'est pas recadré sur la235    boîte. Se fier au type MIME ne suffit pas — Copernicus renvoie d'ailleurs236    « application/octet-stream » pour ses vrais rendus. Seules les dimensions237    exactes du rendu prouvent la géométrie et donc la validité des positions."""238    monkeypatch.setenv("DB_PATH", str(tmp_path / "t.db"))239    png = _image(297, 159, rouges=[(50, 50), (51, 50)])240 241    async def run():242        await db.init_db()243        async with db.connect() as conn:244            await conn.execute(245                "INSERT INTO imagery_cache (mode, sensed_at, scene_name, fetched_at,"246                " content_type, data) VALUES ('radar', '2026-09-01T14:16:15',"247                " 'S1C_APERCU', '2026-09-01T15:00:00', 'application/octet-stream', ?)",248                (png,))249            await conn.commit()250        return await sar_detect.refresh(), await sar_detect.lire()251 252    resume, lu = asyncio.run(run())253    assert resume["etat"] == "géométrie inconnue"254    assert resume["taille"] == "297x159"255    assert lu["derniere"] is None          # aucune position publiée256 257 258def test_type_mime_octet_stream_accepte(tmp_path, monkeypatch):259    """Réciproque : le vrai rendu est annoncé « application/octet-stream » par260    Copernicus. S'y fier ferait rejeter toutes les scènes en production."""261    monkeypatch.setenv("DB_PATH", str(tmp_path / "t.db"))262    png = _image(imagery.RENDER_W, imagery.RENDER_H,263                 rouges=[(500, 500), (501, 500), (500, 501)])264 265    async def run():266        await db.init_db()267        async with db.connect() as conn:268            await conn.execute(269                "INSERT INTO imagery_cache (mode, sensed_at, scene_name, fetched_at,"270                " content_type, data) VALUES ('radar', '2026-09-10T02:00:00',"271                " 'S1C_RENDU', '2026-09-10T03:00:00', 'application/octet-stream', ?)",272                (png,))273            await conn.commit()274        return await sar_detect.refresh()275 276    resume = asyncio.run(run())277    assert resume["etat"] == "analysé" and resume["detections"] == 1278 279 280def test_sans_image_radar_rien_ne_casse(tmp_path, monkeypatch):281    monkeypatch.setenv("DB_PATH", str(tmp_path / "t.db"))282 283    async def run():284        await db.init_db()285        return await sar_detect.refresh(), await sar_detect.lire()286 287    resume, lu = asyncio.run(run())288    assert resume["etat"] == "aucune image radar"289    assert lu["derniere"] is None and lu["detections"] == []290 291 292# ── Seuil auto-calibré ──────────────────────────────────────────────────────293# Constaté en production : « rendu inexploitable (580 échos), scène ignorée ».294# Abandonner la scène privait de TOUTE mesure. Le seuil se resserre désormais.295 296def _image_degradee(largeur, hauteur, faibles, forts):297    """Image mêlant des échos faibles (chatoiement, côte fragmentée) et des298    échos francs (navires) : seul un seuil plus sévère sépare les deux."""299    px = [(40, 40, 40)] * (largeur * hauteur)300    for (x, y) in faibles:301        px[y * largeur + x] = (140, 60, 60)      # rouge − vert = 80302    for (x, y) in forts:303        px[y * largeur + x] = (240, 20, 20)      # rouge − vert = 220304    return _encoder_png(largeur, hauteur, px)305 306 307def _taches(nombre, decalage=0):308    """`nombre` taches de 3 pixels, réparties sans se toucher."""309    pts = []310    for i in range(nombre):311        x, y = (i * 4) % 600, decalage + (i * 4) // 600 * 4312        pts += [(x, y), (x + 1, y), (x, y + 1)]313    return pts314 315 316def test_le_seuil_se_resserre_au_lieu_d_abandonner():317    """Trop de taches au seuil de base : on monte d'un cran plutôt que de318    jeter la scène."""319    png = _image_degradee(700, 620, _taches(sar_detect.MAX_DETECTIONS + 50),320                          _taches(5, decalage=500))321    res = sar_detect.detecter_calibre(png)322    assert res["seuil"] > sar_detect.SEUIL_ROUGE, "le seuil aurait dû se resserrer"323    assert len(res["navires"]) == 5              # seuls les échos francs restent324    assert not res.get("tronque")325 326 327def test_une_scene_ordinaire_garde_le_seuil_de_base():328    res = sar_detect.detecter_calibre(329        _image_degradee(200, 200, [], [(50, 50), (51, 50), (50, 51)]))330    assert res["seuil"] == sar_detect.SEUIL_ROUGE and len(res["navires"]) == 1331 332 333def test_en_dernier_ressort_on_garde_les_plus_forts(monkeypatch):334    """Même au seuil le plus sévère, une image saturée doit livrer les échos335    les plus forts plutôt que rien du tout."""336    monkeypatch.setattr(sar_detect, "SEUILS", (60, 90))337    png = _image_degradee(700, 620, [], _taches(sar_detect.MAX_DETECTIONS + 30))338    res = sar_detect.detecter_calibre(png)339    assert res.get("tronque") is True340    assert len(res["navires"]) == sar_detect.MAX_DETECTIONS341 342 343def test_un_rendu_trop_contraste_est_analyse_et_non_abandonne(tmp_path, monkeypatch):344    """Bout en bout : la scène qui était « ignorée » est désormais exploitée."""345    monkeypatch.setenv("DB_PATH", str(tmp_path / "t.db"))346    monkeypatch.setattr(imagery, "RENDER_W", 700)347    monkeypatch.setattr(imagery, "RENDER_H", 620)348    png = _image_degradee(700, 620, _taches(sar_detect.MAX_DETECTIONS + 50),349                          _taches(6, decalage=500))350 351    async def run():352        await db.init_db()353        async with db.connect() as conn:354            await conn.execute(355                "INSERT INTO imagery_cache (mode, sensed_at, scene_name, fetched_at,"356                " content_type, data) VALUES ('radar', '2026-09-10T02:00:00',"357                " 'S1A_CONTRASTE', '2026-09-10T03:00:00', 'image/png', ?)", (png,))358            await conn.commit()359        return await sar_detect.refresh(), await sar_detect.lire()360 361    resume, lu = asyncio.run(run())362    assert resume["etat"] == "analysé"                 # plus jamais abandonnée363    assert resume["seuil"] > sar_detect.SEUIL_ROUGE    # seuil resserré364    assert resume["detections"] == 6365    assert lu["derniere"]["detections"] == 6366 367 368def test_le_seuil_est_choisi_au_coude_et_le_profil_conserve():369    """Le seuil ne doit pas être décidé par un plafond rond — constaté en370    production : 394 échos retenus juste sous le plafond de 400, c'est le371    chiffre qui décidait, pas la mesure. On retient le seuil où le nombre de372    taches CESSE de s'effondrer : le bruit disparaît vite, les navires non."""373    # Bruit franchement plus faible que les cibles : il disparaît entre 60 et374    # 90, les cibles résistent jusqu'au bout.375    png = _image_degradee(700, 620, _taches(300), _taches(12, decalage=500))376    res = sar_detect.detecter_calibre(png)377    assert res["seuil"] == 90, "le coude est juste après la disparition du bruit"378    assert len(res["navires"]) == 12379    # Le profil complet est conservé : le choix reste vérifiable après coup.380    profil = {p["seuil"]: p["taches"] for p in res["profil"]}381    assert profil[60] == 312 and profil[90] == 12382    assert set(profil) == set(sar_detect.SEUILS)383 384 385def test_le_profil_est_enregistre_en_base(tmp_path, monkeypatch):386    monkeypatch.setenv("DB_PATH", str(tmp_path / "t.db"))387    monkeypatch.setattr(imagery, "RENDER_W", 200)388    monkeypatch.setattr(imagery, "RENDER_H", 200)389    png = _image_degradee(200, 200, [], [(50, 50), (51, 50), (50, 51)])390 391    async def run():392        await db.init_db()393        async with db.connect() as conn:394            await conn.execute(395                "INSERT INTO imagery_cache (mode, sensed_at, scene_name, fetched_at,"396                " content_type, data) VALUES ('radar', '2026-09-10T02:00:00',"397                " 'S1A_PROFIL', '2026-09-10T03:00:00', 'image/png', ?)", (png,))398            await conn.commit()399        await sar_detect.refresh()400        async with db.connect() as conn:401            cur = await conn.execute("SELECT seuil, profil FROM sar_scenes")402            return await cur.fetchone()403 404    seuil, profil = asyncio.run(run())405    import json as _json406    assert seuil == sar_detect.SEUIL_ROUGE407    assert len(_json.loads(profil)) == len(sar_detect.SEUILS)408 409 410# ── Référence chiffrée d'un comptage ────────────────────────────────────────411# « 344 navires » ne se lit pas : rien ne dit si c'est beaucoup. L'historique412# était en base depuis le début et ne servait qu'à tracer une courbe sans413# échelle ; il fournit ici le point de comparaison qui manquait.414 415def test_ecart_a_la_mediane_des_scenes_precedentes():416    scenes = [{"detections": 344}, {"detections": 320}, {"detections": 300},417              {"detections": 340}, {"detections": 310}]418    ref = sar_detect.reference(scenes)419    assert ref["fiable"] is True420    assert ref["mediane"] == 315.0          # médiane de 320, 300, 340, 310421    assert ref["ecart_pct"] == 9.2422    assert ref["scenes"] == 4423 424 425def test_la_scene_courante_est_exclue_de_sa_propre_reference():426    """Sinon une scène aberrante se justifierait elle-même."""427    scenes = [{"detections": 1000}, {"detections": 100},428              {"detections": 100}, {"detections": 100}]429    assert sar_detect.reference(scenes)["mediane"] == 100430 431 432def test_mediane_et_non_moyenne():433    """Une scène aberrante (rendu trop contrasté, couverture partielle) ne434    doit pas déplacer la référence."""435    scenes = [{"detections": 100}, {"detections": 100}, {"detections": 100},436              {"detections": 100}, {"detections": 5000}]437    ref = sar_detect.reference(scenes)438    assert ref["mediane"] == 100            # la moyenne vaudrait 1325439    assert ref["ecart_pct"] == 0.0440 441 442def test_pas_de_reference_sous_le_minimum_de_scenes():443    scenes = [{"detections": 344}, {"detections": 320}]444    ref = sar_detect.reference(scenes)445    assert ref["fiable"] is False and ref["mediane"] is None446    assert ref["scenes"] == 1447 448 449def test_reference_sur_historique_vide():450    assert sar_detect.reference([])["fiable"] is False451