| """Defect schema and geometry helpers for Project Halide.""" |
|
|
| from __future__ import annotations |
|
|
| import os |
| from dataclasses import dataclass |
| from typing import Any, Iterable |
|
|
| ALLOWED_LABELS = frozenset( |
| { |
| "dust", |
| "dirt", |
| "scratch", |
| "long_hair", |
| "short_hair", |
| "emulsion_damage", |
| "chemical_stain", |
| "light_leak", |
| } |
| ) |
|
|
| DEDUP_IOU_THRESHOLD = 0.72 |
|
|
| LABEL_DISPLAY_NAMES = { |
| "dust": "Dust", |
| "dirt": "Dirt", |
| "scratch": "Scratch", |
| "long_hair": "Long hair", |
| "short_hair": "Short hair", |
| "emulsion_damage": "Emulsion damage", |
| "chemical_stain": "Chemical stain", |
| "light_leak": "Light leak", |
| } |
|
|
| DEFECT_CLASSES_KNOWN = { |
| "dust": 0, |
| "dirt": 1, |
| "scratch": 2, |
| "long_hair": 3, |
| "short_hair": 4, |
| "light_leak": 5, |
| "chemical_stain": 6, |
| "emulsion_damage": 7, |
| } |
|
|
| BBox = tuple[float, float, float, float] |
|
|
|
|
| def _env_float(name: str, default: float) -> float: |
| try: |
| return float(os.getenv(name, str(default))) |
| except (TypeError, ValueError): |
| return default |
|
|
|
|
| SUBJECT_HAIR_CONFIDENCE_MAX = 0.5 |
| MIN_DEFECT_CONFIDENCE = _env_float("HALIDE_MIN_DEFECT_CONFIDENCE", 0.45) |
|
|
|
|
| @dataclass(frozen=True) |
| class Defect: |
| label: str |
| bbox: BBox |
| confidence: float | None = None |
|
|
| def to_json(self) -> dict[str, Any]: |
| out: dict[str, Any] = { |
| "label": self.label, |
| "bbox": [round(v, 6) for v in self.bbox], |
| } |
| if self.confidence is not None: |
| out["confidence"] = round(float(self.confidence), 4) |
| return out |
|
|
|
|
| def _unwrap_bbox(bbox: Any) -> Any: |
| """Accept a single nested bbox from imperfect model JSON.""" |
| if ( |
| isinstance(bbox, (list, tuple)) |
| and len(bbox) == 1 |
| and isinstance(bbox[0], (list, tuple)) |
| ): |
| return bbox[0] |
| return bbox |
|
|
|
|
| def normalize_bbox(bbox: Any) -> BBox | None: |
| """Normalize a bbox to float [0, 1]. |
| |
| Accepts either [0, 999] integer grid values or normalized [0, 1] floats. |
| Returns None for malformed, reversed, or out-of-range boxes. |
| """ |
| bbox = _unwrap_bbox(bbox) |
| if not isinstance(bbox, (list, tuple)) or len(bbox) != 4: |
| return None |
|
|
| try: |
| x_min, y_min, x_max, y_max = (float(v) for v in bbox) |
| except (TypeError, ValueError): |
| return None |
|
|
| if x_max <= x_min or y_max <= y_min: |
| return None |
|
|
| max_val = max(x_min, y_min, x_max, y_max) |
| all_whole = all( |
| isinstance(v, int) or (isinstance(v, float) and v.is_integer()) |
| for v in bbox |
| ) |
| scale = 999.0 if all_whole and max_val > 1.5 else 1.0 |
| if scale == 999.0: |
| x_min /= scale |
| y_min /= scale |
| x_max /= scale |
| y_max /= scale |
| if not all(-0.001 <= v <= 1.002 for v in (x_min, y_min, x_max, y_max)): |
| return None |
| x_min = max(0.0, min(1.0, x_min)) |
| y_min = max(0.0, min(1.0, y_min)) |
| x_max = max(0.0, min(1.0, x_max)) |
| y_max = max(0.0, min(1.0, y_max)) |
|
|
| if not all(0.0 <= v <= 1.0 for v in (x_min, y_min, x_max, y_max)): |
| return None |
| if x_max <= x_min or y_max <= y_min: |
| return None |
|
|
| return ( |
| round(x_min, 6), |
| round(y_min, 6), |
| round(x_max, 6), |
| round(y_max, 6), |
| ) |
|
|
|
|
| def validate_defect(raw: Any, min_confidence: float = MIN_DEFECT_CONFIDENCE) -> Defect | None: |
| if not isinstance(raw, dict): |
| return None |
| label = raw.get("label") |
| if label not in ALLOWED_LABELS: |
| return None |
| bbox = normalize_bbox(raw.get("bbox")) |
| if bbox is None: |
| return None |
| confidence = raw.get("confidence") |
| if confidence is not None: |
| try: |
| confidence = float(confidence) |
| except (TypeError, ValueError): |
| confidence = None |
| if confidence is not None and confidence < min_confidence: |
| return None |
| if is_likely_subject_hair(label, bbox, confidence): |
| return None |
| return Defect(label=label, bbox=bbox, confidence=confidence) |
|
|
|
|
| def is_likely_subject_hair( |
| label: str, |
| bbox: BBox, |
| confidence: float | None, |
| ) -> bool: |
| """Drop central hair-like subject detail before it reaches diagnosis.""" |
| if label not in {"long_hair", "short_hair"}: |
| return False |
| if confidence is not None and confidence >= SUBJECT_HAIR_CONFIDENCE_MAX: |
| return False |
|
|
| x_min, y_min, x_max, y_max = bbox |
| width = x_max - x_min |
| height = y_max - y_min |
| if width <= 0 or height <= 0: |
| return False |
|
|
| aspect_ratio = max(width / height, height / width) |
| fully_inside_subject_zone = ( |
| x_min > 0.16 |
| and x_max < 0.84 |
| and y_min > 0.10 |
| and y_max < 0.90 |
| ) |
| return fully_inside_subject_zone and aspect_ratio >= 7.5 |
|
|
|
|
| def clean_defects( |
| raw_defects: Any, |
| min_confidence: float = MIN_DEFECT_CONFIDENCE, |
| ) -> tuple[list[dict[str, Any]], int]: |
| """Return valid defect dicts and number of dropped records.""" |
| if not isinstance(raw_defects, list): |
| return [], 1 if raw_defects else 0 |
|
|
| cleaned: list[dict[str, Any]] = [] |
| dropped = 0 |
| for raw in raw_defects: |
| defect = validate_defect(raw, min_confidence=min_confidence) |
| if defect is None: |
| dropped += 1 |
| else: |
| cleaned.append(defect.to_json()) |
| return cleaned, dropped |
|
|
|
|
| def label_counts(defects: Iterable[dict[str, Any]]) -> dict[str, int]: |
| counts: dict[str, int] = {} |
| for defect in defects: |
| label = defect.get("label") |
| if label in ALLOWED_LABELS: |
| counts[label] = counts.get(label, 0) + 1 |
| return dict(sorted(counts.items())) |
|
|
|
|
| def _defect_confidence(defect: dict[str, Any]) -> float: |
| value = defect.get("confidence") |
| try: |
| return float(value) |
| except (TypeError, ValueError): |
| return 0.5 |
|
|
|
|
| def _serialize_defect(label: str, bbox: BBox, source: dict[str, Any]) -> dict[str, Any]: |
| out: dict[str, Any] = {"label": label, "bbox": [round(v, 6) for v in bbox]} |
| if source.get("confidence") is not None: |
| out["confidence"] = round(_defect_confidence(source), 4) |
| return out |
|
|
|
|
| def dedupe_defects( |
| defects: Iterable[dict[str, Any]], |
| iou_threshold: float = DEDUP_IOU_THRESHOLD, |
| ) -> tuple[list[dict[str, Any]], int]: |
| """Drop exact and heavily overlapping duplicates from already-clean defects.""" |
| unique: list[dict[str, Any]] = [] |
| seen: set[tuple[str, tuple[float, float, float, float]]] = set() |
| duplicate_count = 0 |
|
|
| for defect in defects: |
| label = str(defect.get("label", "")) |
| bbox = normalize_bbox(defect.get("bbox")) |
| if label not in ALLOWED_LABELS or bbox is None: |
| continue |
| key = (label, bbox) |
| if key in seen: |
| duplicate_count += 1 |
| continue |
| seen.add(key) |
| unique.append(_serialize_defect(label, bbox, defect)) |
|
|
| merged: list[dict[str, Any]] = [] |
| for defect in unique: |
| label = str(defect.get("label", "")) |
| bbox = normalize_bbox(defect.get("bbox")) |
| if bbox is None: |
| continue |
| replaced = False |
| for index, existing in enumerate(merged): |
| if existing.get("label") != label: |
| continue |
| if bbox_iou(existing.get("bbox"), bbox) < iou_threshold: |
| continue |
| duplicate_count += 1 |
| if _defect_confidence(defect) > _defect_confidence(existing): |
| merged[index] = defect |
| replaced = True |
| break |
| if not replaced: |
| merged.append(defect) |
|
|
| return merged, duplicate_count |
|
|
|
|
| def filter_edge_artifacts(defects: Iterable[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]: |
| """Drop repeated edge artifacts that look like film borders or sprockets.""" |
| filtered: list[dict[str, Any]] = [] |
| dropped = 0 |
| for defect in defects: |
| label = str(defect.get("label", "")) |
| bbox = normalize_bbox(defect.get("bbox")) |
| if bbox is None: |
| dropped += 1 |
| continue |
| if _is_edge_artifact(label, bbox, defect.get("confidence")): |
| dropped += 1 |
| continue |
| filtered.append(_serialize_defect(label, bbox, defect)) |
| return filtered, dropped |
|
|
|
|
| def _is_edge_artifact(label: str, bbox: BBox, confidence: Any) -> bool: |
| x_min, y_min, x_max, y_max = bbox |
| width = x_max - x_min |
| height = y_max - y_min |
| area = width * height |
| center_x = (x_min + x_max) / 2.0 |
| center_y = (y_min + y_max) / 2.0 |
|
|
| try: |
| confidence_value = float(confidence) if confidence is not None else None |
| except (TypeError, ValueError): |
| confidence_value = None |
|
|
| low_evidence = confidence_value is None or confidence_value < 0.62 |
| if not low_evidence: |
| return False |
|
|
| if label == "dust" and area < 0.0016 and (center_x < 0.12 or center_x > 0.88): |
| return True |
|
|
| if width < 0.02 and height > 0.18 and (x_min <= 0.004 or x_max >= 0.996): |
| return True |
|
|
| if height < 0.02 and width > 0.22 and (y_min <= 0.004 or y_max >= 0.996): |
| return True |
|
|
| if label in {"scratch", "emulsion_damage"}: |
| if width < 0.075 and height > 0.22 and (x_min <= 0.006 or x_max >= 0.994): |
| return True |
| if height < 0.075 and width > 0.22 and (y_min <= 0.006 or y_max >= 0.994): |
| return True |
|
|
| if label == "scratch": |
| if width < 0.04 and height > 0.05 and (center_x < 0.12 or center_x > 0.78): |
| return True |
| if height < 0.04 and width > 0.05 and ( |
| center_y < 0.08 |
| or center_y > 0.88 |
| or center_x < 0.12 |
| or center_x > 0.78 |
| ): |
| return True |
|
|
| return False |
|
|
|
|
| def bbox_area(bbox: Any) -> float: |
| norm = normalize_bbox(bbox) |
| if norm is None: |
| return 0.0 |
| x_min, y_min, x_max, y_max = norm |
| return max(0.0, x_max - x_min) * max(0.0, y_max - y_min) |
|
|
|
|
| def bbox_iou(a: Any, b: Any) -> float: |
| box_a = normalize_bbox(a) |
| box_b = normalize_bbox(b) |
| if box_a is None or box_b is None: |
| return 0.0 |
|
|
| ax1, ay1, ax2, ay2 = box_a |
| bx1, by1, bx2, by2 = box_b |
| ix1 = max(ax1, bx1) |
| iy1 = max(ay1, by1) |
| ix2 = min(ax2, bx2) |
| iy2 = min(ay2, by2) |
| if ix2 <= ix1 or iy2 <= iy1: |
| return 0.0 |
|
|
| inter = (ix2 - ix1) * (iy2 - iy1) |
| union = bbox_area(box_a) + bbox_area(box_b) - inter |
| if union <= 0: |
| return 0.0 |
| return round(inter / union, 6) |
|
|
|
|
| def bbox_to_pixels(bbox: Any, width: int, height: int) -> tuple[int, int, int, int] | None: |
| norm = normalize_bbox(bbox) |
| if norm is None: |
| return None |
| x_min, y_min, x_max, y_max = norm |
| return ( |
| int(round(x_min * width)), |
| int(round(y_min * height)), |
| int(round(x_max * width)), |
| int(round(y_max * height)), |
| ) |
|
|
|
|
| def spatial_summary(defects: Iterable[dict[str, Any]]) -> dict[str, Any]: |
| """Compute compact spatial cues for the reasoning model.""" |
| defects_list = list(defects) |
| if not defects_list: |
| return { |
| "edge_defects": 0, |
| "center_defects": 0, |
| "largest_labels": [], |
| } |
|
|
| edge_count = 0 |
| center_count = 0 |
| largest: list[tuple[float, str]] = [] |
| for defect in defects_list: |
| bbox = normalize_bbox(defect.get("bbox")) |
| if bbox is None: |
| continue |
| x_min, y_min, x_max, y_max = bbox |
| cx = (x_min + x_max) / 2.0 |
| cy = (y_min + y_max) / 2.0 |
| if x_min < 0.08 or y_min < 0.08 or x_max > 0.92 or y_max > 0.92: |
| edge_count += 1 |
| if 0.35 <= cx <= 0.65 and 0.35 <= cy <= 0.65: |
| center_count += 1 |
| largest.append((bbox_area(bbox), str(defect.get("label", "unknown")))) |
|
|
| largest_labels = [ |
| label for _, label in sorted(largest, reverse=True)[:5] |
| ] |
| return { |
| "edge_defects": edge_count, |
| "center_defects": center_count, |
| "largest_labels": largest_labels, |
| } |
|
|
|
|
| __all__ = [ |
| "ALLOWED_LABELS", |
| "BBox", |
| "DEFECT_CLASSES_KNOWN", |
| "DEDUP_IOU_THRESHOLD", |
| "Defect", |
| "LABEL_DISPLAY_NAMES", |
| "MIN_DEFECT_CONFIDENCE", |
| "bbox_area", |
| "bbox_iou", |
| "bbox_to_pixels", |
| "clean_defects", |
| "dedupe_defects", |
| "filter_edge_artifacts", |
| "label_counts", |
| "normalize_bbox", |
| "spatial_summary", |
| "validate_defect", |
| ] |
|
|