diff --git a/CHANGELOG.md b/CHANGELOG.md index 47609612..e3594620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Enriched existing QA evidence GeoJSON with persisted detection and segmentation provenance without changing its endpoint or canonical envelope. - Added a read-only, storage-root-confined false-positive contact-sheet renderer with deterministic AOI/area/confidence stratification and persisted reference overlays. +- Candidate-centred crops preserve non-square edge-tile aspect ratios and limit reference overlays to the local review context. - Added an explicit five-state operator review contract; incomplete reviews fail the completion gate and no decision is inferred. - Exported only manually confirmed model false-positives as possible review input, keeping reference gaps, QA alignment issues and uncertain cases separate. - Added focused provenance, visual rendering, path-confinement, incomplete-review and confirmed-export regression coverage plus all-in-one/readiness wiring. diff --git a/backend/tests/test_sprint176_detection_false_positive_visual_review.py b/backend/tests/test_sprint176_detection_false_positive_visual_review.py index af2670d3..e9ecc171 100644 --- a/backend/tests/test_sprint176_detection_false_positive_visual_review.py +++ b/backend/tests/test_sprint176_detection_false_positive_visual_review.py @@ -112,21 +112,21 @@ def test_detection_quality_evidence_exposes_persisted_detection_provenance() -> assert properties["tile_index"] == 3 -def _write_geotiff(path: Path, *, seed: int) -> None: +def _write_geotiff(path: Path, *, seed: int, width: int = 128, height: int = 128) -> None: rng = np.random.default_rng(seed) - data = rng.integers(35, 190, size=(3, 128, 128), dtype=np.uint8) + data = rng.integers(35, 190, size=(3, height, width), dtype=np.uint8) data[:, 32:92, 38:98] = np.array([190, 180, 165], dtype=np.uint8)[:, None, None] path.parent.mkdir(parents=True, exist_ok=True) with rasterio.open( path, "w", driver="GTiff", - width=128, - height=128, + width=width, + height=height, count=3, dtype="uint8", crs="EPSG:4326", - transform=from_bounds(5.0, 51.0, 5.01, 51.01, 128, 128), + transform=from_bounds(5.0, 51.0, 5.01, 51.01, width, height), ) as dataset: dataset.write(data) @@ -174,7 +174,11 @@ def _write_review_portfolio(tmp_path: Path, *, unsafe_tile: bool = False) -> tup samples = [] for sample_index, sample_slug in enumerate(("geel", "turnhout")): tile_path = storage_root / sample_slug / "tile_0000.tif" - _write_geotiff(tile_path, seed=sample_index + 1) + _write_geotiff( + tile_path, + seed=sample_index + 1, + height=80 if sample_slug == "turnhout" else 128, + ) selected_tile = (tmp_path / "outside.tif") if unsafe_tile and sample_slug == "geel" else tile_path if unsafe_tile and sample_slug == "geel": _write_geotiff(selected_tile, seed=99) diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 835df801..b6483386 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -7203,6 +7203,7 @@ Open: - Added a storage-root-confined contact-sheet renderer for persisted detection false-positive evidence. - The renderer validates portfolio role counts, polygon geometry, source imagery and persisted provenance, then selects deterministically across AOI, WGS84 area bucket and confidence band. - Source imagery is rendered with the candidate pixel bbox plus persisted matched-reference and missed-reference overlays. +- Live orthophoto inspection exposed stretched non-square edge tiles and overly distant full-tile context; the renderer now uses candidate-centred crops, preserves aspect ratio and limits reference overlays to the crop. - Added an explicit five-state review CSV: `confirmed_model_false_positive`, `reference_gap_or_change`, `qa_alignment_mismatch`, `uncertain` and `unreviewed`. - Added a separate validator that rejects missing, duplicate, unexpected or invalid decisions. `--require-complete` exits with code `2` while any record remains unreviewed. - Only explicitly confirmed model false-positives are emitted to `confirmed_model_false_positives.geojson`; no QA result is automatically converted into a model label or training artifact. @@ -7221,6 +7222,15 @@ Open: - `python -m alembic upgrade head --sql`: complete migration chain rendered successfully. - Local Docker validation remains unavailable because Docker CLI is not installed on the Windows host; live all-in-one/PostGIS validation follows on Tower after deployment. +## Tower deployment evidence + +- Deployed commit `1322a5d` through the repository-driven all-in-one build with the existing CPU-AI dependency layer cached. +- PostGIS `3.6`, required tables/indexes, Alembic head `202606120900`, frontend proxy, API proxy and icon checks passed. +- Re-exported all seven persisted fixed-threshold QualityChecks into an enriched portfolio containing 27,840 evidence features. +- The false-positive audit now reports persisted confidence coverage `5,568/5,568` rather than inventing values for the older static export. +- Rendered an initial 48-case Turnhout/Herentals/Geel review spanning all four area buckets and all three confidence bands with no missing provenance or source tiles. +- All 48 decisions remain explicitly `unreviewed`; validation status is `review_required` and the confirmed-model-false-positive GeoJSON is empty. + ## Next recommended pass - Re-export the seven-AOI evidence portfolio from the deployed backend, render the Turnhout/Herentals/Geel sheets and inspect the real orthophoto evidence. Keep all CSV decisions `unreviewed` until an operator makes an explicit visual classification; do not start another model training run yet. diff --git a/scripts/render_detection_false_positive_review_contact_sheets.py b/scripts/render_detection_false_positive_review_contact_sheets.py index be2267b4..c3b06067 100644 --- a/scripts/render_detection_false_positive_review_contact_sheets.py +++ b/scripts/render_detection_false_positive_review_contact_sheets.py @@ -141,7 +141,7 @@ def require_dependencies() -> dict[str, Any]: import rasterio from PIL import Image, ImageDraw, ImageFont from pyproj import Geod, Transformer - from shapely.geometry import box, shape + from shapely.geometry import Polygon, box, shape from shapely.ops import transform except ImportError as exc: raise SystemExit( @@ -156,6 +156,7 @@ def require_dependencies() -> dict[str, Any]: "Geod": Geod, "Transformer": Transformer, "box": box, + "Polygon": Polygon, "shape": shape, "transform": transform, } @@ -212,15 +213,23 @@ def draw_geometry( draw: Any, geometry: Any, inverse_transform: Any, - scale_x: float, - scale_y: float, + scale: float, + offset_x: float, + offset_y: float, + crop_left: float, + crop_top: float, color: tuple[int, int, int], ) -> None: for ring in polygon_rings(geometry): points = [] for x, y in ring.coords: column, row = inverse_transform * (x, y) - points.append((column * scale_x, row * scale_y)) + points.append( + ( + (column - crop_left) * scale + offset_x, + (row - crop_top) * scale + offset_y, + ) + ) if len(points) >= 2: draw.line(points, fill=color, width=2, joint="curve") @@ -239,18 +248,44 @@ def render_card( Transformer = dependencies["Transformer"] shape = dependencies["shape"] transform_geometry = dependencies["transform"] - box = dependencies["box"] + Polygon = dependencies["Polygon"] header_height = 88 with rasterio.open(record["resolved_source_tile_path"]) as source: pixels = normalize_raster(source.read(), numpy) - image = Image.fromarray(pixels, mode="RGB").resize( - (thumb_size, thumb_size), Image.Resampling.BILINEAR + bbox = record["bbox_json"] + if ( + float(bbox["x_min"]) < 0 + or float(bbox["y_min"]) < 0 + or float(bbox["x_max"]) > source.width + or float(bbox["y_max"]) > source.height + ): + raise SystemExit( + f"Detection bbox_json exceeds source tile dimensions: {record['candidate_feature_id']}" + ) + bbox_width = float(bbox["x_max"]) - float(bbox["x_min"]) + bbox_height = float(bbox["y_max"]) - float(bbox["y_min"]) + crop_width = min(source.width, max(128, math.ceil(bbox_width * 4))) + crop_height = min(source.height, max(128, math.ceil(bbox_height * 4))) + center_x = (float(bbox["x_min"]) + float(bbox["x_max"])) / 2 + center_y = (float(bbox["y_min"]) + float(bbox["y_max"])) / 2 + crop_left = max(0, min(source.width - crop_width, round(center_x - crop_width / 2))) + crop_top = max(0, min(source.height - crop_height, round(center_y - crop_height / 2))) + crop_right = crop_left + crop_width + crop_bottom = crop_top + crop_height + image = Image.fromarray(pixels, mode="RGB").crop( + (crop_left, crop_top, crop_right, crop_bottom) ) + scale = min(thumb_size / image.width, thumb_size / image.height) + render_width = max(1, round(image.width * scale)) + render_height = max(1, round(image.height * scale)) + image = image.resize((render_width, render_height), Image.Resampling.BILINEAR) + image_offset_x = (thumb_size - render_width) // 2 + image_offset_y = (thumb_size - render_height) // 2 card = Image.new( "RGB", (thumb_size, thumb_size + header_height), color=(242, 245, 247) ) - card.paste(image, (0, header_height)) + card.paste(image, (image_offset_x, header_height + image_offset_y)) draw = ImageDraw.Draw(card) font = ImageFont.load_default() draw.rectangle((0, 0, thumb_size, header_height), fill=(22, 29, 38)) @@ -279,29 +314,33 @@ def render_card( (6, 72), "green ref | blue miss", fill=(235, 238, 241), font=font ) - scale_x = thumb_size / source.width - scale_y = thumb_size / source.height - bbox = record["bbox_json"] - draw.rectangle( - ( - max(0, float(bbox["x_min"]) * scale_x), - header_height + max(0, float(bbox["y_min"]) * scale_y), - min(thumb_size - 1, float(bbox["x_max"]) * scale_x), - header_height + min(thumb_size - 1, float(bbox["y_max"]) * scale_y), - ), - outline=(231, 76, 60), - width=3, + candidate_box = ( + (float(bbox["x_min"]) - crop_left) * scale + image_offset_x, + header_height + + (float(bbox["y_min"]) - crop_top) * scale + + image_offset_y, + (float(bbox["x_max"]) - crop_left) * scale + image_offset_x, + header_height + + (float(bbox["y_max"]) - crop_top) * scale + + image_offset_y, ) overlay_count = 0 if source.crs: transformer = Transformer.from_crs("EPSG:4326", source.crs, always_xy=True) - bounds = box(*source.bounds) + crop_bounds = Polygon( + [ + source.transform * (crop_left, crop_top), + source.transform * (crop_right, crop_top), + source.transform * (crop_right, crop_bottom), + source.transform * (crop_left, crop_bottom), + ] + ) overlay = Image.new("RGBA", (thumb_size, thumb_size), (0, 0, 0, 0)) overlay_draw = ImageDraw.Draw(overlay) for reference in references: geometry = transform_geometry(transformer.transform, shape(reference["geometry"])) - if geometry.is_empty or not geometry.intersects(bounds): + if geometry.is_empty or not geometry.intersects(crop_bounds): continue color = ( (39, 174, 96) @@ -312,12 +351,16 @@ def render_card( overlay_draw, geometry, ~source.transform, - scale_x, - scale_y, + scale, + image_offset_x, + image_offset_y, + crop_left, + crop_top, color, ) overlay_count += 1 card.paste(overlay, (0, header_height), overlay) + draw.rectangle(candidate_box, outline=(231, 76, 60), width=3) return card, overlay_count