theDocWho's picture
Multi-car mode: detect every vehicle, identify each, group damage per car
fbfbcff
Raw
History Blame Contribute Delete
22.4 kB
"""Gradio Blocks UI for the ccdp inference pipelines.
Layout — three tabs:
1. Estimate — upload an image, see Variant A / B side-by-side cost
2. Catalog manager — list / view / activate parts-cost catalogs
3. FX manager — view / refresh USD↔INR rate
The "Label this car" tab from the original Phase 3 plan is deferred — the
unidentified-cars SQLite bucket is empty in production until we wire identification
into batch processing in a later checkpoint.
The launcher (`build_demo`) is the function the HF Space's ``app.py`` calls.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
import gradio as gr
from PIL import Image
from ccdp.costing import activate as activate_catalog
from ccdp.costing import fx as fxmod
from ccdp.costing import list_catalogs
from ccdp.identification.car_identifier import IdentificationResult, infer_segment
from ccdp.preprocess import preprocess
from ccdp.viz import (
annotate_car_box,
annotate_multicar,
annotate_no_detections,
annotate_prediction,
)
# ---------------------------------------------------------------------------
# Pipeline caching (load once, reuse for every UI interaction)
# ---------------------------------------------------------------------------
_pipelines: dict = {}
def _get_pipelines() -> dict:
"""Lazy-load the variant pipelines on first demo interaction."""
if not _pipelines:
from ccdp.infer.variant_a import VariantAPipeline
try:
_pipelines["a"] = VariantAPipeline()
except Exception as e: # noqa: BLE001
print(f"[demo] Variant A unavailable: {e}")
_pipelines["a"] = None
try:
from ccdp.infer.variant_b import VariantBPipeline
_pipelines["b"] = VariantBPipeline()
except Exception as e: # noqa: BLE001
print(f"[demo] Variant B unavailable: {e}")
_pipelines["b"] = None
try:
from ccdp.infer.variant_d import VariantDPipeline
_pipelines["d"] = VariantDPipeline()
except Exception as e: # noqa: BLE001
print(f"[demo] Variant D unavailable: {e}")
_pipelines["d"] = None
try:
from ccdp.infer.multi_car import MultiCarPipeline
_pipelines["multi"] = MultiCarPipeline()
except Exception as e: # noqa: BLE001
print(f"[demo] Multi-car unavailable: {e}")
_pipelines["multi"] = None
return _pipelines
def _format_multicar(pred) -> str:
"""Per-car breakdown markdown for the multi-car mode."""
if not pred.cars:
return "## Multi-car\n**No vehicles detected.**"
lines = [f"## Multi-car — {len(pred.cars)} vehicle(s)\n",
f"**Total: {pred.total_cost:.2f} {pred.currency}**\n"]
for c in pred.cars:
who = f"{c.make} {c.model}".strip() if c.make else f"{c.label} (unknown)"
dmg = ", ".join(c.damage_types) or "none"
parts = ", ".join(c.parts) or "—"
lines.append(
f"- **Car {c.index + 1} · {who}** ({c.confidence:.0%}) — "
f"{c.cost:.0f} {pred.currency}\n"
f" - damage: {dmg}\n - parts: {parts}"
)
if pred.unassigned_damage:
lines.append(f"\n_Unassigned damage (no car overlap): {', '.join(pred.unassigned_damage)}_")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Estimate tab handler
# ---------------------------------------------------------------------------
def _build_metadata(make, model_name, year, body_type) -> Optional[IdentificationResult]:
if not make:
return None
return IdentificationResult(
image_path=Path(""),
make=make.lower(),
model=(model_name.lower() if model_name else None),
year=int(year) if year else None,
body_type=body_type or "unknown",
segment=infer_segment(make),
confidence=1.0,
source="user",
)
def _format_identification(auto) -> str:
"""Render the gate + ML-identifier verdict as Markdown for the UI."""
g = auto.gate
if not auto.has_car:
return "## Car check\n**No car detected** — upload a photo containing a car."
head = f"## Car check\n**{g.label.title()} detected** ({g.score:.0%} confidence).\n\n"
if auto.ml is not None:
ml = auto.ml
body = (
f"**Identified:** {ml.make or '—'} {ml.model or ''} "
f"{('(' + str(ml.year) + ')') if ml.year else ''}{ml.confidence:.0%}\n\n"
)
if ml.topk:
alts = " · ".join(f"{n} {p:.0%}" for n, p in ml.topk)
body += f"_Top guesses: {alts}_\n\n"
body += f"_{auto.note}_\n"
return head + body
return head
def _estimate(
image: Image.Image,
model_choice: str,
currency: str,
classifier_threshold: float,
detector_conf: float,
identifier_confidence: float,
auto_detect: bool,
make: str,
model_name: str,
year: Optional[int],
body_type: str,
) -> tuple[Image.Image, str, str, str, str]:
"""Returns (annotated_image, id_summary, variant_a_summary, variant_b_summary, full_json).
When ``auto_detect`` is on and the user did not type a make, the car gate
(COCO Mask R-CNN) runs first: if no car is present we stop and say so;
otherwise the ML identifier fills make/model/year and the gate's car box is
drawn on the annotated image. A user-typed make always takes precedence.
"""
if image is None:
# 6 values to match outputs: annotated, id, variant_a, variant_b, variant_d, json
return None, "", "Please upload an image.", "", "", ""
pipes = _get_pipelines()
pil_image, preprocessing_meta = preprocess(image)
metadata = _build_metadata(make, model_name, year, body_type)
full: dict = {
"preprocessing": preprocessing_meta,
"thresholds": {
"classifier": classifier_threshold,
"detector_conf": detector_conf,
},
}
# Multi-car mode: detect every vehicle, identify each, group damage per car.
if model_choice == "Multi-car (group damage per car)":
mc = pipes.get("multi")
if not mc:
return (pil_image,
"## Multi-car\n_Model not loaded — needs the yoloseg + parts weights._",
"_Multi-car mode._", "_Multi-car mode._", "_Multi-car mode._",
json.dumps({"error": "multi-car model not loaded"}, indent=2))
try:
pred = mc.predict(pil_image, currency=currency)
full["multi_car"] = pred.to_dict()
total = (f"## Total\n**{pred.total_cost:.2f} {pred.currency}** "
f"across {len(pred.cars)} car(s)")
return (annotate_multicar(pil_image, pred), _format_multicar(pred),
"_Per-car results shown in the Car-check panel._",
"_Per-car results shown in the Car-check panel._",
total, json.dumps(full, indent=2, default=str))
except Exception as e: # noqa: BLE001
return (pil_image, f"## Multi-car\n_Error: {e}_", "", "", "",
json.dumps({"error": str(e)}, indent=2))
id_text = ""
car_box = None
car_label = "car"
# Auto-detect car presence + make/model when the user gave no make.
if auto_detect and metadata is None:
try:
from ccdp.identification.auto_identify import auto_identify
auto = auto_identify(pil_image, min_confidence=identifier_confidence)
full["auto_identify"] = auto.to_dict()
id_text = _format_identification(auto)
if not auto.has_car:
banner = annotate_no_detections(
pil_image, "No car detected — upload a photo containing a car."
)
skip = "_Skipped — no car in image._"
# 6 values: annotated, id, variant_a, variant_b, variant_d, json
return (banner, id_text, skip, skip, skip,
json.dumps(full, indent=2, default=str))
car_box, car_label = auto.gate.box, auto.gate.label
if auto.identification is not None:
metadata = auto.identification
except Exception as e: # noqa: BLE001 — auto-detect is best-effort
id_text = f"_Auto-detect unavailable ({e}). Enter make/model manually._"
a_text, b_text = "Variant A not loaded.", "Variant B not loaded."
d_text = "Variant D not run."
annotated = pil_image # default: no boxes
if model_choice in ("Variant A (ResNet50 classifier)", "Both"):
if pipes.get("a"):
pred = pipes["a"].predict(
pil_image, metadata=metadata, currency=currency,
threshold=classifier_threshold,
).to_dict()
full["variant_a"] = pred
a_text = _format_prediction("A", pred)
if model_choice in ("Variant B (YOLOv8 detector)", "Both"):
if pipes.get("b"):
pred_b = pipes["b"].predict(
pil_image, metadata=metadata, currency=currency,
conf=detector_conf,
)
pred = pred_b.to_dict()
full["variant_b"] = pred
b_text = _format_prediction("B", pred, n_detections=len(pred_b.detections))
annotated = annotate_prediction(pil_image, pred_b)
else:
b_text = (
"## Variant B\n"
"_Detector model not loaded — no boxes available. "
"See the server logs for the load error._"
)
if model_choice == "Variant D (parts-aware seg)":
if pipes.get("d"):
pred_d = pipes["d"].predict(pil_image, metadata=metadata, currency=currency)
full["variant_d"] = pred_d.to_dict()
d_text = _format_variant_d(pred_d)
annotated = annotate_prediction(pil_image, pred_d)
else:
d_text = (
"## Variant D\n_Parts-aware models not loaded — add `yoloseg.pt` + "
"`parts.pt` to `checkpoints/production/` (or the release)._"
)
# Draw the gate's car box on top so the user sees what was located.
if car_box is not None:
annotated = annotate_car_box(annotated, car_box, label=car_label)
return annotated, id_text, a_text, b_text, d_text, json.dumps(full, indent=2, default=str)
def _format_variant_d(pred) -> str:
cost = f"{pred.cost:.2f} {pred.currency}"
rows = "\n".join(
f"- **{a['damage_type']}** → {a['part'] or '—'} "
f"({a['severity']}, {a['source']})"
for a in pred.assignments
) or "_no damage detected_"
warn = f"\n\n⚠️ _{pred.warning}_" if pred.warning else ""
return (
f"## Variant D — parts-aware\n"
f"**Cost:** {cost} _(tier: `{pred.tier}`)_\n\n"
f"**Damage → part:**\n{rows}{warn}"
)
def _format_prediction(name: str, pred: dict, n_detections: Optional[int] = None) -> str:
cost = pred.get("cost", 0.0)
currency = pred.get("currency", "USD")
types = ", ".join(pred.get("damage_types", [])) or "—"
parts = ", ".join(pred.get("parts", [])) or "—"
tier = pred.get("tier", "?")
prov = pred.get("provenance", "")
detector_line = ""
if n_detections is not None:
if n_detections == 0:
detector_line = (
"**Detector:** ran, found **0 boxes** — try lowering the "
"*Detector confidence* slider, or this car may be undamaged "
"or out of the training distribution.\n\n"
)
else:
detector_line = f"**Detector:** {n_detections} box(es) above threshold.\n\n"
return (
f"## Variant {name}\n"
f"**Cost:** {cost:.2f} {currency} _(tier: `{tier}`)_\n\n"
f"{detector_line}"
f"**Damage types:** {types}\n\n"
f"**Parts:** {parts}\n\n"
f"_{prov}_\n"
)
# ---------------------------------------------------------------------------
# Catalog manager handlers
# ---------------------------------------------------------------------------
def _catalogs_table():
rows = list_catalogs()
return [
[
"★" if r["is_active"] else "",
r["catalog_id"],
r.get("created_at", "") or "",
r.get("currency", "") or "",
]
for r in rows
]
def _activate(catalog_id: str) -> str:
if not catalog_id:
return "Pick a catalog id first."
try:
activate_catalog(catalog_id.strip())
return f"Activated: `{catalog_id}`"
except FileNotFoundError as e:
return f"Not found: {e}"
# ---------------------------------------------------------------------------
# FX manager
# ---------------------------------------------------------------------------
def _fx_show() -> str:
try:
fr = fxmod.get_rate("USD", "INR")
return f"1 {fr.base} = **{fr.rate:.4f}** {fr.target} (source: `{fr.source}`, fetched: {fr.fetched_at})"
except RuntimeError as e:
return f"_Error: {e}_"
def _fx_refresh() -> str:
try:
fr = fxmod.refresh_rate("USD", "INR")
return f"**Refreshed.** 1 {fr.base} = **{fr.rate:.4f}** {fr.target} ({fr.source})"
except RuntimeError as e:
return f"_Error: {e}_"
# ---------------------------------------------------------------------------
# Demo factory
# ---------------------------------------------------------------------------
def build_demo() -> gr.Blocks:
"""Build the Gradio app. Returns it without launching; caller decides how to launch."""
with gr.Blocks(title="ccdp — Car Damage + Repair Cost") as demo:
gr.Markdown("# Car Crash Fix Amount Predictor")
gr.Markdown(
"Upload a damaged-car photo and (optionally) tell us the car's make / "
"model / year for the most accurate cost. See the GitHub repo "
"[theDocWho/car-crash-fix-amount-predictor]"
"(https://github.com/theDocWho/car-crash-fix-amount-predictor) for full docs."
)
with gr.Tab("Estimate"):
with gr.Row():
with gr.Column(scale=1):
image_in = gr.Image(type="pil", label="Car damage image")
model_choice = gr.Radio(
choices=["Variant A (ResNet50 classifier)",
"Variant B (YOLOv8 detector)",
"Variant D (parts-aware seg)",
"Multi-car (group damage per car)",
"Both"],
value="Both",
label="Which model?",
)
currency = gr.Radio(choices=["USD", "INR"], value="USD", label="Currency")
auto_detect = gr.Checkbox(
value=True,
label="Auto-detect car + make/model",
info="Runs a COCO Mask R-CNN gate ('is there a car, and "
"where?') then the ResNet-50 identifier. If you type "
"a make below, that overrides the auto guess.",
)
with gr.Accordion("Sensitivity (raise to reduce false positives)", open=False):
classifier_threshold = gr.Slider(
minimum=0.1, maximum=0.95, step=0.05, value=0.6,
label="Classifier threshold",
info="Variant A reports a damage class only when its "
"sigmoid probability is above this. Default 0.6 "
"(was 0.5 — raised to suppress false positives "
"on undamaged / out-of-distribution images).",
)
detector_conf = gr.Slider(
minimum=0.05, maximum=0.9, step=0.05, value=0.20,
label="Detector confidence",
info="Variant B (YOLOv8) keeps boxes above this "
"confidence. Lower = more boxes, more false "
"positives. Raise = fewer, stricter boxes.",
)
identifier_confidence = gr.Slider(
minimum=0.0, maximum=0.95, step=0.05, value=0.30,
label="Make/model confidence floor",
info="Auto-detected make/model is trusted only above "
"this. The identifier knows 196 (mostly US, "
"≤2013) models, so an unseen car peaks low; below "
"the floor we report 'unknown' and price by body "
"type/segment instead of guessing. Default 0.30.",
)
with gr.Accordion("Car metadata (optional but improves cost accuracy)", open=False):
make = gr.Textbox(label="Make", placeholder="e.g. Toyota")
model_name = gr.Textbox(label="Model", placeholder="e.g. Camry")
year = gr.Number(label="Year", value=None, precision=0)
body_type = gr.Dropdown(
choices=["unknown", "sedan", "suv", "hatchback",
"coupe", "convertible", "wagon", "pickup", "van"],
value="unknown",
label="Body type",
)
run_btn = gr.Button("Estimate", variant="primary")
with gr.Column(scale=1):
annotated_out = gr.Image(
type="pil",
label="Car box (green) + damage boxes",
interactive=False,
)
identification_out = gr.Markdown(label="Car check")
variant_a_out = gr.Markdown(label="Variant A")
variant_b_out = gr.Markdown(label="Variant B")
variant_d_out = gr.Markdown(label="Variant D")
with gr.Accordion("Full JSON (provenance, probabilities, detections)", open=False):
json_out = gr.Code(language="json")
run_btn.click(
_estimate,
inputs=[image_in, model_choice, currency,
classifier_threshold, detector_conf, identifier_confidence,
auto_detect, make, model_name, year, body_type],
outputs=[annotated_out, identification_out,
variant_a_out, variant_b_out, variant_d_out, json_out],
)
with gr.Tab("Catalog manager"):
gr.Markdown(
"The active parts-cost catalog backs every cost prediction. "
"Switching it re-prices the same image **without** retraining the model "
"via the built-in calibrator."
)
catalog_table = gr.Dataframe(
headers=["active", "catalog_id", "created_at", "currency"],
value=_catalogs_table,
interactive=False,
)
with gr.Row():
catalog_pick = gr.Textbox(label="Catalog id to activate")
activate_btn = gr.Button("Activate")
activate_msg = gr.Markdown()
refresh_catalogs_btn = gr.Button("Refresh table")
activate_btn.click(_activate, inputs=catalog_pick, outputs=activate_msg)
refresh_catalogs_btn.click(lambda: _catalogs_table(), outputs=catalog_table)
with gr.Tab("FX (USD ↔ INR)"):
gr.Markdown("Current FX rate used when you select INR in the Estimate tab.")
fx_text = gr.Markdown(value=_fx_show)
fx_refresh_btn = gr.Button("Refresh now")
fx_refresh_btn.click(_fx_refresh, outputs=fx_text)
with gr.Tab("About"):
gr.Markdown(
"ccdp is a capstone project. Cost predictions are **calibrated triage "
"estimates** — they are not insurable quotes. The cost target during "
"training is synthetic (catalog-derived) because no public dataset pairs "
"car-damage images with real repair invoices.\n\n"
"See `PLAN.md §3` in the GitHub repo for the full disclosure and "
"`progress/STATUS.md` for current production metrics.\n\n"
"## Known limitations\n\n"
"- **No 'undamaged' class** *(in the v0.1.0 weights)*. The shipped "
"classifier was trained on **CarDD** (Wang et al. 2023), which "
"contains only damaged-car images, so it has no concept of "
"'no damage' and every image triggers *some* class. The "
"`checkpoint-10` branch adds Stanford Cars images as a "
"negative class — train with `ccdp train classifier "
"--negative-ratio 1.0` to fix this and re-promote the weights. "
"Until then, raise the **Classifier threshold** slider toward "
"`0.8` on undamaged inputs.\n"
"- **Domain shift.** CarDD is mostly studio-like Western photos. "
"Real-world phone photos (varied lighting, bystanders, Indian / Asian "
"makes) are out-of-distribution and detector recall drops sharply. "
"Lower the **Detector confidence** slider to surface borderline boxes "
"or expect zero detections on hard photos.\n"
"- **No segmentation.** We predict bounding boxes, not pixel masks, "
"so the area estimate is always an overestimate around the actual "
"damaged region.\n"
"- **Synthetic cost target.** The cost regressor was trained on "
"catalog-derived prices, not real invoices. Treat the dollar amount "
"as an order-of-magnitude triage estimate, not a quote."
)
return demo