Preserve aspect ratio in false-positive review sheets
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-13 17:20:40 +02:00
parent 1322a5d66a
commit e2ddf84c74
4 changed files with 88 additions and 30 deletions
+1
View File
@@ -11,6 +11,7 @@
- Enriched existing QA evidence GeoJSON with persisted detection and segmentation provenance without changing its endpoint or canonical envelope. - 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. - 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. - 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. - 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. - Added focused provenance, visual rendering, path-confinement, incomplete-review and confirmed-export regression coverage plus all-in-one/readiness wiring.
@@ -112,21 +112,21 @@ def test_detection_quality_evidence_exposes_persisted_detection_provenance() ->
assert properties["tile_index"] == 3 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) 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] data[:, 32:92, 38:98] = np.array([190, 180, 165], dtype=np.uint8)[:, None, None]
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
with rasterio.open( with rasterio.open(
path, path,
"w", "w",
driver="GTiff", driver="GTiff",
width=128, width=width,
height=128, height=height,
count=3, count=3,
dtype="uint8", dtype="uint8",
crs="EPSG:4326", 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: ) as dataset:
dataset.write(data) dataset.write(data)
@@ -174,7 +174,11 @@ def _write_review_portfolio(tmp_path: Path, *, unsafe_tile: bool = False) -> tup
samples = [] samples = []
for sample_index, sample_slug in enumerate(("geel", "turnhout")): for sample_index, sample_slug in enumerate(("geel", "turnhout")):
tile_path = storage_root / sample_slug / "tile_0000.tif" 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 selected_tile = (tmp_path / "outside.tif") if unsafe_tile and sample_slug == "geel" else tile_path
if unsafe_tile and sample_slug == "geel": if unsafe_tile and sample_slug == "geel":
_write_geotiff(selected_tile, seed=99) _write_geotiff(selected_tile, seed=99)
+10
View File
@@ -7203,6 +7203,7 @@ Open:
- Added a storage-root-confined contact-sheet renderer for persisted detection false-positive evidence. - 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. - 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. - 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 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. - 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. - 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. - `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. - 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 ## 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. - 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.
@@ -141,7 +141,7 @@ def require_dependencies() -> dict[str, Any]:
import rasterio import rasterio
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from pyproj import Geod, Transformer from pyproj import Geod, Transformer
from shapely.geometry import box, shape from shapely.geometry import Polygon, box, shape
from shapely.ops import transform from shapely.ops import transform
except ImportError as exc: except ImportError as exc:
raise SystemExit( raise SystemExit(
@@ -156,6 +156,7 @@ def require_dependencies() -> dict[str, Any]:
"Geod": Geod, "Geod": Geod,
"Transformer": Transformer, "Transformer": Transformer,
"box": box, "box": box,
"Polygon": Polygon,
"shape": shape, "shape": shape,
"transform": transform, "transform": transform,
} }
@@ -212,15 +213,23 @@ def draw_geometry(
draw: Any, draw: Any,
geometry: Any, geometry: Any,
inverse_transform: Any, inverse_transform: Any,
scale_x: float, scale: float,
scale_y: float, offset_x: float,
offset_y: float,
crop_left: float,
crop_top: float,
color: tuple[int, int, int], color: tuple[int, int, int],
) -> None: ) -> None:
for ring in polygon_rings(geometry): for ring in polygon_rings(geometry):
points = [] points = []
for x, y in ring.coords: for x, y in ring.coords:
column, row = inverse_transform * (x, y) 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: if len(points) >= 2:
draw.line(points, fill=color, width=2, joint="curve") draw.line(points, fill=color, width=2, joint="curve")
@@ -239,18 +248,44 @@ def render_card(
Transformer = dependencies["Transformer"] Transformer = dependencies["Transformer"]
shape = dependencies["shape"] shape = dependencies["shape"]
transform_geometry = dependencies["transform"] transform_geometry = dependencies["transform"]
box = dependencies["box"] Polygon = dependencies["Polygon"]
header_height = 88 header_height = 88
with rasterio.open(record["resolved_source_tile_path"]) as source: with rasterio.open(record["resolved_source_tile_path"]) as source:
pixels = normalize_raster(source.read(), numpy) pixels = normalize_raster(source.read(), numpy)
image = Image.fromarray(pixels, mode="RGB").resize( bbox = record["bbox_json"]
(thumb_size, thumb_size), Image.Resampling.BILINEAR 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( card = Image.new(
"RGB", (thumb_size, thumb_size + header_height), color=(242, 245, 247) "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) draw = ImageDraw.Draw(card)
font = ImageFont.load_default() font = ImageFont.load_default()
draw.rectangle((0, 0, thumb_size, header_height), fill=(22, 29, 38)) 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 (6, 72), "green ref | blue miss", fill=(235, 238, 241), font=font
) )
scale_x = thumb_size / source.width candidate_box = (
scale_y = thumb_size / source.height (float(bbox["x_min"]) - crop_left) * scale + image_offset_x,
bbox = record["bbox_json"] header_height
draw.rectangle( + (float(bbox["y_min"]) - crop_top) * scale
( + image_offset_y,
max(0, float(bbox["x_min"]) * scale_x), (float(bbox["x_max"]) - crop_left) * scale + image_offset_x,
header_height + max(0, float(bbox["y_min"]) * scale_y), header_height
min(thumb_size - 1, float(bbox["x_max"]) * scale_x), + (float(bbox["y_max"]) - crop_top) * scale
header_height + min(thumb_size - 1, float(bbox["y_max"]) * scale_y), + image_offset_y,
),
outline=(231, 76, 60),
width=3,
) )
overlay_count = 0 overlay_count = 0
if source.crs: if source.crs:
transformer = Transformer.from_crs("EPSG:4326", source.crs, always_xy=True) 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 = Image.new("RGBA", (thumb_size, thumb_size), (0, 0, 0, 0))
overlay_draw = ImageDraw.Draw(overlay) overlay_draw = ImageDraw.Draw(overlay)
for reference in references: for reference in references:
geometry = transform_geometry(transformer.transform, shape(reference["geometry"])) 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 continue
color = ( color = (
(39, 174, 96) (39, 174, 96)
@@ -312,12 +351,16 @@ def render_card(
overlay_draw, overlay_draw,
geometry, geometry,
~source.transform, ~source.transform,
scale_x, scale,
scale_y, image_offset_x,
image_offset_y,
crop_left,
crop_top,
color, color,
) )
overlay_count += 1 overlay_count += 1
card.paste(overlay, (0, header_height), overlay) card.paste(overlay, (0, header_height), overlay)
draw.rectangle(candidate_box, outline=(231, 76, 60), width=3)
return card, overlay_count return card, overlay_count