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.
- 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.
@@ -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)
+10
View File
@@ -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.
@@ -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