Preserve aspect ratio in false-positive review sheets
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user