diff --git a/backend/tests/test_belgium_candidate_evaluation.py b/backend/tests/test_belgium_candidate_evaluation.py index 8e59f89e..0d80e809 100644 --- a/backend/tests/test_belgium_candidate_evaluation.py +++ b/backend/tests/test_belgium_candidate_evaluation.py @@ -22,3 +22,7 @@ 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) + + +def test_box_scaling_preserves_center() -> None: + assert MODULE.scale_box((10.0, 20.0, 30.0, 40.0), 1.5) == (5.0, 15.0, 35.0, 45.0) diff --git a/backend/tests/test_belgium_training_loop.py b/backend/tests/test_belgium_training_loop.py index c9a1feb9..1bb73ddb 100644 --- a/backend/tests/test_belgium_training_loop.py +++ b/backend/tests/test_belgium_training_loop.py @@ -33,6 +33,32 @@ def test_training_command_is_cuda_deterministic_and_bound_to_frozen_inputs(tmp_p assert "epochs=160" in command assert "max_det=1000" in command assert "imgsz=640" in command + assert "optimizer=auto" in command + assert "mosaic=1.0" in command + + +def test_training_command_supports_conservative_aerial_finetuning(tmp_path: Path) -> None: + command = MODULE.training_command( + "yolo", + model=tmp_path / "base.pt", + data=tmp_path / "dataset.yaml", + project=tmp_path / "runs", + name="aerial", + epochs=50, + seed=42, + batch=2, + workers=0, + optimizer="AdamW", + lr0=0.0001, + mosaic=0.0, + scale=0.2, + translate=0.05, + ) + assert "optimizer=AdamW" in command + assert "lr0=0.0001" in command + assert "mosaic=0.0" in command + assert "scale=0.2" in command + assert "translate=0.05" in command assert f"data={tmp_path / 'dataset.yaml'}" in command diff --git a/docs/BELGIUM_BUILDING_TRAINING_LOOP.md b/docs/BELGIUM_BUILDING_TRAINING_LOOP.md index ae8c9f10..cdec6cbd 100644 --- a/docs/BELGIUM_BUILDING_TRAINING_LOOP.md +++ b/docs/BELGIUM_BUILDING_TRAINING_LOOP.md @@ -80,6 +80,10 @@ The active production model remains unchanged while any gate fails. Every failed assessment returns `continue_training_loop`. Only a report with `training_complete` may proceed to final human review and guarded activation. +Optimizer, initial learning rate, image size and geometric augmentation are +explicit loop inputs. This permits a conservative aerial-imagery finetune +(for example AdamW with mosaic disabled) without changing calibration, test +or release gates. After a failed assessment, `scripts/build_failure_driven_yolo_sampling.py` creates a checksummed, diff --git a/scripts/evaluate_belgium_building_candidate.py b/scripts/evaluate_belgium_building_candidate.py index c95e9605..8cca2b29 100644 --- a/scripts/evaluate_belgium_building_candidate.py +++ b/scripts/evaluate_belgium_building_candidate.py @@ -52,6 +52,14 @@ def metrics(tp: int, fp: int, fn: int) -> dict[str, float | int]: } +def scale_box(box: tuple[float, float, float, float], factor: float) -> tuple[float, float, float, float]: + """Scale a detector box around its center for calibration-only geometry correction.""" + x1, y1, x2, y2 = box + cx, cy = (x1 + x2) / 2, (y1 + y2) / 2 + half_width, half_height = (x2 - x1) * factor / 2, (y2 - y1) * factor / 2 + return cx - half_width, cy - half_height, cx + half_width, cy + half_height + + 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 []: @@ -88,6 +96,7 @@ def main() -> int: default=1000, help="Maximum detections retained per tile; dense Belgian urban tiles exceed YOLO's default 300.", ) + parser.add_argument("--box-scale", type=float, default=1.0) args = parser.parse_args() from ultralytics import YOLO @@ -119,7 +128,7 @@ def main() -> int: for tile, result in zip(tiles, results, strict=True): height, width = result.orig_shape predictions = [ - (tuple(map(float, box)), float(score)) + (scale_box(tuple(map(float, box)), args.box_scale), float(score)) for box, score in zip(result.boxes.xyxy.cpu().tolist(), result.boxes.conf.cpu().tolist(), strict=True) ] observations.append( @@ -163,6 +172,7 @@ def main() -> int: "test_time_augmentation": args.augment, "inference_imgsz": args.imgsz, "max_detections_per_tile": args.max_det, + "box_scale": args.box_scale, "tile_count": len(tiles), "sweeps": sweeps, } diff --git a/scripts/provision_belgium_building_training_portfolio.py b/scripts/provision_belgium_building_training_portfolio.py index b4e76890..6aa88fdc 100644 --- a/scripts/provision_belgium_building_training_portfolio.py +++ b/scripts/provision_belgium_building_training_portfolio.py @@ -52,6 +52,35 @@ AOIS = ( Aoi("zeebrugge-port-train-hard", "flanders", "port-hard-negative", "train", 3.205, 51.330, "background_candidate"), Aoi("hoge-kempen-train-bg", "flanders", "forest-heath-negative", "train", 5.650, 50.990, "background_candidate"), Aoi("westhoek-dunes-train-bg", "flanders", "dunes-negative", "train", 2.590, 51.095, "background_candidate"), + # v11 full-AOI review: independent train-only coverage for the Flemish + # image/roof domain that remained the limiting release region. These + # centers are separated from the frozen calibration and test AOIs. + Aoi("geel-urban-train-v12", "flanders", "mixed-urban", "train", 4.989, 51.165), + Aoi("mol-urban-train-v12", "flanders", "ribbon-development", "train", 5.116, 51.190), + Aoi("beringen-train-v12", "flanders", "suburban", "train", 5.226, 51.050), + Aoi("bilzen-train-v12", "flanders", "small-city", "train", 5.519, 50.871), + Aoi("bree-train-v12", "flanders", "small-city", "train", 5.597, 51.141), + Aoi("maaseik-train-v12", "flanders", "historic-urban", "train", 5.790, 51.095), + Aoi("peer-train-v12", "flanders", "ribbon-development", "train", 5.459, 51.130), + Aoi("diksmuide-train-v12", "flanders", "small-city", "train", 2.865, 51.034), + Aoi("ieper-train-v12", "flanders", "historic-urban", "train", 2.885, 50.851), + Aoi("poperinge-train-v12", "flanders", "rural-town", "train", 2.726, 50.855), + Aoi("eeklo-train-v12", "flanders", "mixed-urban", "train", 3.570, 51.185), + Aoi("oudenaarde-train-v12", "flanders", "historic-urban", "train", 3.600, 50.845), + Aoi("geraardsbergen-train-v12", "flanders", "hilly-urban", "train", 3.882, 50.771), + Aoi("ninove-train-v12", "flanders", "mixed-urban", "train", 4.025, 50.835), + Aoi("zottegem-train-v12", "flanders", "ribbon-development", "train", 3.815, 50.870), + Aoi("ronse-train-v12", "flanders", "regional-architecture", "train", 3.600, 50.745), + Aoi("halle-train-v12", "flanders", "dense-urban", "train", 4.235, 50.735), + Aoi("vilvoorde-train-v12", "flanders", "urban-industrial", "train", 4.430, 50.930), + Aoi("boom-train-v12", "flanders", "dense-urban", "train", 4.370, 51.090), + Aoi("heist-op-den-berg-train-v12", "flanders", "ribbon-development", "train", 4.715, 51.075), + Aoi("aalter-train-v12", "flanders", "suburban", "train", 3.445, 51.090), + Aoi("wetteren-train-v12", "flanders", "mixed-urban", "train", 3.885, 51.005), + Aoi("sint-truiden-train-v12", "flanders", "historic-urban", "train", 5.190, 50.815), + Aoi("brasschaat-heath-train-v12-bg", "flanders", "heath-hard-negative", "train", 4.525, 51.350, "background_candidate"), + Aoi("limburg-pine-train-v12-bg", "flanders", "forest-hard-negative", "train", 5.680, 51.020, "background_candidate"), + Aoi("westhoek-field-train-v12-bg", "flanders", "farmland-hard-negative", "train", 2.720, 50.990, "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), @@ -82,6 +111,14 @@ AOIS = ( Aoi("arlon-industry-train", "wallonia", "industrial", "train", 5.790, 49.676), Aoi("liege-rail-train-hard", "wallonia", "rail-hard-negative", "train", 5.615, 50.625, "background_candidate"), Aoi("famenne-quarry-train-hard", "wallonia", "quarry-hard-negative", "train", 5.080, 50.170, "background_candidate"), + Aoi("spa-train-v12", "wallonia", "regional-architecture", "train", 5.867, 50.492), + Aoi("malmedy-train-v12", "wallonia", "small-city", "train", 6.027, 50.426), + Aoi("rochefort-train-v12", "wallonia", "rural-town", "train", 5.222, 50.159), + Aoi("beauraing-train-v12", "wallonia", "rural-town", "train", 4.956, 50.110), + Aoi("thuin-train-v12", "wallonia", "valley-urban", "train", 4.286, 50.339), + Aoi("peruwelz-train-v12", "wallonia", "mixed-urban", "train", 3.591, 50.509), + Aoi("ardenne-clearing-train-v12-bg", "wallonia", "forest-hard-negative", "train", 5.460, 50.080, "background_candidate"), + Aoi("condroz-open-train-v12-bg", "wallonia", "farmland-hard-negative", "train", 5.020, 50.390, "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), diff --git a/scripts/run_belgium_building_training_loop.py b/scripts/run_belgium_building_training_loop.py index f0e5b8b9..0d4a6d94 100644 --- a/scripts/run_belgium_building_training_loop.py +++ b/scripts/run_belgium_building_training_loop.py @@ -42,8 +42,13 @@ def training_command( workers: int, max_det: int = 1000, imgsz: int = 640, + optimizer: str = "auto", + lr0: float | None = None, + mosaic: float = 1.0, + scale: float = 0.5, + translate: float = 0.1, ) -> list[str]: - return [ + command = [ yolo, "train", f"model={model}", @@ -57,12 +62,19 @@ def training_command( "cache=disk", "close_mosaic=20", f"max_det={max_det}", + f"optimizer={optimizer}", + f"mosaic={mosaic}", + f"scale={scale}", + f"translate={translate}", f"seed={seed}", "deterministic=True", f"project={project}", f"name={name}", "exist_ok=True", ] + if lr0 is not None: + command.append(f"lr0={lr0}") + return command def run(command: list[str], log_path: Path | None = None, *, allowed: set[int] = {0}) -> int: @@ -93,6 +105,11 @@ def main() -> int: parser.add_argument("--workers", type=int, default=4) parser.add_argument("--max-det", type=int, default=1000) parser.add_argument("--imgsz", type=int, default=640) + parser.add_argument("--optimizer", default="auto") + parser.add_argument("--lr0", type=float) + parser.add_argument("--mosaic", type=float, default=1.0) + parser.add_argument("--scale", type=float, default=0.5) + parser.add_argument("--translate", type=float, default=0.1) parser.add_argument("--seed", type=int, default=20260731) parser.add_argument("--yolo", default="yolo") parser.add_argument("--dry-run", action="store_true") @@ -140,6 +157,11 @@ def main() -> int: workers=args.workers, max_det=args.max_det, imgsz=args.imgsz, + optimizer=args.optimizer, + lr0=args.lr0, + mosaic=args.mosaic, + scale=args.scale, + translate=args.translate, ) if args.dry_run: print(json.dumps({"training_command": command}, indent=2))