Steph680/ormuz
0
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 