Filter labels created after dated imagery
This commit is contained in:
@@ -88,3 +88,47 @@ def test_spatial_leakage_audit_fails_cross_split_neighbors() -> None:
|
|||||||
assert audit["status"] == "failed"
|
assert audit["status"] == "failed"
|
||||||
assert audit["findings"][0]["left"] == "train-a"
|
assert audit["findings"][0]["left"] == "train-a"
|
||||||
assert audit["findings"][0]["right"] == "val-a"
|
assert audit["findings"][0]["right"] == "val-a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalizer_rejects_features_created_after_dated_imagery(tmp_path: Path) -> None:
|
||||||
|
raster_path = tmp_path / "image.tif"
|
||||||
|
with rasterio.open(
|
||||||
|
raster_path,
|
||||||
|
"w",
|
||||||
|
driver="GTiff",
|
||||||
|
width=100,
|
||||||
|
height=100,
|
||||||
|
count=3,
|
||||||
|
dtype="uint8",
|
||||||
|
crs="EPSG:4326",
|
||||||
|
transform=from_origin(4.0, 51.0, 0.001, 0.001),
|
||||||
|
) as dataset:
|
||||||
|
dataset.write(np.zeros((3, 100, 100), dtype="uint8"))
|
||||||
|
geometry = {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[4.01, 50.99], [4.02, 50.99], [4.02, 50.98], [4.01, 50.98], [4.01, 50.99]]],
|
||||||
|
}
|
||||||
|
reference_path = tmp_path / "reference.geojson"
|
||||||
|
reference_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{"type": "Feature", "id": "old", "properties": {"BEGINDATUM": "2024-01-01"}, "geometry": geometry},
|
||||||
|
{"type": "Feature", "id": "new", "properties": {"BEGINDATUM": "2026-01-01"}, "geometry": geometry},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
normalized, audit = module.normalize(
|
||||||
|
reference_path=reference_path,
|
||||||
|
raster_path=raster_path,
|
||||||
|
source_name="grb",
|
||||||
|
min_label_px=3,
|
||||||
|
imagery_observed_at="2025-01-01T00:00:00Z",
|
||||||
|
imagery_valid_to="2025-12-31T23:59:59Z",
|
||||||
|
reference_observed_at="2026-07-01T00:00:00Z",
|
||||||
|
)
|
||||||
|
assert len(normalized["features"]) == 1
|
||||||
|
assert audit["decision_counts"] == {"accepted": 1, "created_after_imagery_period": 1}
|
||||||
|
|||||||
@@ -81,3 +81,8 @@ Every failed assessment returns `continue_training_loop`. Only a report with
|
|||||||
`training_complete` may proceed to final human review and guarded activation.
|
`training_complete` may proceed to final human review and guarded activation.
|
||||||
The orchestrator refuses to start unless the frozen dataset audit is `ok` and
|
The orchestrator refuses to start unless the frozen dataset audit is `ok` and
|
||||||
contains zero blank/low-variance positive tiles.
|
contains zero blank/low-variance positive tiles.
|
||||||
|
For dated imagery, GRB `BEGINDATUM` and PICC `DATE_CREAT` are compared with the
|
||||||
|
end of the imagery period. A feature created afterward is retained in the
|
||||||
|
audit but excluded from training as `created_after_imagery_period`. UrbIS does
|
||||||
|
not expose an equivalent feature creation field in this acquisition contract,
|
||||||
|
so its remaining temporal relation stays an explicit sample-level limitation.
|
||||||
|
|||||||
@@ -11587,3 +11587,18 @@ Next gate:
|
|||||||
- The v5 tile audit passed with zero blank positive tiles. The automated CUDA
|
- The v5 tile audit passed with zero blank positive tiles. The automated CUDA
|
||||||
loop started from the strongest prior candidate and will checkpoint every
|
loop started from the strongest prior candidate and will checkpoint every
|
||||||
train/calibrate/test/background assessment without promoting failed models.
|
train/calibrate/test/background assessment without promoting failed models.
|
||||||
|
|
||||||
|
## 2026-07-27 - Feature-level temporal mismatch filtering
|
||||||
|
|
||||||
|
- Added provider-native creation-time filtering for dated training imagery:
|
||||||
|
GRB `BEGINDATUM` and PICC `DATE_CREAT` are parsed with explicit UTC handling.
|
||||||
|
Buildings created after the image period are audited and excluded rather
|
||||||
|
than taught as labels for structures absent from the image.
|
||||||
|
- Frozen corpus `building-be-v6-temporal-20260727-r1` excludes 246 such temporal
|
||||||
|
mismatches, accepts 13,524 labels and retains all 75 independent AOIs.
|
||||||
|
Manifest SHA-256 is
|
||||||
|
`973828b453e6fbeb5c04aa567ddb615566d92825d8698654f5056d2997d382eb`.
|
||||||
|
- Composition, spatial leakage, temporal identity and positive-imagery QA pass.
|
||||||
|
The v6 train/calibration/test/background exports are ready for the next loop
|
||||||
|
checkpoint; the running v5 iteration remains evidence but cannot supersede
|
||||||
|
the cleaner v6 corpus.
|
||||||
|
|||||||
@@ -955,3 +955,4 @@ This file now starts with the current implementation status. Older preparation/b
|
|||||||
- [ ] Resolve the v3 Flanders and Wallonia generalisation failures through additional training-only evidence and retraining.
|
- [ ] Resolve the v3 Flanders and Wallonia generalisation failures through additional training-only evidence and retraining.
|
||||||
- [x] Replace rolling-mosaic training inputs with governed dated 2025 Flanders/Brussels and complete 2023 SPW imagery; retain exact flight-day limitations.
|
- [x] Replace rolling-mosaic training inputs with governed dated 2025 Flanders/Brussels and complete 2023 SPW imagery; retain exact flight-day limitations.
|
||||||
- [x] Reject positive labels over blank/no-data imagery and replace partial SPW 2024 coverage with the complete dated SPW 2023 campaign.
|
- [x] Reject positive labels over blank/no-data imagery and replace partial SPW 2024 coverage with the complete dated SPW 2023 campaign.
|
||||||
|
- [x] Exclude GRB/PICC features created after the corresponding dated imagery period while retaining auditable rejection evidence.
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ def main() -> int:
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
reference_observed_at=reference.observed_at.isoformat() if reference.observed_at else None,
|
reference_observed_at=reference.observed_at.isoformat() if reference.observed_at else None,
|
||||||
|
imagery_valid_to=raster.valid_to.isoformat() if raster.valid_to else None,
|
||||||
)
|
)
|
||||||
normalized_target.write_text(json.dumps(normalized, ensure_ascii=False), encoding="utf-8")
|
normalized_target.write_text(json.dumps(normalized, ensure_ascii=False), encoding="utf-8")
|
||||||
audit_target.write_text(json.dumps(audit, ensure_ascii=False, indent=2), encoding="utf-8")
|
audit_target.write_text(json.dumps(audit, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import argparse
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -57,6 +57,26 @@ def _semantic_exclusion(properties: dict[str, Any]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _source_creation_at(source_name: str, properties: dict[str, Any]) -> datetime | None:
|
||||||
|
raw: Any = None
|
||||||
|
if source_name == "grb":
|
||||||
|
raw = properties.get("BEGINDATUM")
|
||||||
|
elif source_name == "spw_picc":
|
||||||
|
raw = properties.get("DATE_CREAT")
|
||||||
|
if raw in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if isinstance(raw, (int, float)) or str(raw).isdigit():
|
||||||
|
value = float(raw)
|
||||||
|
if value > 10_000_000_000:
|
||||||
|
value /= 1000
|
||||||
|
return datetime.fromtimestamp(value, tz=UTC)
|
||||||
|
parsed = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||||
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||||
|
except (OSError, OverflowError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _polygonal(geometry: Any) -> Any | None:
|
def _polygonal(geometry: Any) -> Any | None:
|
||||||
if geometry.geom_type in {"Polygon", "MultiPolygon"}:
|
if geometry.geom_type in {"Polygon", "MultiPolygon"}:
|
||||||
return geometry
|
return geometry
|
||||||
@@ -78,6 +98,7 @@ def normalize(
|
|||||||
min_label_px: float,
|
min_label_px: float,
|
||||||
imagery_observed_at: str | None,
|
imagery_observed_at: str | None,
|
||||||
reference_observed_at: str | None,
|
reference_observed_at: str | None,
|
||||||
|
imagery_valid_to: str | None = None,
|
||||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
if source_name not in SUPPORTED_SOURCES:
|
if source_name not in SUPPORTED_SOURCES:
|
||||||
raise SystemExit(f"Unsupported governed building source: {source_name}")
|
raise SystemExit(f"Unsupported governed building source: {source_name}")
|
||||||
@@ -89,6 +110,9 @@ def normalize(
|
|||||||
decisions: list[dict[str, Any]] = []
|
decisions: list[dict[str, Any]] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
counts: Counter[str] = Counter()
|
counts: Counter[str] = Counter()
|
||||||
|
imagery_cutoff = (
|
||||||
|
datetime.fromisoformat(imagery_valid_to.replace("Z", "+00:00")) if imagery_valid_to else None
|
||||||
|
)
|
||||||
with rasterio.open(raster_path) as raster:
|
with rasterio.open(raster_path) as raster:
|
||||||
if raster.crs is None:
|
if raster.crs is None:
|
||||||
raise SystemExit("Raster CRS is required")
|
raise SystemExit("Raster CRS is required")
|
||||||
@@ -104,6 +128,8 @@ def normalize(
|
|||||||
"reason": None,
|
"reason": None,
|
||||||
"geometry_repaired": False,
|
"geometry_repaired": False,
|
||||||
}
|
}
|
||||||
|
source_creation_at = _source_creation_at(source_name, properties)
|
||||||
|
decision["source_creation_at"] = source_creation_at.isoformat() if source_creation_at else None
|
||||||
try:
|
try:
|
||||||
geometry = shape(feature.get("geometry"))
|
geometry = shape(feature.get("geometry"))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -119,6 +145,8 @@ def normalize(
|
|||||||
decision["reason"] = "invalid_geometry_unrepairable"
|
decision["reason"] = "invalid_geometry_unrepairable"
|
||||||
elif (reason := _semantic_exclusion(properties)) is not None:
|
elif (reason := _semantic_exclusion(properties)) is not None:
|
||||||
decision["reason"] = reason
|
decision["reason"] = reason
|
||||||
|
elif imagery_cutoff and source_creation_at and source_creation_at > imagery_cutoff:
|
||||||
|
decision["reason"] = "created_after_imagery_period"
|
||||||
else:
|
else:
|
||||||
metric = shapely_transform(transformer.transform, geometry)
|
metric = shapely_transform(transformer.transform, geometry)
|
||||||
min_x, min_y, max_x, max_y = metric.bounds
|
min_x, min_y, max_x, max_y = metric.bounds
|
||||||
@@ -179,6 +207,7 @@ def normalize(
|
|||||||
"raster_path": str(raster_path),
|
"raster_path": str(raster_path),
|
||||||
"min_label_px": min_label_px,
|
"min_label_px": min_label_px,
|
||||||
"imagery_observed_at": imagery_observed_at,
|
"imagery_observed_at": imagery_observed_at,
|
||||||
|
"imagery_valid_to": imagery_valid_to,
|
||||||
"reference_observed_at": reference_observed_at,
|
"reference_observed_at": reference_observed_at,
|
||||||
"temporal_mismatch_days": temporal_mismatch_days,
|
"temporal_mismatch_days": temporal_mismatch_days,
|
||||||
"temporal_alignment_status": temporal_alignment_status,
|
"temporal_alignment_status": temporal_alignment_status,
|
||||||
@@ -200,6 +229,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--min-label-px", type=float, default=3.0)
|
parser.add_argument("--min-label-px", type=float, default=3.0)
|
||||||
parser.add_argument("--imagery-observed-at")
|
parser.add_argument("--imagery-observed-at")
|
||||||
parser.add_argument("--reference-observed-at")
|
parser.add_argument("--reference-observed-at")
|
||||||
|
parser.add_argument("--imagery-valid-to")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
normalized, audit = normalize(
|
normalized, audit = normalize(
|
||||||
reference_path=args.reference,
|
reference_path=args.reference,
|
||||||
@@ -208,6 +238,7 @@ def main() -> int:
|
|||||||
min_label_px=args.min_label_px,
|
min_label_px=args.min_label_px,
|
||||||
imagery_observed_at=args.imagery_observed_at,
|
imagery_observed_at=args.imagery_observed_at,
|
||||||
reference_observed_at=args.reference_observed_at,
|
reference_observed_at=args.reference_observed_at,
|
||||||
|
imagery_valid_to=args.imagery_valid_to,
|
||||||
)
|
)
|
||||||
args.output_reference.parent.mkdir(parents=True, exist_ok=True)
|
args.output_reference.parent.mkdir(parents=True, exist_ok=True)
|
||||||
args.output_audit.parent.mkdir(parents=True, exist_ok=True)
|
args.output_audit.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user