From e607cbe724603f27740e8a199131769223401657 Mon Sep 17 00:00:00 2001 From: Jens Date: Mon, 27 Jul 2026 02:29:14 +0200 Subject: [PATCH] Add fail-closed Belgian training loop --- .../test_belgium_candidate_evaluation.py | 24 +++ ...t_belgium_training_iteration_assessment.py | 26 +++ .../tests/test_belgium_training_portfolio.py | 6 +- deploy/unraid/Dockerfile.all-in-one | 2 + docs/BELGIUM_BUILDING_TRAINING_LOOP.md | 79 +++++++++ docs/CODEX_EXECUTION_LOG.md | 23 +++ docs/TODO.md | 4 + ...ess_belgium_building_training_iteration.py | 91 +++++++++++ .../evaluate_belgium_building_candidate.py | 151 ++++++++++++++++++ ...ion_belgium_building_training_portfolio.py | 18 +++ ...r_operator_yolo_label_qa_contact_sheets.py | 4 +- 11 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_belgium_candidate_evaluation.py create mode 100644 backend/tests/test_belgium_training_iteration_assessment.py create mode 100644 docs/BELGIUM_BUILDING_TRAINING_LOOP.md create mode 100644 scripts/assess_belgium_building_training_iteration.py create mode 100644 scripts/evaluate_belgium_building_candidate.py diff --git a/backend/tests/test_belgium_candidate_evaluation.py b/backend/tests/test_belgium_candidate_evaluation.py new file mode 100644 index 00000000..8e59f89e --- /dev/null +++ b/backend/tests/test_belgium_candidate_evaluation.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +SCRIPT = Path(__file__).parents[2] / "scripts" / "evaluate_belgium_building_candidate.py" +SPEC = importlib.util.spec_from_file_location("candidate_evaluation", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def test_iou_and_one_to_one_matching() -> None: + reference = [(0.0, 0.0, 10.0, 10.0)] + predictions = [((0.0, 0.0, 10.0, 10.0), 0.9), ((0.0, 0.0, 10.0, 10.0), 0.8)] + assert MODULE.iou(reference[0], reference[0]) == 1.0 + assert MODULE.match_boxes(predictions, reference, confidence=0.25, match_iou=0.5) == (1, 1, 0) + + +def test_empty_reference_counts_false_positives() -> None: + predictions = [((0.0, 0.0, 10.0, 10.0), 0.4)] + assert MODULE.match_boxes(predictions, [], confidence=0.25, match_iou=0.5) == (0, 1, 0) + assert MODULE.match_boxes(predictions, [], confidence=0.5, match_iou=0.5) == (0, 0, 0) diff --git a/backend/tests/test_belgium_training_iteration_assessment.py b/backend/tests/test_belgium_training_iteration_assessment.py new file mode 100644 index 00000000..7c76099c --- /dev/null +++ b/backend/tests/test_belgium_training_iteration_assessment.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +SCRIPT = Path(__file__).parents[2] / "scripts" / "assess_belgium_building_training_iteration.py" +SPEC = importlib.util.spec_from_file_location("iteration_assessment", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def test_calibration_selection_prefers_worst_region_then_aggregate() -> None: + report = { + "sweeps": [ + {"threshold": 0.1, "pure_empty_false_positives": 0, "aggregate": {"f1": 0.8}, "regions": {"a": {"f1": 0.2}}}, + {"threshold": 0.2, "pure_empty_false_positives": 0, "aggregate": {"f1": 0.6}, "regions": {"a": {"f1": 0.5}}}, + ] + } + assert MODULE.select_calibration_threshold(report)["threshold"] == 0.2 + + +def test_threshold_lookup_is_exact() -> None: + report = {"sweeps": [{"threshold": 0.25, "aggregate": {}}]} + assert MODULE.find_threshold(report, 0.25)["threshold"] == 0.25 diff --git a/backend/tests/test_belgium_training_portfolio.py b/backend/tests/test_belgium_training_portfolio.py index 69d515a0..41a6421f 100644 --- a/backend/tests/test_belgium_training_portfolio.py +++ b/backend/tests/test_belgium_training_portfolio.py @@ -19,10 +19,10 @@ def test_portfolio_covers_every_region_split_and_context_family() -> None: assert len({aoi.slug for aoi in module.AOIS}) == len(module.AOIS) counts = Counter((aoi.region, aoi.split) for aoi in module.AOIS) for region in module.REGION_CONTRACT: - assert counts[(region, "train")] >= 6 + assert counts[(region, "train")] >= 10 assert counts[(region, "val")] >= 2 - assert counts[(region, "calibration")] >= 2 - assert counts[(region, "test")] >= 2 + assert counts[(region, "calibration")] >= 3 + assert counts[(region, "test")] >= 3 assert counts[(region, "background-test")] >= 2 diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one index ba7b3f60..d607999b 100644 --- a/deploy/unraid/Dockerfile.all-in-one +++ b/deploy/unraid/Dockerfile.all-in-one @@ -124,6 +124,8 @@ COPY scripts/normalize_belgium_building_labels.py /app/scripts/normalize_belgium COPY scripts/assemble_belgium_building_corpus.py /app/scripts/assemble_belgium_building_corpus.py COPY scripts/provision_belgium_building_training_portfolio.py /app/scripts/provision_belgium_building_training_portfolio.py COPY scripts/audit_belgium_building_corpus.py /app/scripts/audit_belgium_building_corpus.py +COPY scripts/evaluate_belgium_building_candidate.py /app/scripts/evaluate_belgium_building_candidate.py +COPY scripts/assess_belgium_building_training_iteration.py /app/scripts/assess_belgium_building_training_iteration.py COPY scripts/audit_operator_yolo_dataset_quality.py /app/scripts/audit_operator_yolo_dataset_quality.py COPY scripts/render_operator_yolo_label_qa_contact_sheets.py /app/scripts/render_operator_yolo_label_qa_contact_sheets.py COPY scripts/train_operator_yolo_detector.sh /app/scripts/train_operator_yolo_detector.sh diff --git a/docs/BELGIUM_BUILDING_TRAINING_LOOP.md b/docs/BELGIUM_BUILDING_TRAINING_LOOP.md new file mode 100644 index 00000000..e4885bbe --- /dev/null +++ b/docs/BELGIUM_BUILDING_TRAINING_LOOP.md @@ -0,0 +1,79 @@ +# Belgian building detector: closed training loop + +## Meaning of complete + +`100% trained` means that every frozen release gate below passes. It does not +mean a fabricated 100% precision, recall or mAP score. A model that memorises a +small test set is not complete. + +The loop is: + +1. provision new, spatially independent AOIs from governed official services; +2. freeze imagery, labels, metadata and checksums into a new corpus version; +3. reject invalid, duplicate and sub-resolution labels and run spatial-leakage + checks; +4. train only on the train split with CUDA on the Tower NVIDIA GPU; +5. use validation for early stopping and calibration only for threshold choice; +6. evaluate the fixed threshold once on regional test and background-test data; +7. attribute false positives and false negatives to a region, AOI and context; +8. add new training-only examples for the observed failure modes and repeat; +9. stop only when every objective gate passes; request human review afterward. + +Protected calibration, test and background-test AOIs never become training +data. A new iteration adds independent training AOIs instead. + +## Frozen release gates + +| Area | Gate | +| --- | --- | +| Runtime | CUDA required; NVIDIA device visible; no CPU fallback | +| Corpus | Immutable manifest and artifacts with SHA-256 evidence | +| Geographic composition | Each land region has at least 10 train, 2 val, 3 calibration, 3 test and 2 background-test AOIs | +| Contexts | Dense urban, suburban, rural, industrial and difficult negative contexts represented | +| Leakage | No intersecting AOIs across protected split roles | +| Label integrity | No malformed rows; sub-resolution labels explicitly rejected | +| Temporal truth | Unknown per-pixel dates remain unknown; acquisition dates may not masquerade as observation dates | +| Threshold selection | Calibration set only; maximise the worst regional F1 before aggregate F1 | +| Test aggregate | F1 at least 0.55 at the frozen footprint/detection match IoU 0.25 contract | +| Regional test | Every region: F1 at least 0.45, precision at least 0.50 and recall at least 0.40 | +| Pure background | Zero detections on every pure-empty tile at the selected threshold | +| Production | Exact candidate checksum and fail-closed promotion report required | +| Final review | Human accepts every queued AOI contact sheet after all automated gates pass | + +These are minimum release gates, not performance targets. Raising a confidence +threshold until detections disappear cannot pass because regional recall is a +simultaneous gate. + +## Current gap inventory + +The v3 corpus closes basic composition and leakage gaps with 60 independent +AOIs and 10,262 accepted building labels. It adds coastal, ribbon-development, +farmland, park, forest and additional urban contexts. Its remaining known gaps +are: + +- all rolling regional mosaics have an unknown exact per-pixel observation + date; this is recorded honestly and must be resolved through dated provider + products or change-aware label review, never inferred from download time; +- GRB/PICC/UrbIS describe ground footprints, whereas visible roofs can remain + displaced. The existing detector QA contract therefore uses IoU 0.25; the + threshold is frozen and cannot be relaxed per candidate; +- the first loop candidate generalises poorly in Flanders and Wallonia, + especially Mechelen, Sint-Niklaas, Leuven, Mons and dense PICC areas; +- sparse hard contexts pass the empty-image test more easily than dense urban + recall, so both gates must remain independent; +- building boxes are a valid first detector contract, but footprint-perfect + geometry ultimately requires a separately validated segmentation model. + +The active production model remains unchanged while any gate fails. + +## Reproducible evidence + +- corpus assembler: `scripts/assemble_belgium_building_corpus.py`; +- corpus auditor: `scripts/audit_belgium_building_corpus.py`; +- tile exporter/auditor/contact sheets: the `operator_yolo` scripts; +- per-AOI evaluator: `scripts/evaluate_belgium_building_candidate.py`; +- calibration-only selection and release gates: + `scripts/assess_belgium_building_training_iteration.py`. + +Every failed assessment returns `continue_training_loop`. Only a report with +`training_complete` may proceed to final human review and guarded activation. diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 96052750..753c3415 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -11531,3 +11531,26 @@ Next gate: production gate, so it was not promoted. The active model remains unchanged. The deterministic audit status is `needs_human_review`: an AI-assisted visual inspection cannot be represented as the required human approval. + +## 2026-07-27 - Closed national training loop and corpus v3 + +- Defined a fail-closed completion contract: calibration selects a threshold by + worst-region F1, while independent regional test and pure-empty background + sets decide completion. Passing requires aggregate F1 0.55, every region F1 + 0.45/precision 0.50/recall 0.40 and zero pure-empty detections. +- Added deterministic per-AOI IoU matching and iteration assessment scripts. + Protected AOIs remain excluded from training and a failed assessment emits + `continue_training_loop` rather than a success-shaped result. +- Expanded the governed portfolio from 42 to 60 AOIs: per region 10 train, two + validation, three calibration, three test and two background-test samples. + Frozen corpus `building-be-v3-20260727-r1` contains 10,262 accepted labels; + manifest SHA-256 is + `299212d1b3881330a6e3e936836d279435ab80c156a012121fe566ad0f3eae22`. +- Corpus composition and spatial leakage pass. All 60 mosaics retain explicit + unknown per-pixel observation time; no download timestamp is used as a false + alignment claim. +- The first 97-epoch loop candidate improved validation mAP50 to `0.250` and + mAP50-95 to `0.0835`, but the strict regional assessment failed, particularly + for Flanders and Wallonia, and recorded one pure-empty false positive at the + calibration-selected threshold. Training therefore continued on v3; no model + was promoted and human review remains intentionally deferred. diff --git a/docs/TODO.md b/docs/TODO.md index 34bb2b96..a72720e0 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -950,3 +950,7 @@ This file now starts with the current implementation status. Older preparation/b - [x] Expand each region/context/split until the national minimum-composition gate passes (42 independent 256 m AOIs at 25 cm, including pure-background and hard-negative contexts). - [x] Run explicit spatial leakage and independent calibration/test evaluation. - [ ] Promote only if every regional and pure-background gate passes. +- [x] Freeze the objective train/evaluate/error-analysis loop and regional exit gates. +- [x] Expand the second corpus wave to 60 independent AOIs and 10,262 accepted labels. +- [ ] Resolve the v3 Flanders and Wallonia generalisation failures through additional training-only evidence and retraining. +- [ ] Replace unknown rolling-mosaic observation time with governed dated imagery where the regional provider exposes it; otherwise retain the explicit temporal limitation. diff --git a/scripts/assess_belgium_building_training_iteration.py b/scripts/assess_belgium_building_training_iteration.py new file mode 100644 index 00000000..592b4d76 --- /dev/null +++ b/scripts/assess_belgium_building_training_iteration.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Select on calibration evidence and apply fixed, fail-closed release gates.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +def load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def find_threshold(report: dict[str, Any], threshold: float) -> dict[str, Any]: + for item in report["sweeps"]: + if abs(float(item["threshold"]) - threshold) < 1e-9: + return item + raise ValueError(f"Threshold {threshold} is absent from {report.get('summary')}") + + +def select_calibration_threshold(report: dict[str, Any]) -> dict[str, Any]: + eligible = [item for item in report["sweeps"] if item["pure_empty_false_positives"] == 0] + if not eligible: + eligible = report["sweeps"] + return max( + eligible, + key=lambda item: ( + min(region["f1"] for region in item["regions"].values()), + item["aggregate"]["f1"], + -item["pure_empty_false_positives"], + ), + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--calibration", type=Path, required=True) + parser.add_argument("--test", type=Path, required=True) + parser.add_argument("--background", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--min-aggregate-f1", type=float, default=0.55) + parser.add_argument("--min-region-f1", type=float, default=0.45) + parser.add_argument("--min-region-precision", type=float, default=0.5) + parser.add_argument("--min-region-recall", type=float, default=0.4) + parser.add_argument("--max-pure-empty-fp", type=int, default=0) + args = parser.parse_args() + + calibration = load(args.calibration) + chosen = select_calibration_threshold(calibration) + threshold = float(chosen["threshold"]) + test = find_threshold(load(args.test), threshold) + background = find_threshold(load(args.background), threshold) + failures: list[str] = [] + if test["aggregate"]["f1"] < args.min_aggregate_f1: + failures.append("test_aggregate_f1_below_gate") + for region, values in test["regions"].items(): + if values["f1"] < args.min_region_f1: + failures.append(f"test_{region}_f1_below_gate") + if values["precision"] < args.min_region_precision: + failures.append(f"test_{region}_precision_below_gate") + if values["recall"] < args.min_region_recall: + failures.append(f"test_{region}_recall_below_gate") + if background["pure_empty_false_positives"] > args.max_pure_empty_fp: + failures.append("pure_empty_false_positive_gate_failed") + payload = { + "schema_version": 1, + "status": "training_complete" if not failures else "continue_training_loop", + "threshold_selection_source": "calibration_only", + "selected_threshold": threshold, + "gates": { + "min_aggregate_f1": args.min_aggregate_f1, + "min_region_f1": args.min_region_f1, + "min_region_precision": args.min_region_precision, + "min_region_recall": args.min_region_recall, + "max_pure_empty_false_positives": args.max_pure_empty_fp, + }, + "calibration": chosen, + "test": test, + "background": background, + "failures": failures, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(json.dumps(payload, indent=2)) + return 0 if not failures else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/evaluate_belgium_building_candidate.py b/scripts/evaluate_belgium_building_candidate.py new file mode 100644 index 00000000..e313af61 --- /dev/null +++ b/scripts/evaluate_belgium_building_candidate.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Evaluate one building detector without using protected data for tuning.""" + +from __future__ import annotations + +import argparse +import json +from collections import defaultdict +from pathlib import Path +from typing import Any + + +def iou(left: tuple[float, float, float, float], right: tuple[float, float, float, float]) -> float: + x1, y1 = max(left[0], right[0]), max(left[1], right[1]) + x2, y2 = min(left[2], right[2]), min(left[3], right[3]) + intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1) + union = (left[2] - left[0]) * (left[3] - left[1]) + (right[2] - right[0]) * ( + right[3] - right[1] + ) - intersection + return intersection / union if union > 0 else 0.0 + + +def match_boxes( + predictions: list[tuple[tuple[float, float, float, float], float]], + references: list[tuple[float, float, float, float]], + *, + confidence: float, + match_iou: float, +) -> tuple[int, int, int]: + unmatched = set(range(len(references))) + true_positive = 0 + considered = sorted((item for item in predictions if item[1] >= confidence), key=lambda item: -item[1]) + for box, _score in considered: + candidates = [(iou(box, references[index]), index) for index in unmatched] + best_iou, best_index = max(candidates, default=(0.0, -1)) + if best_iou >= match_iou: + unmatched.remove(best_index) + true_positive += 1 + return true_positive, len(considered) - true_positive, len(unmatched) + + +def metrics(tp: int, fp: int, fn: int) -> dict[str, float | int]: + precision = tp / (tp + fp) if tp + fp else 1.0 + recall = tp / (tp + fn) if tp + fn else 1.0 + return { + "true_positive": tp, + "false_positive": fp, + "false_negative": fn, + "precision": precision, + "recall": recall, + "f1": 2 * precision * recall / (precision + recall) if precision + recall else 0.0, + } + + +def read_references(path: Path, width: int, height: int) -> list[tuple[float, float, float, float]]: + boxes = [] + for line in path.read_text(encoding="utf-8").splitlines() if path.is_file() else []: + parts = line.split() + if len(parts) != 5: + raise ValueError(f"Invalid YOLO label row in {path}: {line}") + _class_id, cx, cy, box_width, box_height = map(float, parts) + boxes.append( + ( + (cx - box_width / 2) * width, + (cy - box_height / 2) * height, + (cx + box_width / 2) * width, + (cy + box_height / 2) * height, + ) + ) + return boxes + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--model", type=Path, required=True) + parser.add_argument("--summary", type=Path, required=True) + parser.add_argument("--corpus-manifest", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--thresholds", type=float, nargs="+", default=[0.1, 0.15, 0.2, 0.25, 0.3, 0.4]) + parser.add_argument("--match-iou", type=float, default=0.25) + parser.add_argument("--device", default="cuda:0") + parser.add_argument("--split", default="val") + args = parser.parse_args() + + from ultralytics import YOLO + + summary = json.loads(args.summary.read_text(encoding="utf-8")) + manifest = json.loads(args.corpus_manifest.read_text(encoding="utf-8")) + regions = {item["sample_slug"]: item["region"] for item in manifest["samples"]} + pure_empty_slugs = { + item["sample_slug"] for item in manifest["samples"] if bool(item.get("require_empty")) + } + tiles = [item for item in summary["tiles"] if item.get("kept", True) and item["split"] == args.split] + image_paths = [item["image_path"] for item in tiles] + results = YOLO(str(args.model)).predict(image_paths, conf=min(args.thresholds), device=args.device, verbose=False) + observations: list[dict[str, Any]] = [] + for tile, result in zip(tiles, results, strict=True): + height, width = result.orig_shape + predictions = [ + (tuple(map(float, box)), float(score)) + for box, score in zip(result.boxes.xyxy.cpu().tolist(), result.boxes.conf.cpu().tolist(), strict=True) + ] + observations.append( + { + "sample_slug": tile["sample_slug"], + "region": regions[tile["sample_slug"]], + "references": read_references(Path(tile["label_path"]), width, height), + "predictions": predictions, + } + ) + + sweeps = [] + for threshold in args.thresholds: + totals: defaultdict[str, list[int]] = defaultdict(lambda: [0, 0, 0]) + pure_empty_fp = 0 + for item in observations: + tp, fp, fn = match_boxes( + item["predictions"], item["references"], confidence=threshold, match_iou=args.match_iou + ) + for key in ("all", item["region"], item["sample_slug"]): + totals[key][0] += tp + totals[key][1] += fp + totals[key][2] += fn + if item["sample_slug"] in pure_empty_slugs: + pure_empty_fp += fp + sweeps.append( + { + "threshold": threshold, + "aggregate": metrics(*totals["all"]), + "regions": {region: metrics(*totals[region]) for region in sorted(set(regions.values()))}, + "samples": {slug: metrics(*counts) for slug, counts in sorted(totals.items()) if slug not in {"all", *regions.values()}}, + "pure_empty_false_positives": pure_empty_fp, + } + ) + payload = { + "schema_version": 1, + "model": str(args.model), + "summary": str(args.summary), + "split": args.split, + "match_iou": args.match_iou, + "tile_count": len(tiles), + "sweeps": sweeps, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(json.dumps(payload, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/provision_belgium_building_training_portfolio.py b/scripts/provision_belgium_building_training_portfolio.py index 9ace8491..ae71a954 100644 --- a/scripts/provision_belgium_building_training_portfolio.py +++ b/scripts/provision_belgium_building_training_portfolio.py @@ -33,12 +33,18 @@ AOIS = ( Aoi("flanders-farms-train", "flanders", "rural-farms", "train", 4.850, 50.900), Aoi("kalmthout-heath-train-bg", "flanders", "heath-negative", "train", 4.450, 51.390, "background_candidate"), Aoi("limburg-forest-train-bg", "flanders", "forest-negative", "train", 5.550, 51.050, "background_candidate"), + Aoi("ostend-coastal-train", "flanders", "coastal-urban", "train", 2.920, 51.225), + Aoi("roeselare-industry-train", "flanders", "industrial", "train", 3.120, 50.945), + Aoi("dendermonde-suburban-train", "flanders", "suburban", "train", 4.100, 51.030), + Aoi("houthalen-forest-train-bg", "flanders", "forest-negative", "train", 5.380, 51.030, "background_candidate"), Aoi("bruges-val", "flanders", "historic-urban", "val", 3.224, 51.209), Aoi("turnhout-val", "flanders", "suburban", "val", 4.944, 51.322), Aoi("hasselt-cal", "flanders", "suburban", "calibration", 5.340, 50.930), Aoi("kortrijk-cal", "flanders", "urban-industrial", "calibration", 3.265, 50.828), + Aoi("waregem-cal", "flanders", "ribbon-development", "calibration", 3.430, 50.890), Aoi("leuven-test", "flanders", "urban", "test", 4.700, 50.880), Aoi("sint-niklaas-test", "flanders", "ribbon-development", "test", 4.143, 51.165), + Aoi("mechelen-test", "flanders", "mixed-urban", "test", 4.480, 51.030), Aoi("kempen-forest-bg", "flanders", "forest-heath", "background-test", 5.180, 51.300, "background_candidate", True), Aoi("antwerp-port-bg", "flanders", "port-hard-negative", "background-test", 4.380, 51.280, "background_candidate", True), # Wallonia. @@ -48,12 +54,18 @@ AOIS = ( Aoi("namur-residential-train", "wallonia", "residential", "train", 4.870, 50.470), Aoi("ardennes-forest-train-bg", "wallonia", "forest-negative", "train", 5.700, 50.200, "background_candidate"), Aoi("wallonia-quarry-train-hard", "wallonia", "quarry-hard-negative", "train", 5.130, 50.530, "background_candidate"), + Aoi("wavre-suburban-train", "wallonia", "suburban", "train", 4.610, 50.720), + Aoi("marche-smallcity-train", "wallonia", "small-city", "train", 5.340, 50.230), + Aoi("ath-rural-train", "wallonia", "rural-town", "train", 3.780, 50.630), + Aoi("condroz-field-train-bg", "wallonia", "farmland-negative", "train", 4.700, 50.300, "background_candidate"), Aoi("tournai-val", "wallonia", "historic-urban", "val", 3.389, 50.606), Aoi("arlon-val", "wallonia", "small-city", "val", 5.817, 49.683), Aoi("verviers-cal", "wallonia", "suburban", "calibration", 5.860, 50.590), Aoi("dinant-cal", "wallonia", "valley-town", "calibration", 4.912, 50.260), + Aoi("mouscron-cal", "wallonia", "mixed-urban", "calibration", 3.210, 50.740), Aoi("mons-test", "wallonia", "urban", "test", 3.950, 50.450), Aoi("bastogne-test", "wallonia", "rural-town", "test", 5.720, 50.000), + Aoi("ottignies-test", "wallonia", "suburban", "test", 4.570, 50.670), Aoi("wallonia-rural-bg", "wallonia", "open-rural", "background-test", 5.000, 50.300, "background_candidate", True), Aoi("ardennes-forest-hard", "wallonia", "forest-hard-negative", "background-test", 5.600, 50.100, "background_candidate"), # Brussels. @@ -63,12 +75,18 @@ AOIS = ( Aoi("schaerbeek-train", "brussels", "dense-residential", "train", 4.380, 50.865), Aoi("brussels-rail-train-hard", "brussels", "rail-hard-negative", "train", 4.345, 50.875, "background_candidate"), Aoi("brussels-park-train-hard", "brussels", "park-hard-negative", "train", 4.400, 50.820, "background_candidate"), + Aoi("haren-mixed-train", "brussels", "mixed-urban", "train", 4.420, 50.890), + Aoi("ixelles-dense-train", "brussels", "dense-urban", "train", 4.370, 50.830), + Aoi("forest-residential-train", "brussels", "residential", "train", 4.320, 50.810), + Aoi("woluwe-park-train-hard", "brussels", "park-hard-negative", "train", 4.440, 50.840, "background_candidate"), Aoi("woluwe-val", "brussels", "suburban", "val", 4.430, 50.845), Aoi("molenbeek-val", "brussels", "mixed-urban", "val", 4.325, 50.855), Aoi("brussels-park-cal", "brussels", "park-edge", "calibration", 4.380, 50.820), Aoi("brussels-canal-cal", "brussels", "canal-industry", "calibration", 4.340, 50.870), + Aoi("saint-gilles-cal", "brussels", "dense-urban", "calibration", 4.345, 50.825), Aoi("brussels-rail-test", "brussels", "rail-context", "test", 4.330, 50.840), Aoi("jette-test", "brussels", "residential-park", "test", 4.325, 50.880), + Aoi("auderghem-test", "brussels", "residential-forest-edge", "test", 4.430, 50.815), Aoi("sonian-forest-hard", "brussels", "forest-hard-negative", "background-test", 4.420, 50.790, "background_candidate"), Aoi("bois-cambre-hard", "brussels", "park-hard-negative", "background-test", 4.375, 50.795, "background_candidate"), ) diff --git a/scripts/render_operator_yolo_label_qa_contact_sheets.py b/scripts/render_operator_yolo_label_qa_contact_sheets.py index 85a71b13..32ba5d29 100644 --- a/scripts/render_operator_yolo_label_qa_contact_sheets.py +++ b/scripts/render_operator_yolo_label_qa_contact_sheets.py @@ -212,6 +212,8 @@ def draw_tile_card( top = max(header_height, y_center - height / 2) right = min(thumb_size - 1, x_center + width / 2) bottom = min(thumb_size + header_height - 1, y_center + height / 2) + if right < left or bottom < top: + continue draw.rectangle((left, top, right, bottom), outline=(255, 214, 10), width=3) return card @@ -370,7 +372,7 @@ def write_markdown(report: dict[str, Any], output_dir: Path) -> None: if report["contact_sheets"]: for sheet in report["contact_sheets"]: lines.append(f"- `{sheet['path']}` ({sheet['tile_count']} tiles)") - lines.append(f"") + lines.append("") lines.append(f"![{sheet['path']}]({sheet['path']})") lines.append("") else: