Promote focused small-building detector
This commit is contained in:
+24
-7
@@ -345,6 +345,12 @@ Then point `OPERATOR_YOLO_DATASET_DIR` at
|
||||
training wrapper. Tile-level output remains operator tooling outside the V1
|
||||
browser product.
|
||||
|
||||
Use `--samples` (or `OPERATOR_YOLO_SAMPLES`) when an experiment needs a
|
||||
deliberate manifest subset. The generated summary records the source manifest
|
||||
count plus selected and excluded sample slugs. Unknown samples and any selected
|
||||
manifest holdout that is omitted from `--val-samples` fail before files are
|
||||
written.
|
||||
|
||||
The backend also exposes a read-only model asset catalog for the mounted model
|
||||
directory:
|
||||
|
||||
@@ -462,13 +468,24 @@ docker exec -it geointel python3 /app/scripts/prepare_operator_real_data_samples
|
||||
```
|
||||
|
||||
The helper writes GeoTIFF orthophotos, GRB GBG building GeoJSON files and
|
||||
`operator_samples_manifest.json` under `/app/storage/operator-data`. The corpus
|
||||
contains dense reference AOIs for Geel, Mol, Turnhout, Herentals, Balen, Retie
|
||||
and Westerlo plus explicitly marked background candidates for Postel-bos,
|
||||
Lommel-heide and Kasterlee-bos. Background candidates can persist empty GRB
|
||||
FeatureCollections for negative-tile training; normal reference AOIs still fail
|
||||
when GRB returns no buildings. These are runtime artifacts only and are not
|
||||
committed to Git.
|
||||
`operator_samples_manifest.json` under `/app/storage/operator-data`. In
|
||||
addition to the established positive and background AOIs, the registry contains
|
||||
Beerse, Rijkevorsel, Hoogstraten and Vorselaar as focused small-building
|
||||
training AOIs. Vosselaar and Grobbendonk are independent validation AOIs and
|
||||
must not be exported into the training split. Background candidates can persist
|
||||
empty GRB FeatureCollections for negative-tile training; normal reference AOIs
|
||||
still fail when GRB returns no buildings. These are runtime artifacts only and
|
||||
are not committed to Git.
|
||||
|
||||
The current recommended local building model is
|
||||
`geointel-building-yolov8s-smallbld-minpx3-img640-ft30-pt` with tile size
|
||||
`512`, overlap `64` and confidence threshold `0.15`. Its SHA256 is
|
||||
`a9088b8491dfae36694b53e9e9406cb4e3511d334a5712fa34f75078a47759c1`.
|
||||
The promotion evidence covers seven positive AOIs at QA match IoU `0.25` and
|
||||
three pure-empty background AOIs. The model improves recall and persisted
|
||||
false-negative counts, but has lower precision than the previous balanced
|
||||
model; operators must review and persist QA/QC rather than treating detections
|
||||
as ground truth.
|
||||
|
||||
For model-quality calibration, run the confidence sweep wrapper:
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class DetectionModelCapability(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
model_id: str
|
||||
display_name: str
|
||||
framework: str
|
||||
@@ -23,6 +25,8 @@ class DetectionModelsResponse(BaseModel):
|
||||
|
||||
|
||||
class ModelAssetRead(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
model_asset_id: str
|
||||
filename: str
|
||||
display_name: str
|
||||
@@ -39,12 +43,16 @@ class ModelAssetRead(BaseModel):
|
||||
|
||||
|
||||
class ModelAssetListResponse(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[ModelAssetRead]
|
||||
total: int
|
||||
model_directory: str
|
||||
|
||||
|
||||
class DetectionRunRequest(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
project_id: UUID
|
||||
dataset_id: UUID
|
||||
model_id: str
|
||||
@@ -63,6 +71,8 @@ class DetectionQaRequest(BaseModel):
|
||||
|
||||
|
||||
class DetectionRunResponse(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
analysis_run_id: UUID
|
||||
job_id: UUID
|
||||
project_id: UUID
|
||||
@@ -75,6 +85,8 @@ class DetectionRunResponse(BaseModel):
|
||||
|
||||
|
||||
class DetectionRunRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
dataset_id: UUID | None = None
|
||||
@@ -90,8 +102,6 @@ class DetectionRunRead(BaseModel):
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DetectionRunListResponse(BaseModel):
|
||||
items: list[DetectionRunRead]
|
||||
@@ -99,6 +109,8 @@ class DetectionRunListResponse(BaseModel):
|
||||
|
||||
|
||||
class DetectionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
dataset_id: UUID | None = None
|
||||
@@ -113,8 +125,6 @@ class DetectionRead(BaseModel):
|
||||
properties_json: dict | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DetectionListResponse(BaseModel):
|
||||
items: list[DetectionRead]
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.schemas.detection import DetectionModelCapability
|
||||
|
||||
@@ -16,6 +16,8 @@ class SegmentationModelsResponse(BaseModel):
|
||||
|
||||
|
||||
class SegmentationRunRequest(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
project_id: UUID
|
||||
dataset_id: UUID
|
||||
model_id: str
|
||||
@@ -33,6 +35,8 @@ class SegmentationQaRequest(BaseModel):
|
||||
|
||||
|
||||
class SegmentationRunResponse(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
analysis_run_id: UUID
|
||||
job_id: UUID
|
||||
project_id: UUID
|
||||
@@ -45,6 +49,8 @@ class SegmentationRunResponse(BaseModel):
|
||||
|
||||
|
||||
class SegmentationRunRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
dataset_id: UUID | None = None
|
||||
@@ -60,8 +66,6 @@ class SegmentationRunRead(BaseModel):
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SegmentationRunListResponse(BaseModel):
|
||||
items: list[SegmentationRunRead]
|
||||
@@ -69,6 +73,8 @@ class SegmentationRunListResponse(BaseModel):
|
||||
|
||||
|
||||
class SegmentationRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
dataset_id: UUID | None = None
|
||||
@@ -87,8 +93,6 @@ class SegmentationRead(BaseModel):
|
||||
provenance_json: dict | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SegmentationListResponse(BaseModel):
|
||||
items: list[SegmentationRead]
|
||||
|
||||
@@ -58,14 +58,33 @@ def test_all_in_one_dockerfile_can_opt_into_ai_dependencies_without_base_install
|
||||
def test_all_in_one_dockerfile_copies_operator_scripts_for_runtime_use() -> None:
|
||||
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
|
||||
|
||||
assert "COPY scripts/prepare_operator_real_data_samples.py /app/scripts/prepare_operator_real_data_samples.py" in dockerfile
|
||||
assert "COPY scripts/export_operator_yolo_tile_dataset.py /app/scripts/export_operator_yolo_tile_dataset.py" in dockerfile
|
||||
assert "COPY scripts/audit_operator_yolo_dataset_quality.py /app/scripts/audit_operator_yolo_dataset_quality.py" in dockerfile
|
||||
assert (
|
||||
"COPY scripts/render_operator_yolo_label_qa_contact_sheets.py "
|
||||
"/app/scripts/render_operator_yolo_label_qa_contact_sheets.py"
|
||||
) in dockerfile
|
||||
assert "COPY scripts/train_operator_yolo_detector.sh /app/scripts/train_operator_yolo_detector.sh" in dockerfile
|
||||
for line in dockerfile.splitlines():
|
||||
if line.startswith("COPY scripts/"):
|
||||
source_path = line.split()[1]
|
||||
assert (ROOT / source_path).is_file()
|
||||
|
||||
required_runtime_scripts = {
|
||||
"prepare_operator_real_data_samples.py",
|
||||
"export_operator_yolo_tile_dataset.py",
|
||||
"audit_operator_yolo_dataset_quality.py",
|
||||
"render_operator_yolo_label_qa_contact_sheets.py",
|
||||
"train_operator_yolo_detector.sh",
|
||||
"verify_real_data_detection_qa_workflow.sh",
|
||||
"run_detection_quality_matrix.sh",
|
||||
"run_multi_sample_detection_quality_matrix.sh",
|
||||
"export_detection_calibration_evidence.sh",
|
||||
"assemble_detection_calibration_evidence_portfolio.sh",
|
||||
"build_fixed_threshold_evidence_portfolio_inputs.py",
|
||||
"audit_detection_false_negative_evidence.py",
|
||||
"run_operator_hard_negative_detection_matrix.sh",
|
||||
"run_background_corpus_split_matrix.sh",
|
||||
"build_background_corpus_split_report.py",
|
||||
"build_detection_model_promotion_report.py",
|
||||
"run_split_background_promotion_workflow.sh",
|
||||
"activate_promoted_yolo_candidate.py",
|
||||
}
|
||||
for script_name in required_runtime_scripts:
|
||||
assert f"COPY scripts/{script_name} /app/scripts/{script_name}" in dockerfile
|
||||
|
||||
|
||||
def test_all_in_one_dockerfile_copies_operator_scripts_after_dependency_install() -> None:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas import detection, segmentation
|
||||
|
||||
|
||||
def test_model_prefixed_api_fields_are_explicitly_supported() -> None:
|
||||
schemas = [
|
||||
value
|
||||
for module in (detection, segmentation)
|
||||
for value in vars(module).values()
|
||||
if isinstance(value, type)
|
||||
and issubclass(value, BaseModel)
|
||||
and any(field_name.startswith("model_") for field_name in value.model_fields)
|
||||
]
|
||||
|
||||
assert schemas
|
||||
assert all(schema.model_config.get("protected_namespaces") == () for schema in schemas)
|
||||
@@ -69,6 +69,7 @@ def test_operator_yolo_tile_dataset_export_help_does_not_require_gis_dependencie
|
||||
assert "Export operator real-data samples to a tile-level YOLO detection dataset" in result.stdout
|
||||
assert "--tile-size" in result.stdout
|
||||
assert "--stride" in result.stdout
|
||||
assert "--samples" in result.stdout
|
||||
assert "--negative-keep-ratio" in result.stdout
|
||||
assert "--min-label-visible-ratio" in result.stdout
|
||||
assert "--background-negative-repeat" in result.stdout
|
||||
@@ -84,10 +85,19 @@ def test_default_validation_split_is_explicit_and_rejects_holdout_leakage() -> N
|
||||
{"sample_slug": "retie", "recommended_split": "val"},
|
||||
{"sample_slug": "westerlo", "recommended_split": "val"},
|
||||
{"sample_slug": "arendonk_heide", "recommended_split": "val"},
|
||||
{"sample_slug": "vosselaar_center", "recommended_split": "val"},
|
||||
{"sample_slug": "grobbendonk_center", "recommended_split": "val"},
|
||||
]
|
||||
|
||||
assert module.DEFAULT_VALIDATION_SAMPLE_SLUGS == frozenset(
|
||||
{"turnhout", "retie", "westerlo", "arendonk_heide"}
|
||||
{
|
||||
"turnhout",
|
||||
"retie",
|
||||
"westerlo",
|
||||
"arendonk_heide",
|
||||
"vosselaar_center",
|
||||
"grobbendonk_center",
|
||||
}
|
||||
)
|
||||
assert module.validate_validation_split(
|
||||
samples,
|
||||
@@ -100,6 +110,35 @@ def test_default_validation_split_is_explicit_and_rejects_holdout_leakage() -> N
|
||||
module.validate_validation_split(samples, {"turnhout", "missing"})
|
||||
|
||||
|
||||
def test_manifest_sample_selection_keeps_external_holdouts_out_of_targeted_dataset() -> None:
|
||||
module = load_tile_exporter()
|
||||
samples = [
|
||||
{"sample_slug": "geel", "recommended_split": "train"},
|
||||
{"sample_slug": "beerse_center", "recommended_split": "train"},
|
||||
{"sample_slug": "vosselaar_center", "recommended_split": "val"},
|
||||
{"sample_slug": "turnhout", "recommended_split": "val"},
|
||||
{"sample_slug": "retie", "recommended_split": "val"},
|
||||
{"sample_slug": "westerlo", "recommended_split": "val"},
|
||||
]
|
||||
|
||||
selected, excluded = module.select_manifest_samples(
|
||||
samples,
|
||||
{"geel", "beerse_center", "vosselaar_center"},
|
||||
)
|
||||
|
||||
assert [sample["sample_slug"] for sample in selected] == [
|
||||
"geel",
|
||||
"beerse_center",
|
||||
"vosselaar_center",
|
||||
]
|
||||
assert excluded == ["retie", "turnhout", "westerlo"]
|
||||
assert module.validate_validation_split(selected, {"vosselaar_center"}) == {
|
||||
"vosselaar_center"
|
||||
}
|
||||
with pytest.raises(SystemExit, match="unknown samples"):
|
||||
module.select_manifest_samples(samples, {"geel", "missing"})
|
||||
|
||||
|
||||
def test_validation_coverage_reports_holdouts_without_retained_tiles() -> None:
|
||||
module = load_tile_exporter()
|
||||
coverage = module.validation_sample_coverage(
|
||||
|
||||
@@ -51,7 +51,7 @@ def test_operator_training_expansion_preserves_geographically_separate_holdouts(
|
||||
expected_holdouts = {"turnhout", "retie", "westerlo", "arendonk_heide"}
|
||||
|
||||
assert module.TRAINING_EXPANSION_SAMPLE_SLUGS == frozenset(expected_expansion)
|
||||
assert module.DEFAULT_VALIDATION_SAMPLE_SLUGS == frozenset(expected_holdouts)
|
||||
assert expected_holdouts.issubset(module.DEFAULT_VALIDATION_SAMPLE_SLUGS)
|
||||
assert all(module.SAMPLES[slug].sample_role == "reference" for slug in expected_expansion)
|
||||
assert all(not module.SAMPLES[slug].allow_empty_reference for slug in expected_expansion)
|
||||
assert all(module.recommended_split_for_sample(module.SAMPLES[slug]) == "train" for slug in expected_expansion)
|
||||
@@ -78,6 +78,51 @@ def test_operator_training_expansion_preserves_geographically_separate_holdouts(
|
||||
) >= 2_000
|
||||
|
||||
|
||||
def test_small_building_expansion_has_separate_training_and_validation_centers() -> None:
|
||||
module = load_sample_preparer()
|
||||
|
||||
expected_training = {
|
||||
"beerse_center",
|
||||
"rijkevorsel_center",
|
||||
"hoogstraten_center",
|
||||
"vorselaar_center",
|
||||
}
|
||||
expected_validation = {"vosselaar_center", "grobbendonk_center"}
|
||||
|
||||
assert module.SMALL_BUILDING_TRAINING_SAMPLE_SLUGS == frozenset(expected_training)
|
||||
assert module.SMALL_BUILDING_VALIDATION_SAMPLE_SLUGS == frozenset(expected_validation)
|
||||
assert expected_validation.issubset(module.DEFAULT_VALIDATION_SAMPLE_SLUGS)
|
||||
assert all(module.SAMPLES[slug].sample_role == "reference" for slug in expected_training | expected_validation)
|
||||
assert all(
|
||||
module.recommended_split_for_sample(module.SAMPLES[slug]) == "train"
|
||||
for slug in expected_training
|
||||
)
|
||||
assert all(
|
||||
module.recommended_split_for_sample(module.SAMPLES[slug]) == "val"
|
||||
for slug in expected_validation
|
||||
)
|
||||
|
||||
def distance_m(left, right) -> float:
|
||||
radius_m = 6_371_008.8
|
||||
left_lat = math.radians(left.center_lat)
|
||||
right_lat = math.radians(right.center_lat)
|
||||
delta_lat = right_lat - left_lat
|
||||
delta_lon = math.radians(right.center_lon - left.center_lon)
|
||||
haversine = (
|
||||
math.sin(delta_lat / 2) ** 2
|
||||
+ math.cos(left_lat) * math.cos(right_lat) * math.sin(delta_lon / 2) ** 2
|
||||
)
|
||||
return 2 * radius_m * math.asin(math.sqrt(haversine))
|
||||
|
||||
protected_holdouts = expected_validation | {"turnhout", "retie", "westerlo"}
|
||||
for training_slug in expected_training:
|
||||
training_sample = module.SAMPLES[training_slug]
|
||||
assert min(
|
||||
distance_m(training_sample, module.SAMPLES[holdout_slug])
|
||||
for holdout_slug in protected_holdouts
|
||||
) >= 2_000
|
||||
|
||||
|
||||
def test_operator_background_candidates_are_unique_enough_for_hard_negative_training() -> None:
|
||||
module = load_sample_preparer()
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ def test_detection_operator_profiles_define_explicit_yolo_candidates_and_promote
|
||||
source = profiles.read_text(encoding="utf-8")
|
||||
|
||||
assert "DETECTION_OPERATOR_PROFILES" in source
|
||||
assert "geointel-building-yolov8s-smallbld-minpx3-img640-ft30-pt" in source
|
||||
assert "geointel-building-yolov8s-aoi1024expandedminpx4vis035e50-pt" in source
|
||||
assert "geointel-building-yolov8s-aoi1024bg512r3e50-pt" in source
|
||||
assert "small-building-balanced-review" in source
|
||||
assert "expanded-balanced-review" in source
|
||||
assert "conservative-review" in source
|
||||
assert "confidenceThreshold: 0.15" in source
|
||||
@@ -18,10 +20,12 @@ def test_detection_operator_profiles_define_explicit_yolo_candidates_and_promote
|
||||
assert "defaultApproved: true" in source
|
||||
assert "promotionRecommendation: 'promote_candidate'" in source
|
||||
assert "positiveSampleCount: 7" in source
|
||||
assert "f1: 0.5824578631584316" in source
|
||||
assert "f1: 0.5432865390636915" in source
|
||||
assert "maxBackgroundDetections: 0" in source
|
||||
assert "pure-empty gate passed" in source
|
||||
assert "persistent small-building misses" in source
|
||||
assert "1,571 fewer false negatives" in source
|
||||
assert "higher false-positive review load" in source
|
||||
|
||||
|
||||
def test_detection_lab_surfaces_profiles_as_deliberate_operator_actions() -> None:
|
||||
|
||||
@@ -199,6 +199,28 @@ def test_false_negative_audit_finds_persistent_reference_misses(tmp_path: Path)
|
||||
assert candidate["false_negative_count"] == 1
|
||||
assert candidate["false_negative_rate"] == 1 / 3
|
||||
assert active["false_negative_area_m2"]["median"] > 0
|
||||
assert sample["persistent_false_negative_area_m2"]["count"] == 1
|
||||
assert sample["persistent_false_negative_area_m2"]["median"] > 0
|
||||
assert sum(
|
||||
bucket["count"] for bucket in sample["persistent_area_buckets"].values()
|
||||
) == 1
|
||||
assert sum(
|
||||
bucket["share"] for bucket in sample["persistent_area_buckets"].values()
|
||||
) == 1.0
|
||||
persistent_evidence = json.loads(
|
||||
(output_dir / "persistent_false_negatives.geojson").read_text(encoding="utf-8")
|
||||
)
|
||||
assert persistent_evidence["type"] == "FeatureCollection"
|
||||
assert len(persistent_evidence["features"]) == 1
|
||||
persistent_feature = persistent_evidence["features"][0]
|
||||
assert persistent_feature["properties"]["qa_evidence_role"] == "persistent_false_negative"
|
||||
assert persistent_feature["properties"]["sample_slug"] == "geel"
|
||||
assert persistent_feature["properties"]["persistent_reference_id"] == "source:persistent-small"
|
||||
assert persistent_feature["properties"]["area_m2"] > 0
|
||||
assert persistent_feature["properties"]["area_bucket"] in sample["persistent_area_buckets"]
|
||||
assert report["persistent_evidence_geojson_path"] == str(
|
||||
output_dir / "persistent_false_negatives.geojson"
|
||||
)
|
||||
assert report["recommendations"]
|
||||
assert (output_dir / "detection_false_negative_audit.md").is_file()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user