Classify operator background corpus
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-07-10 02:06:13 +02:00
parent 90048ffb4a
commit 64dac0d9b7
9 changed files with 218 additions and 16 deletions
+7
View File
@@ -7,6 +7,13 @@
# Changelog # Changelog
## Sprint 156 Background corpus classification (2026-07-10)
- Added explicit operator background categories to prepared sample manifests: `pure_empty_negative` when GRB returns zero reference buildings and `sparse_building_context` when contextual GRB buildings are present.
- Added `OPERATOR_BACKGROUND_CATEGORIES` to `scripts/run_operator_hard_negative_detection_matrix.sh` so strict default-promotion false-positive gates can run on pure-empty negatives separately from sparse-context review samples.
- Preserved `background_category` in exported YOLO tile metadata for training auditability.
- No model default, backend API, database migration, provider fetching, fake detection output or model download behavior changed.
## Sprint 155 Detection operator profiles (2026-07-09) ## Sprint 155 Detection operator profiles (2026-07-09)
- Added explicit Detection Lab operator profiles for the inactive `geointel-building-yolov8s-aoi1024bg512r3e50-pt` local model asset. - Added explicit Detection Lab operator profiles for the inactive `geointel-building-yolov8s-aoi1024bg512r3e50-pt` local model asset.
@@ -0,0 +1,99 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[2]
def load_sample_preparer():
script_path = ROOT / "scripts" / "prepare_operator_real_data_samples.py"
spec = importlib.util.spec_from_file_location("operator_sample_preparer_s156", script_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_background_samples_are_classified_by_actual_reference_density() -> None:
module = load_sample_preparer()
background = module.OperatorSample(
slug="background",
display_name="Background",
center_lon=5.0,
center_lat=51.0,
sample_role="background_candidate",
allow_empty_reference=True,
)
reference = module.OperatorSample(
slug="reference",
display_name="Reference",
center_lon=5.0,
center_lat=51.0,
)
assert module.background_category_for_sample(background, 0) == "pure_empty_negative"
assert module.background_category_for_sample(background, 3) == "sparse_building_context"
assert module.background_category_for_sample(reference, 30) == "reference_aoi"
def test_prepare_sample_manifest_records_background_category_from_cached_reference(
tmp_path: Path,
monkeypatch,
) -> None:
module = load_sample_preparer()
sample = module.OperatorSample(
slug="background",
display_name="Background",
center_lon=5.0,
center_lat=51.0,
sample_role="background_candidate",
allow_empty_reference=True,
)
raster_path, reference_path = module.sample_artifact_paths(sample, tmp_path)
raster_path.write_bytes(b"placeholder raster")
reference_path.write_text(
json.dumps(
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [5.0, 51.0]},
"properties": {},
}
],
}
),
encoding="utf-8",
)
monkeypatch.setattr(module, "raster_summary", lambda path: {"path": str(path)})
monkeypatch.setattr(module, "sample_bounds", lambda current: ((0.0, 0.0, 1.0, 1.0), [4.9, 50.9, 5.1, 51.1]))
prepared = module.prepare_sample(sample, tmp_path, force=False)
assert prepared["background_category"] == "sparse_building_context"
assert prepared["reference_feature_count"] == 1
def test_hard_negative_matrix_can_filter_background_categories() -> None:
script = (ROOT / "scripts" / "run_operator_hard_negative_detection_matrix.sh").read_text(encoding="utf-8")
assert "OPERATOR_BACKGROUND_CATEGORIES" in script
assert "background_category" in script
assert "pure_empty_negative" in script
assert "sparse_building_context" in script
assert "background_category_counts" in script
def test_yolo_tile_export_preserves_background_category_provenance() -> None:
script = (ROOT / "scripts" / "export_operator_yolo_tile_dataset.py").read_text(encoding="utf-8")
assert "background_category = str(sample.get(\"background_category\") or \"reference_aoi\")" in script
assert "\"background_category\": background_category" in script
+8 -2
View File
@@ -247,6 +247,7 @@ considered as a default:
```bash ```bash
OPERATOR_SAMPLE_MANIFEST_PATH=storage/operator-data/operator_samples_manifest.json \ OPERATOR_SAMPLE_MANIFEST_PATH=storage/operator-data/operator_samples_manifest.json \
OPERATOR_BACKGROUND_CATEGORIES="pure_empty_negative" \
OPERATOR_BACKGROUND_SAMPLE_SLUGS="postel_bos lommel_heide kasterlee_bos" \ OPERATOR_BACKGROUND_SAMPLE_SLUGS="postel_bos lommel_heide kasterlee_bos" \
QUALITY_MODEL_ASSET_IDS="geointel-building-yolov8n-expanded160e50-pt geointel-building-yolov8n-tile30-pt yolov8s-building-segmentation-pt" \ QUALITY_MODEL_ASSET_IDS="geointel-building-yolov8n-expanded160e50-pt geointel-building-yolov8n-tile30-pt yolov8s-building-segmentation-pt" \
QUALITY_TILE_SIZES="640" \ QUALITY_TILE_SIZES="640" \
@@ -257,8 +258,13 @@ bash scripts/run_operator_hard_negative_detection_matrix.sh http://192.168.10.15
The hard-negative matrix uploads only background rasters and counts detections The hard-negative matrix uploads only background rasters and counts detections
as false-positive pressure. It does not run QA/QC or invent reference metrics as false-positive pressure. It does not run QA/QC or invent reference metrics
for empty/sparse background AOIs. The first expanded local model improved dense for empty/sparse background AOIs. Operator manifests classify background
AOI F1, but Kasterlee-bos false positives block default promotion. samples as `pure_empty_negative` when GRB returns zero reference buildings and
`sparse_building_context` when contextual buildings are present. Use
`OPERATOR_BACKGROUND_CATEGORIES="pure_empty_negative"` for default-promotion
hard-negative gates, then run `sparse_building_context` as a separate review
matrix. The first expanded local model improved dense AOI F1, but Kasterlee-bos
false positives block default promotion.
The current inactive AOI1024 background-aware local model asset, The current inactive AOI1024 background-aware local model asset,
`geointel-building-yolov8s-aoi1024bg512r3e50-pt`, is exposed in Detection Lab `geointel-building-yolov8s-aoi1024bg512r3e50-pt`, is exposed in Detection Lab
+38
View File
@@ -6197,3 +6197,41 @@ Open:
- The profiles are review/demo aids only. The background corpus still needs to be split into pure-empty negatives and sparse-building contextual AOIs before retraining or recalibrating for a default detector decision. - The profiles are review/demo aids only. The background corpus still needs to be split into pure-empty negatives and sparse-building contextual AOIs before retraining or recalibrating for a default detector decision.
- No backend API contract, migration, provider fetching, fake detection output, model download behavior or active runtime default changed. - No backend API contract, migration, provider fetching, fake detection output, model download behavior or active runtime default changed.
# Sprint 156 - Background corpus classification
## What changed
- Added explicit background category classification to operator sample preparation:
- `pure_empty_negative` when a background candidate has zero GRB reference buildings.
- `sparse_building_context` when a background candidate has one or more GRB reference buildings.
- `reference_aoi` for normal positive reference samples.
- Persisted `background_category` into generated operator sample manifests and reference GeoJSON metadata.
- Added `OPERATOR_BACKGROUND_CATEGORIES` to `scripts/run_operator_hard_negative_detection_matrix.sh` so the strict default-promotion hard-negative gate can run only on `pure_empty_negative` samples, while `sparse_building_context` samples can be reviewed separately.
- Preserved `background_category` in YOLO tile export metadata so negative-tile provenance survives training dataset audits.
- Updated operator pipeline docs, TODO and changelog.
## What was tested
- Added regression coverage in `backend/tests/test_sprint156_background_corpus_classification.py`.
- Ran `python -m pytest tests/test_sprint156_background_corpus_classification.py -q`.
- Ran `python -m pytest tests/test_sprint156_background_corpus_classification.py tests/test_sprint131_operator_sample_expansion.py tests/test_sprint132_operator_hard_negative_matrix.py tests/test_sprint130_operator_yolo_tile_dataset.py -q`: 17 passed.
- Ran `python -m compileall backend/app`.
- Ran `python -m pytest` in `backend`: 439 passed.
- Ran `cd frontend && npm run typecheck`.
- Ran `cd frontend && npm run build`.
- Ran `bash scripts/run_readiness_check.sh`.
- Ran `cd backend && python -m alembic heads` and `cd backend && python -m alembic upgrade head --sql`.
- Ran `bash -n scripts/live_migration_smoke.sh` and `bash -n scripts/run_operator_hard_negative_detection_matrix.sh`.
## Known limitations
- This pass adds the cleaner corpus/gate contract only. It does not regenerate Tower manifests, retrain YOLO, rerun the live hard-negative matrices or change any model default.
- No backend API contract, database migration, provider fetching, fake detection output or model download behavior changed.
## Next recommended pass
- Redeploy/rebuild the runtime scripts, regenerate the operator sample manifest, then run:
- `OPERATOR_BACKGROUND_CATEGORIES="pure_empty_negative"` for the strict default-promotion false-positive gate.
- `OPERATOR_BACKGROUND_CATEGORIES="sparse_building_context"` for contextual review evidence.
- Retrain or recalibrate the inactive AOI1024 local model candidate only after those two matrices are available.
+2 -1
View File
@@ -121,7 +121,8 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Train and gate `geointel-building-yolov8s-aoi1024clean512e50-pt` through seven positive AOIs and nine hard-negative/background AOIs. - [x] Train and gate `geointel-building-yolov8s-aoi1024clean512e50-pt` through seven positive AOIs and nine hard-negative/background AOIs.
- [x] Train and gate background-aware `geointel-building-yolov8s-aoi1024bg512r3e50-pt`; it is the strongest positive-AOI candidate so far but remains inactive because full background-candidate false-positive pressure still blocks default promotion. - [x] Train and gate background-aware `geointel-building-yolov8s-aoi1024bg512r3e50-pt`; it is the strongest positive-AOI candidate so far but remains inactive because full background-candidate false-positive pressure still blocks default promotion.
- [x] Add explicit operator detection profiles for local model assets: balanced review around threshold `0.15` and conservative high-precision review around threshold `0.35`, both clearly marked as non-default-approved until promotion gates pass. - [x] Add explicit operator detection profiles for local model assets: balanced review around threshold `0.15` and conservative high-precision review around threshold `0.35`, both clearly marked as non-default-approved until promotion gates pass.
- [ ] Split the background corpus into pure-empty negatives and sparse-building contextual AOIs, then retrain or recalibrate against the cleaner gate. - [x] Add pure-empty versus sparse-building contextual background corpus classification to operator manifests, hard-negative matrix filters and YOLO tile provenance.
- [ ] Retrain or recalibrate against the cleaner pure-empty gate plus separate sparse-context inspection matrix.
- [ ] Promote a V1 default building detector only after it passes seven positive AOIs, clean hard-negative/background gates and persisted QA/QC evidence without fake detections or model downloads. - [ ] Promote a V1 default building detector only after it passes seven positive AOIs, clean hard-negative/background gates and persisted QA/QC evidence without fake detections or model downloads.
## Sprint 8 status ## Sprint 8 status
+12 -3
View File
@@ -187,8 +187,11 @@ Kasterlee-bos, Dessel-heide, Ravels-bos, Meerhout-bos, Geel-Bel,
Arendonk-heide and Herenthout-bos. Normal reference AOIs still fail when GRB Arendonk-heide and Herenthout-bos. Normal reference AOIs still fail when GRB
returns no buildings; background candidates are explicitly marked with returns no buildings; background candidates are explicitly marked with
`sample_role` and may write an empty reference FeatureCollection for `sample_role` and may write an empty reference FeatureCollection for
negative-tile training. The helper fetches only the explicit documented AOIs, negative-tile training. Generated manifests also classify background samples as
records Digitaal Vlaanderen attribution and reuses existing files by default. `pure_empty_negative` when GRB returns zero reference buildings or
`sparse_building_context` when GRB returns one or more contextual buildings.
The helper fetches only the explicit documented AOIs, records Digitaal
Vlaanderen attribution and reuses existing files by default.
Use `--force` only when the local runtime artifacts should be regenerated. Use `--force` only when the local runtime artifacts should be regenerated.
GRB building references are fetched through the provider's OGC API GRB building references are fetched through the provider's OGC API
`rel=next` pagination links, so dense AOIs are not silently limited to the `rel=next` pagination links, so dense AOIs are not silently limited to the
@@ -472,6 +475,7 @@ before changing model defaults:
```bash ```bash
OPERATOR_SAMPLE_MANIFEST_PATH=storage/operator-data/operator_samples_manifest.json \ OPERATOR_SAMPLE_MANIFEST_PATH=storage/operator-data/operator_samples_manifest.json \
OPERATOR_BACKGROUND_CATEGORIES="pure_empty_negative" \
OPERATOR_BACKGROUND_SAMPLE_SLUGS="postel_bos lommel_heide kasterlee_bos dessel_heide ravels_bos meerhout_bos geel_bel arendonk_heide herenthout_bos" \ OPERATOR_BACKGROUND_SAMPLE_SLUGS="postel_bos lommel_heide kasterlee_bos dessel_heide ravels_bos meerhout_bos geel_bel arendonk_heide herenthout_bos" \
QUALITY_MODEL_ASSET_IDS="geointel-building-yolov8n-expanded160e50-pt geointel-building-yolov8n-tile30-pt yolov8s-building-segmentation-pt" \ QUALITY_MODEL_ASSET_IDS="geointel-building-yolov8n-expanded160e50-pt geointel-building-yolov8n-tile30-pt yolov8s-building-segmentation-pt" \
QUALITY_TILE_SIZES="640" \ QUALITY_TILE_SIZES="640" \
@@ -485,7 +489,12 @@ The hard-negative matrix uploads only the background raster, generates tiles,
runs configured-YOLO detection and counts persisted detections as runs configured-YOLO detection and counts persisted detections as
`false_positive_pressure`. It does not upload a reference vector and does not `false_positive_pressure`. It does not upload a reference vector and does not
run QA/QC, because empty or sparse background AOIs do not have a meaningful run QA/QC, because empty or sparse background AOIs do not have a meaningful
precision/recall target. In the first live run, `geointel-building-yolov8n-expanded160e50-pt` precision/recall target. Use `OPERATOR_BACKGROUND_CATEGORIES="pure_empty_negative"`
for the strict default-promotion false-positive gate. Run
`OPERATOR_BACKGROUND_CATEGORIES="sparse_building_context"` separately for
contextual review; sparse-context detections should be inspected, not counted
as fake precision/recall metrics. In the first live run,
`geointel-building-yolov8n-expanded160e50-pt`
was clean on Postel-bos and Lommel-heide at thresholds `0.25` and `0.15`, but was clean on Postel-bos and Lommel-heide at thresholds `0.25` and `0.15`, but
produced 38 detections on Kasterlee-bos even at `0.25`. That blocks it from produced 38 detections on Kasterlee-bos even at `0.25`. That blocks it from
becoming a V1 default until a hard-negative-balanced candidate improves. becoming a V1 default until a hard-negative-balanced candidate improves.
@@ -315,6 +315,7 @@ def export_sample_tiles(
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
sample_slug = str(sample["sample_slug"]) sample_slug = str(sample["sample_slug"])
sample_role = str(sample.get("sample_role") or "reference") sample_role = str(sample.get("sample_role") or "reference")
background_category = str(sample.get("background_category") or "reference_aoi")
split = "val" if sample_slug.lower() in val_slugs else "train" split = "val" if sample_slug.lower() in val_slugs else "train"
raster_path = resolve_manifest_path(str(sample["raster_path"]), manifest_path) raster_path = resolve_manifest_path(str(sample["raster_path"]), manifest_path)
reference_path = resolve_manifest_path(str(sample["reference_path"]), manifest_path) reference_path = resolve_manifest_path(str(sample["reference_path"]), manifest_path)
@@ -366,6 +367,7 @@ def export_sample_tiles(
{ {
"sample_slug": sample_slug, "sample_slug": sample_slug,
"sample_role": sample_role, "sample_role": sample_role,
"background_category": background_category,
"split": split, "split": split,
"tile_index": tile_index, "tile_index": tile_index,
"repeat_index": repeat_index, "repeat_index": repeat_index,
+15 -1
View File
@@ -22,6 +22,9 @@ GRB_GBG_URL = "https://geo.api.vlaanderen.be/GRB/ogc/features/v1/collections/GBG
DEFAULT_OUTPUT_DIR = Path("/app/storage/operator-data") DEFAULT_OUTPUT_DIR = Path("/app/storage/operator-data")
DEFAULT_GRB_PAGE_LIMIT = 1000 DEFAULT_GRB_PAGE_LIMIT = 1000
DEFAULT_GRB_MAX_FEATURES = 100000 DEFAULT_GRB_MAX_FEATURES = 100000
REFERENCE_AOI_CATEGORY = "reference_aoi"
PURE_EMPTY_BACKGROUND_CATEGORY = "pure_empty_negative"
SPARSE_BACKGROUND_CATEGORY = "sparse_building_context"
requests: Any = None requests: Any = None
rasterio: Any = None rasterio: Any = None
Transformer: Any = None Transformer: Any = None
@@ -261,6 +264,12 @@ def selected_samples(raw: str) -> list[OperatorSample]:
return [SAMPLES[slug] for slug in slugs] return [SAMPLES[slug] for slug in slugs]
def background_category_for_sample(sample: OperatorSample, reference_feature_count: int) -> str:
if sample.sample_role != "background_candidate":
return REFERENCE_AOI_CATEGORY
return PURE_EMPTY_BACKGROUND_CATEGORY if reference_feature_count <= 0 else SPARSE_BACKGROUND_CATEGORY
def apply_sample_overrides( def apply_sample_overrides(
sample: OperatorSample, sample: OperatorSample,
*, *,
@@ -471,6 +480,7 @@ def fetch_reference(
reference["sample_slug"] = sample.slug reference["sample_slug"] = sample.slug
reference["sample_role"] = sample.sample_role reference["sample_role"] = sample.sample_role
reference["allow_empty_reference"] = sample.allow_empty_reference reference["allow_empty_reference"] = sample.allow_empty_reference
reference["background_category"] = background_category_for_sample(sample, len(features))
reference["reference_page_limit"] = page_limit reference["reference_page_limit"] = page_limit
reference["reference_max_features"] = max_features reference["reference_max_features"] = max_features
reference["reference_pages_fetched"] = len(pages) reference["reference_pages_fetched"] = len(pages)
@@ -484,6 +494,7 @@ def fetch_reference(
props.setdefault("reference_layer_name", "buildings") props.setdefault("reference_layer_name", "buildings")
props.setdefault("sample_slug", sample.slug) props.setdefault("sample_slug", sample.slug)
props.setdefault("sample_role", sample.sample_role) props.setdefault("sample_role", sample.sample_role)
props.setdefault("background_category", background_category_for_sample(sample, len(features)))
reference_path.write_text(json.dumps(reference, ensure_ascii=False), encoding="utf-8") reference_path.write_text(json.dumps(reference, ensure_ascii=False), encoding="utf-8")
return prepared_url(GRB_GBG_URL, ogc_params), len(features) return prepared_url(GRB_GBG_URL, ogc_params), len(features)
@@ -513,6 +524,7 @@ def prepare_sample(
) )
else: else:
reference_feature_count = geojson_feature_count(reference_path) reference_feature_count = geojson_feature_count(reference_path)
background_category = background_category_for_sample(sample, reference_feature_count)
return { return {
"sample_slug": sample.slug, "sample_slug": sample.slug,
@@ -524,6 +536,7 @@ def prepare_sample(
"height": sample.height, "height": sample.height,
"sample_role": sample.sample_role, "sample_role": sample.sample_role,
"allow_empty_reference": sample.allow_empty_reference, "allow_empty_reference": sample.allow_empty_reference,
"background_category": background_category,
"raster_path": str(ortho_path), "raster_path": str(ortho_path),
"reference_path": str(reference_path), "reference_path": str(reference_path),
"reference_feature_count": reference_feature_count, "reference_feature_count": reference_feature_count,
@@ -558,7 +571,8 @@ def write_readme(output_dir: Path, samples: list[dict[str, Any]]) -> None:
lines.append( lines.append(
f"- `{sample['sample_slug']}`: `{Path(sample['raster_path']).name}` and " f"- `{sample['sample_slug']}`: `{Path(sample['raster_path']).name}` and "
f"`{Path(sample['reference_path']).name}`, " f"`{Path(sample['reference_path']).name}`, "
f"{sample['reference_feature_count']} reference features, role `{sample['sample_role']}`." f"{sample['reference_feature_count']} reference features, role `{sample['sample_role']}`, "
f"background category `{sample['background_category']}`."
) )
lines.append("") lines.append("")
lines.append("Purpose: configured-YOLO detection + persisted QA/QC validation with operator-provided files.") lines.append("Purpose: configured-YOLO detection + persisted QA/QC validation with operator-provided files.")
@@ -13,6 +13,7 @@ Usage:
Optional environment: Optional environment:
OPERATOR_SAMPLE_MANIFEST_PATH Manifest from prepare_operator_real_data_samples.py. OPERATOR_SAMPLE_MANIFEST_PATH Manifest from prepare_operator_real_data_samples.py.
OPERATOR_BACKGROUND_SAMPLE_SLUGS Optional comma/space separated filter. Defaults to samples marked background_candidate or allow_empty_reference. OPERATOR_BACKGROUND_SAMPLE_SLUGS Optional comma/space separated filter. Defaults to samples marked background_candidate or allow_empty_reference.
OPERATOR_BACKGROUND_CATEGORIES Optional comma/space separated filter, e.g. pure_empty_negative or sparse_building_context.
HARD_NEGATIVE_OUTPUT_DIR Output directory, default: artifacts/detection-hard-negatives/<timestamp>. HARD_NEGATIVE_OUTPUT_DIR Output directory, default: artifacts/detection-hard-negatives/<timestamp>.
QUALITY_MODEL_ASSET_IDS Space/comma separated local model asset IDs. Default: active configured model asset. QUALITY_MODEL_ASSET_IDS Space/comma separated local model asset IDs. Default: active configured model asset.
QUALITY_TILE_SIZES Space/comma separated raster tile sizes, default: 640. QUALITY_TILE_SIZES Space/comma separated raster tile sizes, default: 640.
@@ -33,6 +34,7 @@ cd "$ROOT"
BASE_URL="${1:-${GE_INTEL_BASE_URL:-http://localhost:1202}}" BASE_URL="${1:-${GE_INTEL_BASE_URL:-http://localhost:1202}}"
OPERATOR_SAMPLE_MANIFEST_PATH="${OPERATOR_SAMPLE_MANIFEST_PATH:-storage/operator-data/operator_samples_manifest.json}" OPERATOR_SAMPLE_MANIFEST_PATH="${OPERATOR_SAMPLE_MANIFEST_PATH:-storage/operator-data/operator_samples_manifest.json}"
OPERATOR_BACKGROUND_SAMPLE_SLUGS="${OPERATOR_BACKGROUND_SAMPLE_SLUGS:-}" OPERATOR_BACKGROUND_SAMPLE_SLUGS="${OPERATOR_BACKGROUND_SAMPLE_SLUGS:-}"
OPERATOR_BACKGROUND_CATEGORIES="${OPERATOR_BACKGROUND_CATEGORIES:-}"
HARD_NEGATIVE_OUTPUT_DIR="${HARD_NEGATIVE_OUTPUT_DIR:-artifacts/detection-hard-negatives/$(date -u +%Y%m%dT%H%M%SZ)}" HARD_NEGATIVE_OUTPUT_DIR="${HARD_NEGATIVE_OUTPUT_DIR:-artifacts/detection-hard-negatives/$(date -u +%Y%m%dT%H%M%SZ)}"
QUALITY_MODEL_ASSET_IDS="${QUALITY_MODEL_ASSET_IDS:-${REAL_MODEL_ASSET_ID:-__active__}}" QUALITY_MODEL_ASSET_IDS="${QUALITY_MODEL_ASSET_IDS:-${REAL_MODEL_ASSET_ID:-__active__}}"
QUALITY_TILE_SIZES="${QUALITY_TILE_SIZES:-640}" QUALITY_TILE_SIZES="${QUALITY_TILE_SIZES:-640}"
@@ -79,6 +81,7 @@ matrix_manifest="${HARD_NEGATIVE_OUTPUT_DIR}/hard_negative_requests.tsv"
"${ROOT}" \ "${ROOT}" \
"${OPERATOR_SAMPLE_MANIFEST_PATH}" \ "${OPERATOR_SAMPLE_MANIFEST_PATH}" \
"${OPERATOR_BACKGROUND_SAMPLE_SLUGS}" \ "${OPERATOR_BACKGROUND_SAMPLE_SLUGS}" \
"${OPERATOR_BACKGROUND_CATEGORIES}" \
"${sample_manifest_tsv}" <<'PY' "${sample_manifest_tsv}" <<'PY'
import json import json
import sys import sys
@@ -87,7 +90,8 @@ from pathlib import Path
root = Path(sys.argv[1]).resolve() root = Path(sys.argv[1]).resolve()
manifest_path = Path(sys.argv[2]) manifest_path = Path(sys.argv[2])
slug_filter_raw = sys.argv[3] slug_filter_raw = sys.argv[3]
output_path = Path(sys.argv[4]) category_filter_raw = sys.argv[4]
output_path = Path(sys.argv[5])
payload = json.loads(manifest_path.read_text(encoding="utf-8-sig")) payload = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
samples = payload.get("samples") or [] samples = payload.get("samples") or []
@@ -99,6 +103,11 @@ requested_slugs = {
for value in slug_filter_raw.replace(",", " ").split() for value in slug_filter_raw.replace(",", " ").split()
if value.strip() if value.strip()
} }
requested_categories = {
value.strip().lower()
for value in category_filter_raw.replace(",", " ").split()
if value.strip()
}
def resolve_path(raw: str) -> str: def resolve_path(raw: str) -> str:
@@ -129,11 +138,22 @@ with output_path.open("w", encoding="utf-8") as handle:
continue continue
raster_path = resolve_path(str(sample.get("raster_path") or "")) raster_path = resolve_path(str(sample.get("raster_path") or ""))
reference_count = int(sample.get("reference_feature_count") or 0) reference_count = int(sample.get("reference_feature_count") or 0)
handle.write(f"{sample_slug}\t{raster_path}\t{sample_role}\t{allow_empty_reference}\t{reference_count}\n") background_category = str(sample.get("background_category") or "").lower()
if not background_category:
if sample_role == "background_candidate" or allow_empty_reference:
background_category = "pure_empty_negative" if reference_count == 0 else "sparse_building_context"
else:
background_category = "reference_aoi"
if requested_categories and background_category not in requested_categories:
continue
handle.write(
f"{sample_slug}\t{raster_path}\t{sample_role}\t{allow_empty_reference}\t"
f"{reference_count}\t{background_category}\n"
)
selected += 1 selected += 1
if selected == 0: if selected == 0:
raise SystemExit("No background_candidate samples matched OPERATOR_BACKGROUND_SAMPLE_SLUGS") raise SystemExit("No background_candidate samples matched OPERATOR_BACKGROUND_SAMPLE_SLUGS/OPERATOR_BACKGROUND_CATEGORIES")
PY PY
model_requests_normalized="$(printf '%s' "${QUALITY_MODEL_ASSET_IDS}" | tr ',' ' ')" model_requests_normalized="$(printf '%s' "${QUALITY_MODEL_ASSET_IDS}" | tr ',' ' ')"
@@ -236,14 +256,15 @@ echo "== GeoIntel operator hard-negative detection matrix =="
echo "Base URL: ${BASE_URL}" echo "Base URL: ${BASE_URL}"
echo "Manifest: ${OPERATOR_SAMPLE_MANIFEST_PATH}" echo "Manifest: ${OPERATOR_SAMPLE_MANIFEST_PATH}"
echo "Background filter: ${OPERATOR_BACKGROUND_SAMPLE_SLUGS:-background_candidate samples}" echo "Background filter: ${OPERATOR_BACKGROUND_SAMPLE_SLUGS:-background_candidate samples}"
echo "Background categories: ${OPERATOR_BACKGROUND_CATEGORIES:-all}"
echo "Models: ${model_requests_normalized}" echo "Models: ${model_requests_normalized}"
echo "Tile sizes: ${tile_sizes_normalized}" echo "Tile sizes: ${tile_sizes_normalized}"
echo "Tile overlaps: ${tile_overlaps_normalized}" echo "Tile overlaps: ${tile_overlaps_normalized}"
echo "Thresholds: ${thresholds_normalized}" echo "Thresholds: ${thresholds_normalized}"
echo "Output: ${HARD_NEGATIVE_OUTPUT_DIR}" echo "Output: ${HARD_NEGATIVE_OUTPUT_DIR}"
while IFS=$'\t' read -r sample_slug raster_path sample_role allow_empty_reference reference_feature_count; do while IFS=$'\t' read -r sample_slug raster_path sample_role allow_empty_reference reference_feature_count background_category; do
echo "-- Background sample ${sample_slug}: role=${sample_role} allow_empty_reference=${allow_empty_reference} reference_features=${reference_feature_count} --" echo "-- Background sample ${sample_slug}: role=${sample_role} category=${background_category} allow_empty_reference=${allow_empty_reference} reference_features=${reference_feature_count} --"
while IFS=$'\t' read -r model_request tile_size tile_overlap threshold run_label; do while IFS=$'\t' read -r model_request tile_size tile_overlap threshold run_label; do
sample_output_dir="${HARD_NEGATIVE_OUTPUT_DIR}/${sample_slug}" sample_output_dir="${HARD_NEGATIVE_OUTPUT_DIR}/${sample_slug}"
mkdir -p "${sample_output_dir}" mkdir -p "${sample_output_dir}"
@@ -379,6 +400,7 @@ PY
"${sample_role}" \ "${sample_role}" \
"${allow_empty_reference}" \ "${allow_empty_reference}" \
"${reference_feature_count}" \ "${reference_feature_count}" \
"${background_category}" \
"${model_request}" \ "${model_request}" \
"${model_asset_id}" \ "${model_asset_id}" \
"${tile_size}" \ "${tile_size}" \
@@ -401,6 +423,7 @@ import sys
sample_role, sample_role,
allow_empty_reference, allow_empty_reference,
reference_feature_count, reference_feature_count,
background_category,
model_request, model_request,
model_asset_id, model_asset_id,
tile_size, tile_size,
@@ -414,7 +437,7 @@ import sys
detections_list_count, detections_list_count,
tile_count, tile_count,
run_log, run_log,
) = sys.argv[1:19] ) = sys.argv[1:20]
detections = int(detection_count) detections = int(detection_count)
listed = int(detections_list_count) listed = int(detections_list_count)
@@ -426,6 +449,7 @@ summary = {
"sample_role": sample_role, "sample_role": sample_role,
"allow_empty_reference": allow_empty_reference == "True", "allow_empty_reference": allow_empty_reference == "True",
"reference_feature_count": int(reference_feature_count), "reference_feature_count": int(reference_feature_count),
"background_category": background_category,
"model_request": model_request, "model_request": model_request,
"model_asset_id": model_asset_id, "model_asset_id": model_asset_id,
"tile_size": int(tile_size), "tile_size": int(tile_size),
@@ -443,7 +467,7 @@ summary = {
with open(output_path, "w", encoding="utf-8") as handle: with open(output_path, "w", encoding="utf-8") as handle:
json.dump(summary, handle, indent=2, sort_keys=True) json.dump(summary, handle, indent=2, sort_keys=True)
print( print(
"sample={sample_slug} model={model_asset_id} tile={tile_size} overlap={tile_overlap} " "sample={sample_slug} category={background_category} model={model_asset_id} tile={tile_size} overlap={tile_overlap} "
"threshold={threshold} detections={detection_count} false_positive_pressure={false_positive_pressure}".format( "threshold={threshold} detections={detection_count} false_positive_pressure={false_positive_pressure}".format(
**summary **summary
) )
@@ -460,6 +484,7 @@ done < "${sample_manifest_tsv}"
import glob import glob
import json import json
import sys import sys
from collections import Counter
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -486,6 +511,7 @@ summary = {
"base_url": base_url, "base_url": base_url,
"operator_sample_manifest_path": manifest_path, "operator_sample_manifest_path": manifest_path,
"sample_count": len({item["sample_slug"] for item in items}), "sample_count": len({item["sample_slug"] for item in items}),
"background_category_counts": dict(Counter(str(item.get("background_category") or "unknown") for item in items)),
"run_count": len(items), "run_count": len(items),
"best_by_lowest_pressure": best_by_lowest_pressure, "best_by_lowest_pressure": best_by_lowest_pressure,
"items": items, "items": items,
@@ -495,10 +521,10 @@ summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True), encoding=
print("") print("")
print("Operator hard-negative detection summary") print("Operator hard-negative detection summary")
print("sample\tmodel\ttile\toverlap\tthreshold\tdetections\tfalse_positive_pressure") print("sample\tcategory\tmodel\ttile\toverlap\tthreshold\tdetections\tfalse_positive_pressure")
for item in items: for item in items:
print( print(
"{sample_slug}\t{model_asset_id}\t{tile_size}\t{tile_overlap}\t{threshold:.2f}\t{detection_count}\t{false_positive_pressure}".format( "{sample_slug}\t{background_category}\t{model_asset_id}\t{tile_size}\t{tile_overlap}\t{threshold:.2f}\t{detection_count}\t{false_positive_pressure}".format(
**item **item
) )
) )