Report blank YOLO label QA tiles
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-07-11 22:05:13 +02:00
parent fbccf8322e
commit 5688feec35
5 changed files with 70 additions and 16 deletions
@@ -68,6 +68,18 @@ def test_all_in_one_dockerfile_copies_operator_scripts_for_runtime_use() -> None
assert "COPY scripts/train_operator_yolo_detector.sh /app/scripts/train_operator_yolo_detector.sh" in dockerfile assert "COPY scripts/train_operator_yolo_detector.sh /app/scripts/train_operator_yolo_detector.sh" in dockerfile
def test_all_in_one_dockerfile_copies_operator_scripts_after_dependency_install() -> None:
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
dependency_install_index = dockerfile.index('RUN /usr/bin/python3.11 -m venv /opt/geointel/venv \\')
operator_copy_index = dockerfile.index(
"COPY scripts/render_operator_yolo_label_qa_contact_sheets.py "
"/app/scripts/render_operator_yolo_label_qa_contact_sheets.py"
)
assert operator_copy_index > dependency_install_index
def test_compose_does_not_require_missing_root_env_file() -> None: def test_compose_does_not_require_missing_root_env_file() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
@@ -5,12 +5,20 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
def write_patterned_image(path: Path, color: tuple[int, int, int]) -> None:
image = Image.new("RGB", (64, 64), color=color)
draw = ImageDraw.Draw(image)
draw.rectangle((8, 8, 38, 30), fill=(220, 220, 210))
draw.line((0, 48, 64, 24), fill=(30, 40, 50), width=4)
image.save(path)
def test_operator_yolo_label_qa_contact_sheets_render_visual_artifacts(tmp_path: Path) -> None: def test_operator_yolo_label_qa_contact_sheets_render_visual_artifacts(tmp_path: Path) -> None:
script_path = ROOT / "scripts" / "render_operator_yolo_label_qa_contact_sheets.py" script_path = ROOT / "scripts" / "render_operator_yolo_label_qa_contact_sheets.py"
assert script_path.exists() assert script_path.exists()
@@ -29,9 +37,9 @@ def test_operator_yolo_label_qa_contact_sheets_render_visual_artifacts(tmp_path:
(image_train / "dense_000.png", (120, 130, 140)), (image_train / "dense_000.png", (120, 130, 140)),
(image_train / "invalid_000.png", (80, 100, 120)), (image_train / "invalid_000.png", (80, 100, 120)),
(image_val / "missing_000.png", (90, 120, 90)), (image_val / "missing_000.png", (90, 120, 90)),
(image_val / "negative_000.png", (50, 50, 55)),
): ):
Image.new("RGB", (64, 64), color=color).save(path) write_patterned_image(path, color)
Image.new("RGB", (64, 64), color=(255, 255, 255)).save(image_val / "negative_000.png")
(labels_train / "dense_000.txt").write_text( (labels_train / "dense_000.txt").write_text(
"0 0.500000 0.500000 0.500000 0.500000\n" "0 0.500000 0.500000 0.500000 0.500000\n"
@@ -141,12 +149,16 @@ def test_operator_yolo_label_qa_contact_sheets_render_visual_artifacts(tmp_path:
assert report["rendered_tile_count"] == 4 assert report["rendered_tile_count"] == 4
assert report["missing_label_file_count"] == 1 assert report["missing_label_file_count"] == 1
assert report["invalid_label_count"] == 1 assert report["invalid_label_count"] == 1
assert report["low_visual_variance_tile_count"] == 1
assert [tile["sample_slug"] for tile in report["selected_tiles"]] == [ assert [tile["sample_slug"] for tile in report["selected_tiles"]] == [
"dense", "dense",
"invalid", "invalid",
"missing", "missing",
"negative", "negative",
] ]
tile_by_slug = {tile["sample_slug"]: tile for tile in report["selected_tiles"]}
assert tile_by_slug["negative"]["low_visual_variance"] is True
assert tile_by_slug["dense"]["low_visual_variance"] is False
sheet_path = output_dir / report["contact_sheets"][0]["path"] sheet_path = output_dir / report["contact_sheets"][0]["path"]
assert sheet_path.exists() assert sheet_path.exists()
@@ -159,4 +171,5 @@ def test_operator_yolo_label_qa_contact_sheets_render_visual_artifacts(tmp_path:
assert "Operator YOLO Label QA Contact Sheets" in markdown assert "Operator YOLO Label QA Contact Sheets" in markdown
assert "missing label files: 1" in markdown assert "missing label files: 1" in markdown
assert "invalid label rows: 1" in markdown assert "invalid label rows: 1" in markdown
assert "low-variance rendered tiles: 1" in markdown
assert "contact_sheet_001.png" in markdown assert "contact_sheet_001.png" in markdown
+13 -11
View File
@@ -46,6 +46,17 @@ WORKDIR /app
COPY backend/ /app/ COPY backend/ /app/
COPY fixtures/ /app/fixtures/ COPY fixtures/ /app/fixtures/
RUN /usr/bin/python3.11 -m venv /opt/geointel/venv \
&& pip install --no-cache-dir --upgrade pip setuptools \
&& extras=".[gis]" \
&& if [ "$GEOINTEL_INSTALL_AI" = "true" ]; then extras=".[gis,ai]"; fi \
&& pip install --no-cache-dir "$extras" \
&& python scripts/gis_import_smoke.py \
&& python scripts/yolo_preflight.py --json >/tmp/geointel-yolo-preflight.json \
&& rm -f /etc/nginx/sites-enabled/default \
&& mkdir -p /app/storage /run/nginx /var/log/nginx
COPY scripts/prepare_operator_real_data_samples.py /app/scripts/prepare_operator_real_data_samples.py COPY scripts/prepare_operator_real_data_samples.py /app/scripts/prepare_operator_real_data_samples.py
COPY scripts/export_operator_yolo_tile_dataset.py /app/scripts/export_operator_yolo_tile_dataset.py COPY scripts/export_operator_yolo_tile_dataset.py /app/scripts/export_operator_yolo_tile_dataset.py
COPY scripts/audit_operator_yolo_dataset_quality.py /app/scripts/audit_operator_yolo_dataset_quality.py COPY scripts/audit_operator_yolo_dataset_quality.py /app/scripts/audit_operator_yolo_dataset_quality.py
@@ -55,17 +66,8 @@ COPY deploy/unraid/nginx-all-in-one.conf /etc/nginx/conf.d/default.conf
COPY deploy/unraid/all-in-one-start.sh /usr/local/bin/geointel-all-in-one-start COPY deploy/unraid/all-in-one-start.sh /usr/local/bin/geointel-all-in-one-start
COPY --from=frontend-build /frontend/dist/ /usr/share/nginx/html/ COPY --from=frontend-build /frontend/dist/ /usr/share/nginx/html/
RUN /usr/bin/python3.11 -m venv /opt/geointel/venv \ RUN chmod +x /usr/local/bin/geointel-all-in-one-start \
&& pip install --no-cache-dir --upgrade pip setuptools \ && chmod +x /app/scripts/train_operator_yolo_detector.sh
&& extras=".[gis]" \
&& if [ "$GEOINTEL_INSTALL_AI" = "true" ]; then extras=".[gis,ai]"; fi \
&& pip install --no-cache-dir "$extras" \
&& python scripts/gis_import_smoke.py \
&& python scripts/yolo_preflight.py --json >/tmp/geointel-yolo-preflight.json \
&& chmod +x /usr/local/bin/geointel-all-in-one-start \
&& chmod +x /app/scripts/train_operator_yolo_detector.sh \
&& rm -f /etc/nginx/sites-enabled/default \
&& mkdir -p /app/storage /run/nginx /var/log/nginx
VOLUME ["/var/lib/postgresql/data", "/app/storage"] VOLUME ["/var/lib/postgresql/data", "/app/storage"]
+2 -2
View File
@@ -436,8 +436,8 @@ The renderer writes `operator_yolo_label_qa_summary.json`,
`operator_yolo_label_qa_contact_sheet.md` and `contact_sheet_001.png`. It draws `operator_yolo_label_qa_contact_sheet.md` and `contact_sheet_001.png`. It draws
existing YOLO labels on existing tile images only; it does not run inference, existing YOLO labels on existing tile images only; it does not run inference,
train a model, fetch providers or create fake detections. Missing image files, train a model, fetch providers or create fake detections. Missing image files,
missing label files and invalid YOLO rows are reported in the JSON/Markdown missing label files, invalid YOLO rows and low-variance/blank-looking rendered
artifacts. tiles are reported in the JSON/Markdown artifacts.
Current Tower audit status: Current Tower audit status:
@@ -31,6 +31,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--max-tiles", type=int, default=24, help="Maximum selected tiles to render") parser.add_argument("--max-tiles", type=int, default=24, help="Maximum selected tiles to render")
parser.add_argument("--columns", type=int, default=4, help="Contact-sheet columns") parser.add_argument("--columns", type=int, default=4, help="Contact-sheet columns")
parser.add_argument("--thumb-size", type=int, default=256, help="Rendered tile thumbnail size in pixels") parser.add_argument("--thumb-size", type=int, default=256, help="Rendered tile thumbnail size in pixels")
parser.add_argument(
"--blank-range-threshold",
type=int,
default=3,
help="Mark rendered images with grayscale max-min range at or below this value as low variance.",
)
return parser.parse_args() return parser.parse_args()
@@ -146,6 +152,7 @@ def draw_tile_card(
thumb_size: int, thumb_size: int,
invalid_label_count: int, invalid_label_count: int,
missing_label_file: bool, missing_label_file: bool,
low_visual_variance: bool,
) -> Image.Image: ) -> Image.Image:
header_height = 44 header_height = 44
card = Image.new("RGB", (thumb_size, thumb_size + header_height), color=(245, 247, 250)) card = Image.new("RGB", (thumb_size, thumb_size + header_height), color=(245, 247, 250))
@@ -163,6 +170,8 @@ def draw_tile_card(
subtitle_parts.append("missing-label-file") subtitle_parts.append("missing-label-file")
if invalid_label_count: if invalid_label_count:
subtitle_parts.append(f"invalid:{invalid_label_count}") subtitle_parts.append(f"invalid:{invalid_label_count}")
if low_visual_variance:
subtitle_parts.append("low-variance")
draw.text((6, 6), title[:44], fill=(255, 255, 255), font=font) draw.text((6, 6), title[:44], fill=(255, 255, 255), font=font)
draw.text((6, 24), " | ".join(subtitle_parts)[:52], fill=(191, 219, 254), font=font) draw.text((6, 24), " | ".join(subtitle_parts)[:52], fill=(191, 219, 254), font=font)
@@ -180,6 +189,12 @@ def draw_tile_card(
return card return card
def image_has_low_visual_variance(image_path: Path, blank_range_threshold: int) -> bool:
image = Image.open(image_path).convert("L")
min_value, max_value = image.getextrema()
return (max_value - min_value) <= blank_range_threshold
def build_contact_sheet(cards: list[Image.Image], columns: int, output_path: Path) -> None: def build_contact_sheet(cards: list[Image.Image], columns: int, output_path: Path) -> None:
if not cards: if not cards:
return return
@@ -216,6 +231,7 @@ def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Nam
missing_label_file_count = 0 missing_label_file_count = 0
invalid_label_count = 0 invalid_label_count = 0
valid_label_count = 0 valid_label_count = 0
low_visual_variance_tile_count = 0
for tile in selected_tiles: for tile in selected_tiles:
image_path = resolve_path(tile.get("image_path"), summary_path) image_path = resolve_path(tile.get("image_path"), summary_path)
@@ -227,9 +243,13 @@ def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Nam
missing_label_file_count += 1 missing_label_file_count += 1
rendered = False rendered = False
low_visual_variance = False
if image_path is None or not image_path.exists(): if image_path is None or not image_path.exists():
missing_image_count += 1 missing_image_count += 1
else: else:
low_visual_variance = image_has_low_visual_variance(image_path, args.blank_range_threshold)
if low_visual_variance:
low_visual_variance_tile_count += 1
rendered_cards.append( rendered_cards.append(
draw_tile_card( draw_tile_card(
image_path=image_path, image_path=image_path,
@@ -238,6 +258,7 @@ def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Nam
thumb_size=args.thumb_size, thumb_size=args.thumb_size,
invalid_label_count=tile_invalid_count, invalid_label_count=tile_invalid_count,
missing_label_file=missing_label_file, missing_label_file=missing_label_file,
low_visual_variance=low_visual_variance,
) )
) )
rendered = True rendered = True
@@ -255,6 +276,7 @@ def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Nam
"valid_label_count": len(boxes), "valid_label_count": len(boxes),
"invalid_label_count": tile_invalid_count, "invalid_label_count": tile_invalid_count,
"missing_label_file": missing_label_file, "missing_label_file": missing_label_file,
"low_visual_variance": low_visual_variance,
"rendered": rendered, "rendered": rendered,
} }
) )
@@ -285,6 +307,8 @@ def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Nam
"missing_label_file_count": missing_label_file_count, "missing_label_file_count": missing_label_file_count,
"invalid_label_count": invalid_label_count, "invalid_label_count": invalid_label_count,
"valid_label_count": valid_label_count, "valid_label_count": valid_label_count,
"low_visual_variance_tile_count": low_visual_variance_tile_count,
"blank_range_threshold": args.blank_range_threshold,
"contact_sheets": contact_sheets, "contact_sheets": contact_sheets,
"selected_tiles": selected_report_tiles, "selected_tiles": selected_report_tiles,
}, },
@@ -302,6 +326,7 @@ def write_markdown(report: dict[str, Any], output_dir: Path) -> None:
f"- missing images: {report['missing_image_count']}", f"- missing images: {report['missing_image_count']}",
f"- missing label files: {report['missing_label_file_count']}", f"- missing label files: {report['missing_label_file_count']}",
f"- invalid label rows: {report['invalid_label_count']}", f"- invalid label rows: {report['invalid_label_count']}",
f"- low-variance rendered tiles: {report['low_visual_variance_tile_count']}",
f"- valid labels rendered: {report['valid_label_count']}", f"- valid labels rendered: {report['valid_label_count']}",
"", "",
"## Contact Sheets", "## Contact Sheets",
@@ -323,6 +348,8 @@ def write_markdown(report: dict[str, Any], output_dir: Path) -> None:
flags.append("missing-label-file") flags.append("missing-label-file")
if tile["invalid_label_count"]: if tile["invalid_label_count"]:
flags.append(f"invalid:{tile['invalid_label_count']}") flags.append(f"invalid:{tile['invalid_label_count']}")
if tile["low_visual_variance"]:
flags.append("low-variance")
flag_text = ", ".join(flags) if flags else "ok" flag_text = ", ".join(flags) if flags else "ok"
lines.append( lines.append(
"- " "- "