"""Conservative image-analysis assist for obvious film defects. This module does not run a model. It uses local contrast only to catch clear linear scratches and compact bright debris that MiniCPM can miss on real scans. """ from __future__ import annotations from dataclasses import dataclass from typing import Any import numpy as np from PIL import Image, ImageFilter, ImageOps from data.schemas import bbox_area, bbox_iou, normalize_bbox @dataclass(frozen=True) class Candidate: label: str bbox: tuple[float, float, float, float] score: float def to_json(self) -> dict[str, Any]: confidence = min(0.61, max(0.48, 0.46 + self.score * 0.48)) return { "label": self.label, "bbox": [round(v, 6) for v in self.bbox], "confidence": round(confidence, 4), } def detect_classical_defects( image: Any, *, max_defects: int = 32, include_compact_debris: bool = False, ) -> list[dict[str, Any]]: """Return high-precision defect candidates from image structure only.""" pil_image = ImageOps.exif_transpose(image.convert("RGB")) width, height = pil_image.size if width < 64 or height < 64: return [] work = _resize_work_image(pil_image) gray = _gray_array(work) blur = _blur_array(gray, work.size) residual = gray - blur mask = _bright_residual_mask(gray, residual) candidates: list[Candidate] = [] candidates.extend(_linear_candidates(mask, residual)) if include_compact_debris: candidates.extend(_compact_debris_candidates(mask, residual)) candidates = _dedupe_candidates(candidates, max_defects=max(1, int(max_defects))) return [candidate.to_json() for candidate in candidates] def _resize_work_image(image: Image.Image) -> Image.Image: width, height = image.size max_side = 1100 scale = min(1.0, max_side / float(max(width, height))) if scale >= 1.0: return image.copy() new_size = ( max(1, int(round(width * scale))), max(1, int(round(height * scale))), ) return image.resize(new_size, Image.Resampling.LANCZOS) def _gray_array(image: Image.Image) -> np.ndarray: arr = np.asarray(image).astype("float32") / 255.0 return ( 0.2126 * arr[:, :, 0] + 0.7152 * arr[:, :, 1] + 0.0722 * arr[:, :, 2] ) def _blur_array(gray: np.ndarray, size: tuple[int, int]) -> np.ndarray: width, height = size radius = max(2, int(round(max(width, height) * 0.006))) source = Image.fromarray(np.uint8(np.clip(gray * 255.0, 0, 255))) blurred = source.filter(ImageFilter.GaussianBlur(radius=radius)) return np.asarray(blurred).astype("float32") / 255.0 def _bright_residual_mask(gray: np.ndarray, residual: np.ndarray) -> np.ndarray: threshold = max(0.045, float(np.percentile(residual, 99.2))) mask = (residual >= threshold) & (gray > float(np.percentile(gray, 42))) mask[:2, :] = False mask[-2:, :] = False mask[:, :2] = False mask[:, -2:] = False return mask def _linear_candidates(mask: np.ndarray, residual: np.ndarray) -> list[Candidate]: height, width = mask.shape min_horizontal = max(22, int(round(width * 0.035))) min_vertical = max(22, int(round(height * 0.035))) candidates: list[Candidate] = [] for y in range(height): xs = np.flatnonzero(mask[y]) if xs.size == 0: continue for run in _contiguous_runs(xs): if run.size < min_horizontal: continue x0, x1 = int(run[0]), int(run[-1]) + 1 pad = max(2, int(round(height * 0.003))) bbox = ( x0 / width, max(0, y - pad) / height, x1 / width, min(height, y + pad + 1) / height, ) if _is_border_frame(bbox): continue candidates.append( Candidate("scratch", bbox, float(residual[y, run].mean())) ) for x in range(width): ys = np.flatnonzero(mask[:, x]) if ys.size == 0: continue for run in _contiguous_runs(ys): if run.size < min_vertical: continue y0, y1 = int(run[0]), int(run[-1]) + 1 pad = max(2, int(round(width * 0.003))) bbox = ( max(0, x - pad) / width, y0 / height, min(width, x + pad + 1) / width, y1 / height, ) if _is_border_frame(bbox): continue candidates.append( Candidate("scratch", bbox, float(residual[run, x].mean())) ) return candidates def _compact_debris_candidates(mask: np.ndarray, residual: np.ndarray) -> list[Candidate]: if int(mask.sum()) > 45_000: return [] height, width = mask.shape visited = np.zeros(mask.shape, dtype=bool) points = np.argwhere(mask) candidates: list[Candidate] = [] for y_raw, x_raw in points: y = int(y_raw) x = int(x_raw) if visited[y, x] or not mask[y, x]: continue coords = _component(mask, visited, y, x, max_pixels=900) if len(coords) < 3 or len(coords) > 850: continue ys = np.array([coord[0] for coord in coords], dtype=np.int32) xs = np.array([coord[1] for coord in coords], dtype=np.int32) x0 = int(xs.min()) x1 = int(xs.max()) + 1 y0 = int(ys.min()) y1 = int(ys.max()) + 1 box_width = x1 - x0 box_height = y1 - y0 if box_width > width * 0.1 or box_height > height * 0.1: continue aspect = max(box_width / max(1, box_height), box_height / max(1, box_width)) label = "scratch" if aspect >= 8.0 else "dust" pad = 3 if label == "scratch" else 2 bbox = ( max(0, x0 - pad) / width, max(0, y0 - pad) / height, min(width, x1 + pad) / width, min(height, y1 + pad) / height, ) if _is_tiny_edge_artifact(bbox) or _is_border_frame(bbox): continue score = float(residual[ys, xs].mean()) candidates.append(Candidate(label, bbox, score)) return candidates def _component( mask: np.ndarray, visited: np.ndarray, start_y: int, start_x: int, *, max_pixels: int, ) -> list[tuple[int, int]]: height, width = mask.shape stack = [(start_y, start_x)] visited[start_y, start_x] = True coords: list[tuple[int, int]] = [] while stack and len(coords) < max_pixels: y, x = stack.pop() coords.append((y, x)) for next_y in (y - 1, y, y + 1): if next_y < 0 or next_y >= height: continue for next_x in (x - 1, x, x + 1): if next_x < 0 or next_x >= width: continue if visited[next_y, next_x] or not mask[next_y, next_x]: continue visited[next_y, next_x] = True stack.append((next_y, next_x)) return coords def _contiguous_runs(values: np.ndarray) -> list[np.ndarray]: splits = np.where(np.diff(values) > 1)[0] + 1 return list(np.split(values, splits)) def _dedupe_candidates( candidates: list[Candidate], *, max_defects: int, ) -> list[Candidate]: kept: list[Candidate] = [] for candidate in sorted(candidates, key=lambda item: item.score, reverse=True): if len(kept) >= max_defects: break bbox = normalize_bbox(candidate.bbox) if bbox is None or bbox_area(bbox) <= 0: continue if any(_overlaps_existing(candidate, existing) for existing in kept): continue kept.append(candidate) return kept def _overlaps_existing(candidate: Candidate, existing: Candidate) -> bool: if bbox_iou(candidate.bbox, existing.bbox) >= 0.15: return True if candidate.label != "scratch" or existing.label != "scratch": return False cx0, cy0, cx1, cy1 = candidate.bbox ex0, ey0, ex1, ey1 = existing.bbox same_row = abs(((cy0 + cy1) / 2.0) - ((ey0 + ey1) / 2.0)) < 0.025 x_overlap = max(0.0, min(cx1, ex1) - max(cx0, ex0)) return same_row and x_overlap > 0.05 def _is_border_frame(bbox: tuple[float, float, float, float]) -> bool: x0, y0, x1, y1 = bbox width = x1 - x0 height = y1 - y0 near_outer_edge = x0 < 0.012 or y0 < 0.012 or x1 > 0.988 or y1 > 0.988 return near_outer_edge and (width > 0.2 or height > 0.2) def _is_tiny_edge_artifact(bbox: tuple[float, float, float, float]) -> bool: x0, y0, x1, y1 = bbox area = max(0.0, x1 - x0) * max(0.0, y1 - y0) center_x = (x0 + x1) / 2.0 return area < 0.0016 and (center_x < 0.12 or center_x > 0.88) __all__ = ["detect_classical_defects"]