Add fail-closed Belgian training loop
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user