fix: corriger les faux positifs AFAI — seuils, masque gdal, filtre bruit
Problème : le threshold AFAI 0.005 (S2) / MCI 0.008 (S3) trop bas classifie la majorité des pixels océan comme sargasse → gros rectangle orange sur des zones sans sargasses. Corrections : - S2 AFAI threshold 0.005 → 0.015 (littérature : Hu 2009, Wang & Hu 2016) - S3 MCI threshold 0.008 → 0.015 - Ajout protection sun glint : si NIR > 0.08 → pixel rejeté (S2 + S3) - Division NDWI sécurisée (dénominateur = 0 → return 0) - gdal_polygonize avec -mask (gdal_calc.py) : seuls les pixels DN=1 sont vectorisés, le reste n'existe pas dans le GeoJSON - Filtre MIN_RING_POINTS=8 : supprime les polygones < ~2px (bruit) - PROCESSING_VERSION 1.0.0 → 1.1.0 pour traçabilité ⚠️ Les observations en base avec processing_version='1.0.0' sont faussées — les supprimer avant de relancer l'ingestion. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,8 @@ class IngestionService
|
|||||||
private const CLOUD_THRESHOLD = 60.0;
|
private const CLOUD_THRESHOLD = 60.0;
|
||||||
private const SARGASSUM_DN = 1;
|
private const SARGASSUM_DN = 1;
|
||||||
private const SIMPLIFY_TOLERANCE = 0.001; // degrés (~100m à l'équateur)
|
private const SIMPLIFY_TOLERANCE = 0.001; // degrés (~100m à l'équateur)
|
||||||
private const PROCESSING_VERSION = '1.0.0';
|
private const MIN_RING_POINTS = 8; // filtre polygones < ~2 pixels (bruit)
|
||||||
|
private const PROCESSING_VERSION = '1.1.0';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private SentinelHubClient $sentinelHub,
|
private SentinelHubClient $sentinelHub,
|
||||||
@@ -133,16 +134,44 @@ class IngestionService
|
|||||||
|
|
||||||
private function polygonize(string $tifPath, string $geojsonPath): void
|
private function polygonize(string $tifPath, string $geojsonPath): void
|
||||||
{
|
{
|
||||||
$process = new Process([
|
$maskPath = dirname($tifPath) . '/mask.tif';
|
||||||
|
|
||||||
|
// Créer un masque binaire : pixels DN=1 → valeur 1, tout le reste → nodata (0)
|
||||||
|
// Cela permet à gdal_polygonize de n'extraire QUE les pixels sargasses,
|
||||||
|
// évitant les faux positifs issus de la vectorisation de tout le tile.
|
||||||
|
$maskProcess = new Process([
|
||||||
|
'gdal_calc.py',
|
||||||
|
'-A', $tifPath,
|
||||||
|
'--outfile', $maskPath,
|
||||||
|
'--calc', 'A==1',
|
||||||
|
'--type', 'Byte',
|
||||||
|
'--NoDataValue', '0',
|
||||||
|
'--quiet',
|
||||||
|
]);
|
||||||
|
$maskProcess->setTimeout(60);
|
||||||
|
$maskProcess->run();
|
||||||
|
|
||||||
|
$args = [
|
||||||
'gdal_polygonize.py',
|
'gdal_polygonize.py',
|
||||||
$tifPath,
|
$tifPath,
|
||||||
'-f', 'GeoJSON',
|
'-f', 'GeoJSON',
|
||||||
'-q',
|
'-q',
|
||||||
$geojsonPath,
|
$geojsonPath,
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if ($maskProcess->isSuccessful() && file_exists($maskPath)) {
|
||||||
|
// Insérer -mask avant le fichier de sortie
|
||||||
|
array_splice($args, 1, 0, ['-mask', $maskPath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = new Process($args);
|
||||||
$process->setTimeout(120);
|
$process->setTimeout(120);
|
||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
|
if (file_exists($maskPath)) {
|
||||||
|
unlink($maskPath);
|
||||||
|
}
|
||||||
|
|
||||||
if (!$process->isSuccessful()) {
|
if (!$process->isSuccessful()) {
|
||||||
throw new ProcessFailedException($process);
|
throw new ProcessFailedException($process);
|
||||||
}
|
}
|
||||||
@@ -169,13 +198,18 @@ class IngestionService
|
|||||||
$geom = $feature['geometry'];
|
$geom = $feature['geometry'];
|
||||||
|
|
||||||
if ($geom['type'] === 'Polygon') {
|
if ($geom['type'] === 'Polygon') {
|
||||||
|
// Ignorer les polygones trop petits (bruit : quelques pixels isolés)
|
||||||
|
if (count($geom['coordinates'][0] ?? []) >= self::MIN_RING_POINTS) {
|
||||||
$polygons[] = $geom['coordinates'];
|
$polygons[] = $geom['coordinates'];
|
||||||
|
}
|
||||||
} elseif ($geom['type'] === 'MultiPolygon') {
|
} elseif ($geom['type'] === 'MultiPolygon') {
|
||||||
foreach ($geom['coordinates'] as $poly) {
|
foreach ($geom['coordinates'] as $poly) {
|
||||||
|
if (count($poly[0] ?? []) >= self::MIN_RING_POINTS) {
|
||||||
$polygons[] = $poly;
|
$polygons[] = $poly;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($polygons)) {
|
if (empty($polygons)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ class SentinelHubClient
|
|||||||
{
|
{
|
||||||
// Longueurs d'onde Sentinel-2 (nm) — constantes capteur
|
// Longueurs d'onde Sentinel-2 (nm) — constantes capteur
|
||||||
// B04 (Red) : 664.5 | B08 (NIR) : 832.8 | B11 (SWIR1) : 1613.7
|
// B04 (Red) : 664.5 | B08 (NIR) : 832.8 | B11 (SWIR1) : 1613.7
|
||||||
|
//
|
||||||
|
// Seuil AFAI : 0.015 (relevé de 0.005)
|
||||||
|
// → 0.005 génère des faux positifs massifs (sun glint, eaux côtières turbides)
|
||||||
|
// → 0.015 correspond à une densité minimale réelle de Sargassum fluitans/natans
|
||||||
|
// selon Hu (2009), Wang & Hu (2016) : AFAI > 0.010-0.020 pour détection fiable
|
||||||
return <<<'EVALSCRIPT'
|
return <<<'EVALSCRIPT'
|
||||||
//VERSION=3
|
//VERSION=3
|
||||||
function setup() {
|
function setup() {
|
||||||
@@ -153,8 +158,13 @@ function setup() {
|
|||||||
function evaluatePixel(sample) {
|
function evaluatePixel(sample) {
|
||||||
if (sample.CLP > 0.4) { return [255]; } // nodata : nuage
|
if (sample.CLP > 0.4) { return [255]; } // nodata : nuage
|
||||||
|
|
||||||
|
// Protection sun glint : NIR élevé → reflet solaire ou eau turbide
|
||||||
|
if (sample.B08 > 0.08) { return [0]; }
|
||||||
|
|
||||||
// Masque eau : NDWI = (Green - NIR) / (Green + NIR) — terre si <= 0
|
// Masque eau : NDWI = (Green - NIR) / (Green + NIR) — terre si <= 0
|
||||||
const ndwi = (sample.B03 - sample.B08) / (sample.B03 + sample.B08);
|
const ndwiDenom = sample.B03 + sample.B08;
|
||||||
|
if (ndwiDenom === 0) { return [0]; }
|
||||||
|
const ndwi = (sample.B03 - sample.B08) / ndwiDenom;
|
||||||
if (ndwi <= 0) { return [0]; }
|
if (ndwi <= 0) { return [0]; }
|
||||||
|
|
||||||
const lambdaRed = 664.5;
|
const lambdaRed = 664.5;
|
||||||
@@ -164,7 +174,7 @@ function evaluatePixel(sample) {
|
|||||||
|
|
||||||
const afai = sample.B08 - sample.B04 - (sample.B11 - sample.B04) * ratio;
|
const afai = sample.B08 - sample.B04 - (sample.B11 - sample.B04) * ratio;
|
||||||
|
|
||||||
return [afai > 0.005 ? 1 : 0];
|
return [afai > 0.015 ? 1 : 0];
|
||||||
}
|
}
|
||||||
EVALSCRIPT;
|
EVALSCRIPT;
|
||||||
}
|
}
|
||||||
@@ -178,6 +188,8 @@ EVALSCRIPT;
|
|||||||
*/
|
*/
|
||||||
private function getFaiBinaryEvalscriptS3(): string
|
private function getFaiBinaryEvalscriptS3(): string
|
||||||
{
|
{
|
||||||
|
// Seuil MCI : 0.015 (relevé de 0.008)
|
||||||
|
// → même logique que S2 : 0.008 trop sensible pour la Caraïbe
|
||||||
return <<<'EVALSCRIPT'
|
return <<<'EVALSCRIPT'
|
||||||
//VERSION=3
|
//VERSION=3
|
||||||
function setup() {
|
function setup() {
|
||||||
@@ -188,8 +200,13 @@ function setup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function evaluatePixel(sample) {
|
function evaluatePixel(sample) {
|
||||||
|
// Protection sun glint / eau turbide
|
||||||
|
if (sample.B17 > 0.08) { return [0]; }
|
||||||
|
|
||||||
// Masque eau : NDWI = (Green B06 560nm - NIR B17 865nm) / (Green + NIR)
|
// Masque eau : NDWI = (Green B06 560nm - NIR B17 865nm) / (Green + NIR)
|
||||||
const ndwi = (sample.B06 - sample.B17) / (sample.B06 + sample.B17);
|
const ndwiDenom = sample.B06 + sample.B17;
|
||||||
|
if (ndwiDenom === 0) { return [0]; }
|
||||||
|
const ndwi = (sample.B06 - sample.B17) / ndwiDenom;
|
||||||
if (ndwi <= 0) { return [0]; }
|
if (ndwi <= 0) { return [0]; }
|
||||||
|
|
||||||
const lambdaRed = 665.0;
|
const lambdaRed = 665.0;
|
||||||
@@ -200,7 +217,7 @@ function evaluatePixel(sample) {
|
|||||||
const baseline = sample.B08 + (sample.B11 - sample.B08) * (lambdaNir - lambdaRed) / (lambdaRedEdge - lambdaRed);
|
const baseline = sample.B08 + (sample.B11 - sample.B08) * (lambdaNir - lambdaRed) / (lambdaRedEdge - lambdaRed);
|
||||||
const mci = sample.B17 - baseline;
|
const mci = sample.B17 - baseline;
|
||||||
|
|
||||||
return [mci > 0.008 ? 1 : 0];
|
return [mci > 0.015 ? 1 : 0];
|
||||||
}
|
}
|
||||||
EVALSCRIPT;
|
EVALSCRIPT;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user