project-halide / models /vision /inference.py
Lonelyguyse1's picture
Deploy Project Halide Gradio Space
5f0f16b verified
Raw
History Blame Contribute Delete
8.1 kB
"""Vision inference pipeline. Takes a film scan and returns defect JSON."""
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Any
from config import get_vision_config
from data.schemas import (
BBox,
clean_defects,
dedupe_defects,
filter_edge_artifacts,
label_counts,
normalize_bbox,
)
from data.preprocessing import load_image
from models.vision.classical_assist import detect_classical_defects
from models.vision.minicpm_wrapper import get_detector
logger = logging.getLogger(__name__)
def extract_defects(image: Any) -> dict:
"""Run defect extraction on a PIL image. Returns defect dict + metadata."""
started = time.perf_counter()
detector = get_detector()
input_image = load_image(image)
model_image, resized_for_model = _resize_for_model(input_image)
raw = detector.detect(model_image)
if not isinstance(raw, dict):
logger.warning("Model output is not a dict: %r", type(raw))
raw = {"defects": [], "_parse_error": "non_dict_output"}
cleaned, dropped = clean_defects(raw.get("defects", []))
full_frame_count = len(cleaned)
tile_fallback_used = False
tile_count = 0
tile_parse_errors: list[str] = []
classical_assist_count = 0
classical_assist_used = False
edge_artifact_count = 0
cfg = get_vision_config()
if _should_run_tile_fallback(input_image, cleaned):
tile_fallback_used = True
tile_defects: list[dict[str, Any]] = list(cleaned)
for tile_index, (tile_image, tile_box) in enumerate(_iter_tiles(input_image), start=1):
tile_count = tile_index
tile_model_image, tile_resized = _resize_for_model(tile_image)
resized_for_model = resized_for_model or tile_resized
tile_raw = detector.detect(tile_model_image)
if not isinstance(tile_raw, dict):
tile_parse_errors.append("non_dict_output")
dropped += 1
continue
if tile_raw.get("_parse_error"):
tile_parse_errors.append(str(tile_raw.get("_parse_error")))
tile_cleaned, tile_dropped = clean_defects(tile_raw.get("defects", []))
dropped += tile_dropped
tile_defects.extend(
_remap_tile_defects(
tile_cleaned,
tile_box=tile_box,
image_size=input_image.size,
)
)
if tile_count >= max(1, int(cfg.tile_max_tiles)):
break
cleaned = tile_defects
if cfg.classical_assist_enabled:
classical_raw = detect_classical_defects(
input_image,
max_defects=cfg.classical_assist_max_defects,
)
classical_cleaned, classical_dropped = clean_defects(classical_raw)
dropped += classical_dropped
classical_cleaned = [
defect for defect in classical_cleaned if defect.get("label") == "scratch"
]
classical_assist_count = len(classical_cleaned)
classical_assist_used = bool(classical_cleaned)
cleaned.extend(classical_cleaned)
cleaned, edge_artifact_count = filter_edge_artifacts(cleaned)
cleaned, duplicate_count = dedupe_defects(cleaned)
counts = label_counts(cleaned)
elapsed = time.perf_counter() - started
return {
"defects": cleaned,
"defect_count": len(cleaned),
"label_counts": counts,
"dropped_count": dropped,
"duplicate_count": duplicate_count,
"edge_artifact_count": edge_artifact_count,
"inference_seconds": round(elapsed, 3),
"model_path": detector.model_path,
"parse_error": raw.get("_parse_error"),
"resized_for_model": resized_for_model,
"tile_fallback_used": tile_fallback_used,
"tile_count": tile_count,
"full_frame_defect_count": full_frame_count,
"tile_parse_errors": tile_parse_errors,
"classical_assist_used": classical_assist_used,
"classical_assist_count": classical_assist_count,
}
def extract_defects_from_path(image_path: str | Path) -> dict:
"""Convenience: open image from path and run extraction."""
img = load_image(image_path)
return extract_defects(img)
def _resize_for_model(image: Any) -> tuple[Any, bool]:
cfg = get_vision_config()
max_pixels = max(1, int(cfg.max_input_pixels or 0))
width, height = image.size
pixels = width * height
if pixels <= max_pixels:
return image, False
scale = (max_pixels / float(pixels)) ** 0.5
new_size = (
max(1, int(round(width * scale))),
max(1, int(round(height * scale))),
)
return image.resize(new_size), True
def _should_run_tile_fallback(image: Any, defects: list[dict[str, Any]]) -> bool:
cfg = get_vision_config()
if not cfg.tile_fallback_enabled:
return False
if len(defects) >= max(0, int(cfg.tile_fallback_min_defects)):
return False
width, height = image.size
if max(width, height) < max(1, int(cfg.tile_min_side)):
return False
return True
def _iter_tiles(image: Any) -> list[tuple[Any, tuple[int, int, int, int]]]:
cfg = get_vision_config()
width, height = image.size
tile_side = min(max(1, int(cfg.tile_max_side)), max(width, height))
tile_width = min(width, tile_side)
tile_height = min(height, tile_side)
overlap = max(0.0, min(0.85, float(cfg.tile_overlap)))
xs = _axis_positions(width, tile_width, overlap)
ys = _axis_positions(height, tile_height, overlap)
tiles: list[tuple[Any, tuple[int, int, int, int]]] = []
center = ((width - tile_width) // 2, (height - tile_height) // 2)
ordered_positions = [(x, y) for y in ys for x in xs]
ordered_positions.insert(0, center)
seen: set[tuple[int, int]] = set()
for x, y in ordered_positions:
x = max(0, min(width - tile_width, x))
y = max(0, min(height - tile_height, y))
if (x, y) in seen:
continue
seen.add((x, y))
box = (x, y, x + tile_width, y + tile_height)
tiles.append((image.crop(box), box))
if len(tiles) >= max(1, int(cfg.tile_max_tiles)):
break
return tiles
def _axis_positions(length: int, tile_length: int, overlap: float) -> list[int]:
if length <= tile_length:
return [0]
stride = max(1, int(round(tile_length * (1.0 - overlap))))
limit = length - tile_length
positions = list(range(0, limit + 1, stride))
positions.extend([limit, limit // 2])
return sorted(set(max(0, min(limit, pos)) for pos in positions))
def _remap_tile_defects(
defects: list[dict[str, Any]],
*,
tile_box: tuple[int, int, int, int],
image_size: tuple[int, int],
) -> list[dict[str, Any]]:
image_width, image_height = image_size
x0, y0, x1, y1 = tile_box
tile_width = max(1, x1 - x0)
tile_height = max(1, y1 - y0)
remapped: list[dict[str, Any]] = []
for defect in defects:
bbox = normalize_bbox(defect.get("bbox"))
if bbox is None:
continue
gx0, gy0, gx1, gy1 = _remap_bbox(
bbox,
x0=x0,
y0=y0,
tile_width=tile_width,
tile_height=tile_height,
image_width=image_width,
image_height=image_height,
)
out = {
"label": defect.get("label"),
"bbox": [gx0, gy0, gx1, gy1],
}
if defect.get("confidence") is not None:
out["confidence"] = defect.get("confidence")
remapped.append(out)
return remapped
def _remap_bbox(
bbox: BBox,
*,
x0: int,
y0: int,
tile_width: int,
tile_height: int,
image_width: int,
image_height: int,
) -> BBox:
bx0, by0, bx1, by1 = bbox
return (
round((x0 + bx0 * tile_width) / image_width, 6),
round((y0 + by0 * tile_height) / image_height, 6),
round((x0 + bx1 * tile_width) / image_width, 6),
round((y0 + by1 * tile_height) / image_height, 6),
)