From 5688feec35ccb79cba9f072c74b5e23e688a65e4 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 22:05:13 +0200 Subject: [PATCH] Report blank YOLO label QA tiles --- backend/tests/test_docker_runtime_config.py | 12 +++++++++ ...7_operator_yolo_label_qa_contact_sheets.py | 19 ++++++++++--- deploy/unraid/Dockerfile.all-in-one | 24 +++++++++-------- scripts/README.md | 4 +-- ...r_operator_yolo_label_qa_contact_sheets.py | 27 +++++++++++++++++++ 5 files changed, 70 insertions(+), 16 deletions(-) diff --git a/backend/tests/test_docker_runtime_config.py b/backend/tests/test_docker_runtime_config.py index 9d893605..7d7c15f0 100644 --- a/backend/tests/test_docker_runtime_config.py +++ b/backend/tests/test_docker_runtime_config.py @@ -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 +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: compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") diff --git a/backend/tests/test_sprint167_operator_yolo_label_qa_contact_sheets.py b/backend/tests/test_sprint167_operator_yolo_label_qa_contact_sheets.py index f76fd7bf..c2ebbfb9 100644 --- a/backend/tests/test_sprint167_operator_yolo_label_qa_contact_sheets.py +++ b/backend/tests/test_sprint167_operator_yolo_label_qa_contact_sheets.py @@ -5,12 +5,20 @@ import subprocess import sys from pathlib import Path -from PIL import Image +from PIL import Image, ImageDraw 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: script_path = ROOT / "scripts" / "render_operator_yolo_label_qa_contact_sheets.py" 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 / "invalid_000.png", (80, 100, 120)), (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( "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["missing_label_file_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"]] == [ "dense", "invalid", "missing", "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"] 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 "missing label files: 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 diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one index 0d954ee3..6249bd35 100644 --- a/deploy/unraid/Dockerfile.all-in-one +++ b/deploy/unraid/Dockerfile.all-in-one @@ -46,6 +46,17 @@ WORKDIR /app COPY backend/ /app/ 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/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 @@ -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 --from=frontend-build /frontend/dist/ /usr/share/nginx/html/ -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 \ - && 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 +RUN chmod +x /usr/local/bin/geointel-all-in-one-start \ + && chmod +x /app/scripts/train_operator_yolo_detector.sh VOLUME ["/var/lib/postgresql/data", "/app/storage"] diff --git a/scripts/README.md b/scripts/README.md index 596e6bb5..c773dc17 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 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, -missing label files and invalid YOLO rows are reported in the JSON/Markdown -artifacts. +missing label files, invalid YOLO rows and low-variance/blank-looking rendered +tiles are reported in the JSON/Markdown artifacts. Current Tower audit status: diff --git a/scripts/render_operator_yolo_label_qa_contact_sheets.py b/scripts/render_operator_yolo_label_qa_contact_sheets.py index 5c6136db..7b3368e7 100644 --- a/scripts/render_operator_yolo_label_qa_contact_sheets.py +++ b/scripts/render_operator_yolo_label_qa_contact_sheets.py @@ -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("--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( + "--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() @@ -146,6 +152,7 @@ def draw_tile_card( thumb_size: int, invalid_label_count: int, missing_label_file: bool, + low_visual_variance: bool, ) -> Image.Image: header_height = 44 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") if 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, 24), " | ".join(subtitle_parts)[:52], fill=(191, 219, 254), font=font) @@ -180,6 +189,12 @@ def draw_tile_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: if not cards: return @@ -216,6 +231,7 @@ def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Nam missing_label_file_count = 0 invalid_label_count = 0 valid_label_count = 0 + low_visual_variance_tile_count = 0 for tile in selected_tiles: 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 rendered = False + low_visual_variance = False if image_path is None or not image_path.exists(): missing_image_count += 1 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( draw_tile_card( 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, invalid_label_count=tile_invalid_count, missing_label_file=missing_label_file, + low_visual_variance=low_visual_variance, ) ) rendered = True @@ -255,6 +276,7 @@ def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Nam "valid_label_count": len(boxes), "invalid_label_count": tile_invalid_count, "missing_label_file": missing_label_file, + "low_visual_variance": low_visual_variance, "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, "invalid_label_count": invalid_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, "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 label files: {report['missing_label_file_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']}", "", "## Contact Sheets", @@ -323,6 +348,8 @@ def write_markdown(report: dict[str, Any], output_dir: Path) -> None: flags.append("missing-label-file") if 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" lines.append( "- "