| """Canonical record schema used by every dataset loader. |
| |
| Each loader yields ``Record`` instances with as many fields filled as the source |
| dataset supports. Downstream code (reference table, training, identification) |
| consumes only this schema, so adding a new dataset later is a one-file change. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass, field, asdict |
| from pathlib import Path |
| from typing import Any, Literal, Optional |
|
|
| |
| |
| |
| |
| |
| CANONICAL_PARTS: tuple[str, ...] = ( |
| "front_bumper", |
| "rear_bumper", |
| "hood", |
| "front_door", |
| "rear_door", |
| "front_fender", |
| "rear_quarter_panel", |
| "headlight", |
| "taillight", |
| "windshield", |
| "side_mirror", |
| "roof", |
| "trunk", |
| "wheel", |
| "grille", |
| ) |
|
|
| |
| |
| DAMAGE_TYPES: tuple[str, ...] = ( |
| "dent", |
| "scratch", |
| "crack", |
| "glass_shatter", |
| "lamp_broken", |
| "tire_flat", |
| ) |
|
|
| |
| |
| DamageLocation = Literal["front", "rear", "unknown"] |
| DamageCondition = Literal["normal", "crushed", "breakage", "unknown"] |
|
|
| Severity = Literal["minor", "moderate", "severe"] |
| Segment = Literal["economy", "mid", "luxury", "unknown"] |
| BodyType = Literal[ |
| "sedan", "suv", "hatchback", "coupe", "convertible", "pickup", "van", |
| "wagon", "minivan", "crossover", "truck", "unknown", |
| ] |
|
|
|
|
| @dataclass |
| class BBox: |
| """YOLO-style normalized bbox (0..1) with damage-type label and optional severity. |
| |
| The `damage_type` field carries one of `DAMAGE_TYPES` (the trainable label |
| from CarDD). `part` is *derived* downstream β leave it None at load time. |
| """ |
| damage_type: str |
| x_center: float |
| y_center: float |
| width: float |
| height: float |
| severity: Optional[Severity] = None |
| confidence: Optional[float] = None |
| part: Optional[str] = None |
|
|
|
|
| @dataclass |
| class Record: |
| """One image's worth of damage + car-identity + cost information.""" |
|
|
| image_path: Path |
| dataset: str |
|
|
| |
| damage_types: list[str] = field(default_factory=list) |
| bboxes: list[BBox] = field(default_factory=list) |
| damage_location: str = "unknown" |
| damage_condition: str = "unknown" |
| |
| parts: list[str] = field(default_factory=list) |
| parts_severity: dict[str, Severity] = field(default_factory=dict) |
|
|
| |
| make: Optional[str] = None |
| model: Optional[str] = None |
| year: Optional[int] = None |
| body_type: BodyType = "unknown" |
| segment: Segment = "unknown" |
|
|
| |
| cost: Optional[float] = None |
| cost_currency: Optional[str] = None |
| cost_usd: Optional[float] = None |
| cost_source: Optional[str] = None |
| fx_snapshot: dict[str, Any] = field(default_factory=dict) |
|
|
| |
| identification_tier: Optional[Literal["exact", "nearest_class", "none"]] = None |
| identification_source: Optional[str] = None |
| identification_confidence: Optional[float] = None |
|
|
| |
| extras: dict[str, Any] = field(default_factory=dict) |
|
|
| |
|
|
| @property |
| def image_id(self) -> str: |
| """Stable id derived from the path. Used as PK across processed tables.""" |
| return f"{self.dataset}/{self.image_path.name}" |
|
|
| @property |
| def is_identified(self) -> bool: |
| return self.make is not None and self.model is not None |
|
|
| def to_dict(self) -> dict[str, Any]: |
| d = asdict(self) |
| d["image_path"] = str(self.image_path) |
| d["bboxes"] = [asdict(b) for b in self.bboxes] |
| return d |
|
|
|
|
| def infer_part_from_damage( |
| damage_type: str, |
| bbox_center: Optional[tuple[float, float]] = None, |
| damage_location: str = "unknown", |
| ) -> Optional[str]: |
| """Heuristic mapping from a (damage_type, bbox-center, location) tuple to a |
| canonical part. Used at inference time to bridge trainable damage labels |
| with the parts-keyed cost catalog. |
| |
| `bbox_center` is normalized (x, y) in [0, 1] from image top-left. |
| `damage_location` is the auxiliary head's prediction ('front' | 'rear' | 'unknown'). |
| |
| Rules are intentionally conservative β return None when ambiguous so the |
| caller can fall through to a generic "front_bumper" / "rear_bumper" tier-3 |
| default. Mapping rationale documented in [CITATIONS.md] discussion. |
| """ |
| dt = damage_type.lower().replace(" ", "_") |
|
|
| |
| if dt == "tire_flat": |
| return "wheel" |
| if dt == "glass_shatter": |
| return "windshield" |
| if dt == "lamp_broken": |
| if damage_location == "rear" or (bbox_center and bbox_center[1] > 0.55): |
| return "taillight" |
| return "headlight" |
|
|
| |
| if bbox_center is None and damage_location == "unknown": |
| return None |
|
|
| is_front = damage_location == "front" or (bbox_center and bbox_center[0] > 0.55) |
| is_rear = damage_location == "rear" or (bbox_center and bbox_center[0] < 0.45) |
|
|
| if dt in {"dent", "scratch", "crack"}: |
| if bbox_center is None: |
| return "front_bumper" if is_front else "rear_bumper" if is_rear else None |
| x, y = bbox_center |
| |
| if y > 0.7: |
| return "front_bumper" if is_front else "rear_bumper" |
| if y < 0.35: |
| if is_front: |
| return "hood" |
| if is_rear: |
| return "trunk" |
| return "roof" |
| |
| if is_front: |
| return "front_door" if 0.3 < y < 0.65 else "front_fender" |
| if is_rear: |
| return "rear_door" if 0.3 < y < 0.65 else "rear_quarter_panel" |
| return None |
| return None |
|
|
|
|
| def map_to_canonical_part(raw_label: str) -> Optional[str]: |
| """Best-effort mapping from free-text damage label to canonical part name. |
| |
| Returns None if no confident mapping exists; caller decides whether to keep |
| in extras or drop. Kept intentionally small + auditable; expand in the EDA |
| notebook as new label vocabularies are discovered. |
| """ |
| s = raw_label.strip().lower().replace("-", " ").replace("_", " ") |
| rules: list[tuple[tuple[str, ...], str]] = [ |
| (("front bumper", "front-bumper", "bumper front"), "front_bumper"), |
| (("rear bumper", "back bumper", "bumper rear"), "rear_bumper"), |
| (("hood", "bonnet"), "hood"), |
| (("front door",), "front_door"), |
| (("rear door", "back door"), "rear_door"), |
| (("front fender", "front-fender", "fender front"), "front_fender"), |
| (("rear quarter", "quarter panel", "quarter-panel"), "rear_quarter_panel"), |
| (("headlight", "head light", "head lamp", "headlamp"), "headlight"), |
| (("taillight", "tail light", "tail lamp", "taillamp", "rear light"), "taillight"), |
| (("windshield", "windscreen", "front glass"), "windshield"), |
| (("side mirror", "wing mirror", "rear view mirror"), "side_mirror"), |
| (("roof",), "roof"), |
| (("trunk", "boot", "tailgate", "rear gate"), "trunk"), |
| (("wheel", "rim", "tire"), "wheel"), |
| (("grille", "grill"), "grille"), |
| ] |
| for needles, canonical in rules: |
| if any(n in s for n in needles): |
| return canonical |
| return None |
|
|