"""ccdp CLI — Typer-based entrypoint. Phase 0 surface: `ccdp costing ...` and `ccdp fx ...`. Later phases add `train`, `infer`, `registry`, `serve`, etc. """ from __future__ import annotations from pathlib import Path import typer from rich.console import Console from rich.table import Table from ccdp import __version__ from ccdp.costing import catalog as catmod from ccdp.costing import fx as fxmod app = typer.Typer(help="Car Crash Damage Predictor CLI", no_args_is_help=True) costing_app = typer.Typer(help="Versioned parts-cost catalog commands.") fx_app = typer.Typer(help="USD<->INR FX rate commands.") data_app = typer.Typer(help="Dataset commands: download, schema inspection, reference table.") unidentified_app = typer.Typer(help="Manage the unidentified-cars bucket.") train_app = typer.Typer(help="Training commands.") registry_app = typer.Typer(help="Model registry commands.") serve_app = typer.Typer(help="Serve the inference API or the Gradio demo.") report_app = typer.Typer(help="Generate the comparison report.") app.add_typer(costing_app, name="costing") app.add_typer(fx_app, name="fx") app.add_typer(data_app, name="data") app.add_typer(unidentified_app, name="unidentified") app.add_typer(train_app, name="train") app.add_typer(registry_app, name="registry") app.add_typer(serve_app, name="serve") app.add_typer(report_app, name="report") console = Console() @app.command() def version() -> None: """Print the ccdp version.""" console.print(f"ccdp {__version__}") # ----------------- costing ----------------------------------------------- @costing_app.command("init") def costing_init( tag: str = typer.Option("initial", help="Tag suffix for the catalog id."), root: Path = typer.Option(catmod.DEFAULT_ROOT, help="Catalogs directory."), force: bool = typer.Option(False, help="Re-seed even if catalogs already exist."), ) -> None: """Create the initial data-driven seed catalog and activate it.""" existing = catmod.list_catalogs(root) if existing and not force: console.print( f"[yellow]Catalogs already exist ({len(existing)}). " f"Use --force to add another seed.[/yellow]" ) raise typer.Exit(0) cat = catmod.build_seed_catalog(tag=tag) path = catmod.save(cat, root) catmod.activate(cat.catalog_id, root) console.print(f"[green]Created and activated catalog:[/green] {cat.catalog_id}") console.print(f" path: {path}") console.print(f" parts: {len(cat.parts)}, median_cost(mid/moderate): ${cat.median_cost():.2f}") @costing_app.command("list") def costing_list( root: Path = typer.Option(catmod.DEFAULT_ROOT, help="Catalogs directory."), ) -> None: """List all known catalogs.""" rows = catmod.list_catalogs(root) if not rows: console.print("[yellow]No catalogs found. Run `ccdp costing init`.[/yellow]") return table = Table(title="Cost catalogs") table.add_column("active", justify="center") table.add_column("catalog_id") table.add_column("created_at") table.add_column("currency") table.add_column("source", overflow="fold") for r in rows: table.add_row( "[bold green]*[/bold green]" if r["is_active"] else "", r["catalog_id"], r["created_at"] or "", r["currency"] or "", (r["source"] or "")[:70], ) console.print(table) @costing_app.command("show") def costing_show( catalog_id: str = typer.Argument(..., help="Catalog id or 'active'."), root: Path = typer.Option(catmod.DEFAULT_ROOT, help="Catalogs directory."), ) -> None: """Show a catalog's contents.""" cat = catmod.load_active(root) if catalog_id == "active" else catmod.load(catalog_id, root) console.print(f"[bold]{cat.catalog_id}[/bold] ({cat.currency}) median=${cat.median_cost():.2f}") console.print(f" created_at: {cat.created_at}") console.print(f" source: {cat.source}") table = Table(title="Parts (mid segment, moderate severity)") table.add_column("part") table.add_column("base", justify="right") table.add_column("labor h", justify="right") table.add_column("cost@mid", justify="right") for name, pc in sorted(cat.parts.items()): rate = cat.labor_rate_per_hour.get("mid", 95.0) table.add_row( name, f"{pc.base_cost.get('mid', 0):.2f}", f"{pc.labor_hours.get('moderate', 0):.1f}", f"{pc.cost('mid', 'moderate', rate):.2f}", ) console.print(table) @costing_app.command("activate") def costing_activate( catalog_id: str = typer.Argument(...), root: Path = typer.Option(catmod.DEFAULT_ROOT, help="Catalogs directory."), ) -> None: """Repoint active.yaml to a specific catalog.""" catmod.activate(catalog_id, root) console.print(f"[green]Activated:[/green] {catalog_id}") @costing_app.command("diff") def costing_diff( id_a: str = typer.Argument(...), id_b: str = typer.Argument(...), root: Path = typer.Option(catmod.DEFAULT_ROOT, help="Catalogs directory."), ) -> None: """Show per-part % change between two catalogs (mid segment, base price).""" d = catmod.diff(id_a, id_b, root) table = Table(title=f"Diff {id_a} -> {id_b} (mid base_cost)") table.add_column("part") table.add_column("status") table.add_column("a", justify="right") table.add_column("b", justify="right") table.add_column("% change", justify="right") for part, info in d.items(): table.add_row( part, info["status"], f"{info.get('a_mid', ''):>}", f"{info.get('b_mid', ''):>}", f"{info.get('pct_change', ''):>}", ) console.print(table) @costing_app.command("estimate") def costing_estimate( parts: list[str] = typer.Argument(..., help="Damaged part names (space-separated)."), segment: str = typer.Option("mid", help="economy | mid | luxury"), severity: str = typer.Option("moderate", help="minor | moderate | severe"), catalog_id: str = typer.Option("active", help="Catalog id or 'active'."), currency: str = typer.Option("USD", help="Output currency: USD or INR."), ) -> None: """Tier-3 catalog-only cost estimate for a given parts list.""" cat = catmod.load_active() if catalog_id == "active" else catmod.load(catalog_id) parts_map = {p: severity for p in parts} usd = cat.estimate(parts_map, segment=segment) if currency.upper() == cat.currency.upper(): console.print(f"[bold]Estimate:[/bold] {usd:.2f} {cat.currency} ({cat.catalog_id})") return out, fr = fxmod.convert(usd, cat.currency, currency) console.print(f"[bold]Estimate:[/bold] {usd:.2f} {cat.currency} = {out:.2f} {currency.upper()}") if fr: console.print( f" fx: 1 {fr.base} = {fr.rate} {fr.target} " f"(source={fr.source}, fetched={fr.fetched_at})" ) # ----------------- fx ---------------------------------------------------- @fx_app.command("show") def fx_show( base: str = typer.Option("USD"), target: str = typer.Option("INR"), ) -> None: """Show the cached FX rate. Will fetch if no cache exists.""" try: fr = fxmod.get_rate(base, target) except RuntimeError as e: console.print(f"[red]{e}[/red]") raise typer.Exit(1) stale = " [yellow](stale)[/yellow]" if fr.is_stale() else "" console.print(f"1 {fr.base} = {fr.rate} {fr.target}{stale}") console.print(f" source: {fr.source}, fetched: {fr.fetched_at}") @fx_app.command("refresh") def fx_refresh( base: str = typer.Option("USD"), target: str = typer.Option("INR"), ) -> None: """Force a live FX fetch and update cache.""" fr = fxmod.refresh_rate(base, target) console.print(f"[green]Refreshed:[/green] 1 {fr.base} = {fr.rate} {fr.target}") console.print(f" source: {fr.source}, fetched: {fr.fetched_at}") @fx_app.command("set") def fx_set( rate: float = typer.Argument(..., help="Manual rate, e.g. 83.2 for USD->INR."), base: str = typer.Option("USD"), target: str = typer.Option("INR"), ) -> None: """Set a manual FX override (recorded as manual_override).""" fr = fxmod.manual_set(base, target, rate) console.print(f"[green]Manual override set:[/green] 1 {fr.base} = {fr.rate} {fr.target}") # ----------------- data -------------------------------------------------- @data_app.command("download") def data_download( stanford_cars: bool = typer.Option(True, help="Include Stanford Cars (Phase 1.5)."), ) -> None: """Run scripts/download_datasets.sh to fetch all primary datasets via Kaggle CLI.""" import os import subprocess env = os.environ.copy() if stanford_cars: env["STANFORD_CARS"] = "1" script = Path("scripts/download_datasets.sh") if not script.exists(): console.print(f"[red]Missing {script}[/red]") raise typer.Exit(1) console.print(f"[bold]Running[/bold] {script} (logs at data/raw/_download.log)") rc = subprocess.call(["bash", str(script)], env=env) raise typer.Exit(rc) @data_app.command("inspect") def data_inspect( limit: int = typer.Option(5, help="Records to sample per dataset."), ) -> None: """Print a few records from each loader to verify on-disk layout.""" from itertools import islice from ccdp.data.loaders import ( CARDD_ROOT, COMPREHENSIVE_ROOT, IAAI_ROOT, iter_cardd, iter_comprehensive, iter_iaai, ) sources = [ ("CarDD (val)", CARDD_ROOT / "annotations" / "instances_val2017.json", lambda: iter_cardd(splits=("val",))), ("comprehensive", COMPREHENSIVE_ROOT, iter_comprehensive), ("iaai", IAAI_ROOT, iter_iaai), ] for name, probe, gen in sources: console.print(f"\n[bold cyan]=== {name} ===[/bold cyan]") if not Path(probe).exists(): console.print(f" [yellow]missing: {probe}[/yellow]") continue for r in islice(gen(), limit): console.print(f" • {r.image_id} dt={r.damage_types} " f"loc={r.damage_location} cond={r.damage_condition} " f"make={r.make} model={r.model} year={r.year} " f"body={r.body_type}") @data_app.command("build-reference-table") def data_build_reference_table( limit: int = typer.Option(0, help="Optional cap on iaai rows (0=all)."), out: Path = typer.Option(None, help="Output path; defaults to data/processed/reference_table.parquet"), ) -> None: """Aggregate iaai metadata into the reference table used by Tier-2 lookups.""" from ccdp.identification import build_reference, reference_table as reftab out_path = out or reftab.DEFAULT_PATH console.print(f"[bold]Building reference table[/bold] -> {out_path}") build_reference.build_from_iaai(out_path=out_path, limit=(limit or None)) rep = reftab.coverage_report(out_path) console.print(f"[green]Done.[/green] rows={rep.get('rows')}, " f"unique_makes={rep.get('unique_makes')}, " f"unique_models={rep.get('unique_models')}, " f"year_range={rep.get('year_range')}") # ----------------- unidentified ----------------------------------------- @unidentified_app.command("list") def un_list( only_unlabeled: bool = typer.Option(False, help="Only show rows still missing labels."), limit: int = typer.Option(20), ) -> None: """List rows in the unidentified-cars bucket.""" from ccdp.identification.unidentified import list_rows, stats rows = list_rows(only_unlabeled=only_unlabeled, limit=limit) if not rows: console.print("[yellow]Bucket empty.[/yellow]") return table = Table(title="Unidentified cars") for col in ("image_id", "assigned_name", "body_type", "segment", "color", "make?", "model?", "year?"): table.add_column(col) for r in rows: table.add_row( r.image_id, r.assigned_name, r.predicted_body_type or "", r.predicted_segment or "", r.predicted_color or "", r.user_supplied_make or "", r.user_supplied_model or "", str(r.user_supplied_year) if r.user_supplied_year else "", ) console.print(table) console.print(f"\n[dim]{stats()}[/dim]") @unidentified_app.command("label") def un_label( image_id: str = typer.Argument(...), make: str = typer.Option(..., "--make"), model: str = typer.Option(..., "--model"), year: int = typer.Option(..., "--year"), ) -> None: """Apply user-supplied (make, model, year) to an unidentified row.""" from ccdp.identification.unidentified import label row = label(image_id, make=make, model=model, year=year) console.print(f"[green]Labeled[/green] {row.image_id} -> {make} {model} {year}") @unidentified_app.command("stats") def un_stats() -> None: from ccdp.identification.unidentified import stats console.print(stats()) # ----------------- costing: import -------------------------------------- @costing_app.command("import") def costing_import( from_dataset: str = typer.Option(None, "--from-dataset", help="One of: iaai (currently no usable cost; reserved for future)."), file: Path = typer.Option(None, "--file", help="CSV file with columns: part,economy,mid,luxury,labor_mid_h"), tag: str = typer.Option("import"), ) -> None: """Build a new timestamped catalog from a CSV or supported dataset. NOTE: `--from-dataset iaai` is currently a no-op because the free iaai sample has no real cost values. The command is wired so that when real cost data lands (e.g., research-access slice), a single change to this function enables data-driven catalog generation. """ if not from_dataset and not file: console.print("[red]Provide --from-dataset or --file.[/red]") raise typer.Exit(2) if from_dataset == "iaai": console.print( "[yellow]iaai free sample has paywalled cost columns; no catalog " "can be derived. See CITATIONS.md (§3) for research-access details. " "Falling back to a copy of the active catalog.[/yellow]" ) active = catmod.load_active() new = active new.catalog_id = catmod.new_catalog_id(tag) catmod.save(new, catmod.DEFAULT_ROOT) console.print(f"[green]Snapshot saved as[/green] {new.catalog_id}") return if file: new = _catalog_from_csv(file, tag=tag) catmod.save(new) console.print(f"[green]Created[/green] {new.catalog_id} from {file}") def _catalog_from_csv(path: Path, tag: str): """CSV columns expected: part,economy,mid,luxury,labor_mid_h.""" import csv from datetime import datetime, timezone from ccdp.costing.catalog import Catalog, PartCost, build_seed_catalog seed = build_seed_catalog() # baseline for severity multipliers / labor_rate parts = dict(seed.parts) with path.open() as f: for row in csv.DictReader(f): name = row["part"].strip().lower() economy = float(row.get("economy") or 0) mid = float(row.get("mid") or 0) luxury = float(row.get("luxury") or 0) labor_mid = float(row.get("labor_mid_h") or seed.parts.get(name, list(seed.parts.values())[0]).labor_hours["moderate"]) existing = parts.get(name) severity_mult = (existing.severity_multiplier if existing else {"minor": 0.4, "moderate": 1.0, "severe": 1.8}) labor_h = (existing.labor_hours if existing else {"minor": labor_mid * 0.4, "moderate": labor_mid, "severe": labor_mid * 2.0}) parts[name] = PartCost( base_cost={"economy": economy, "mid": mid, "luxury": luxury}, severity_multiplier=severity_mult, labor_hours=labor_h, ) return Catalog( catalog_id=catmod.new_catalog_id(tag), created_at=datetime.now(timezone.utc).isoformat(), created_by="ccdp costing import", source=f"imported from {path}", currency=seed.currency, parts=parts, labor_rate_per_hour=seed.labor_rate_per_hour, notes="Imported via `ccdp costing import --file`.", ) # ----------------- train ------------------------------------------------- @train_app.command("identifier") def train_identifier( epochs_stage1: int = typer.Option(3), epochs_stage2: int = typer.Option(12), batch_size: int = typer.Option(32), lr_stage1: float = typer.Option(1e-3), lr_stage2: float = typer.Option(1e-4), num_workers: int = typer.Option(4), image_size: int = typer.Option(224), tag: str = typer.Option("identifier"), resume: Path = typer.Option(None, help="Path to a last.pt to resume from."), smoke_batches: int = typer.Option(0, help="If >0, cap batches/epoch — for smoke runs."), ) -> None: """Fine-tune ResNet50 on Stanford Cars 196 for make/model/year identification.""" from ccdp.train.train_car_identifier import TrainConfig, train as do_train from ccdp.costing import load_active try: active = load_active() catalog_id = active.catalog_id except FileNotFoundError: catalog_id = None cfg = TrainConfig( epochs_stage1=epochs_stage1, epochs_stage2=epochs_stage2, batch_size=batch_size, lr_stage1=lr_stage1, lr_stage2=lr_stage2, num_workers=num_workers, image_size=image_size, tag=tag, ) best = do_train(cfg, resume=resume, smoke_batches=(smoke_batches or None), training_catalog_id=catalog_id) console.print(f"[green]Best checkpoint:[/green] {best}") @train_app.command("identifier-continue") def train_identifier_continue( dataset: str = typer.Option("vmmrdb", help="Extension dataset: vmmrdb | compcars."), top_n: int = typer.Option(2000, help="Cap to the N largest classes (vmmrdb). 0 = all."), base_checkpoint: Path = typer.Option(None, help="Base identifier ckpt; defaults to promoted run."), epochs_stage1: int = typer.Option(2), epochs_stage2: int = typer.Option(10), batch_size: int = typer.Option(64), lr_stage1: float = typer.Option(5e-4), lr_stage2: float = typer.Option(5e-5), num_workers: int = typer.Option(4), tag: str = typer.Option(None, help="Defaults to identifier_."), no_anchor: bool = typer.Option(False, "--no-anchor", help="Skip the Stanford forgetting anchor."), smoke_batches: int = typer.Option(0, help="If >0, cap batches/epoch — smoke runs."), resume_from: Path = typer.Option(None, "--resume", "--resume-from", help="Resume from this epoch_NNN.pt / last.pt " "(skip already-finished epochs)."), resume_run_dir: Path = typer.Option(None, "--resume-run-dir", help="Write new checkpoints into this existing run dir " "instead of creating a fresh run."), ) -> None: """Phase 6: continue-train the identifier on a larger make/model/year dataset. Warm-starts the 196-class ResNet-50, swaps the head to the new label space, and two-stage fine-tunes. VMMRdb (CC0 Kaggle mirror) or CompCars. Crash-resume: re-launch with ``--resume /last.pt --resume-run-dir `` to pick up from the next epoch with the same run-id and same on-disk layout. """ if dataset == "vmmrdb": from ccdp.data import vmmrdb as ds if top_n: ds.set_top_n(top_n) elif dataset == "compcars": from ccdp.data import compcars as ds else: console.print("[red]dataset must be 'vmmrdb' or 'compcars'.[/red]") raise typer.Exit(2) from ccdp.train.continue_identifier import ContinueConfig, train as do_train from ccdp.costing import load_active try: catalog_id = load_active().catalog_id except FileNotFoundError: catalog_id = None cfg = ContinueConfig( base_checkpoint=(str(base_checkpoint) if base_checkpoint else None), epochs_stage1=epochs_stage1, epochs_stage2=epochs_stage2, batch_size=batch_size, lr_stage1=lr_stage1, lr_stage2=lr_stage2, num_workers=num_workers, tag=(tag or f"identifier_{dataset}"), anchor_eval=not no_anchor, resume_from=(str(resume_from) if resume_from else None), resume_run_dir=(str(resume_run_dir) if resume_run_dir else None), ) best = do_train(cfg, dataset=ds, training_catalog_id=catalog_id, smoke_batches=(smoke_batches or None)) console.print(f"[green]Best checkpoint:[/green] {best}") @train_app.command("extract-features") def train_extract_features( checkpoint: Path = typer.Option(None, help="Classifier checkpoint; defaults to promoted run."), out: Path = typer.Option(Path("data/processed/cardd_features.parquet")), batch_size: int = typer.Option(64), num_workers: int = typer.Option(4), smoke_batches: int = typer.Option(0, help="If >0, cap batches per split."), ) -> None: """Extract 2048-d features for every CarDD image and cache to parquet.""" from ccdp.train.extract_features import extract_all extract_all(checkpoint=checkpoint, out_path=out, batch_size=batch_size, num_workers=num_workers, max_batches=(smoke_batches or None)) @train_app.command("detector") def train_detector( model: str = typer.Option("yolov8n.pt", help="yolov8n.pt | yolov8s.pt | ..."), epochs: int = typer.Option(50), imgsz: int = typer.Option(640), batch: int = typer.Option(16), patience: int = typer.Option(15), workers: int = typer.Option(4), tag: str = typer.Option("yolov8n"), device: str = typer.Option(None, help="Override device (mps|cpu|0)."), ) -> None: """Train YOLOv8 on CarDD (Variant B detector). Materializes the YOLO dataset if missing.""" from ccdp.train.train_yolov8 import YoloConfig, train as do_train from ccdp.costing import load_active try: active = load_active() catalog_id = active.catalog_id except FileNotFoundError: catalog_id = None cfg = YoloConfig(model=model, epochs=epochs, imgsz=imgsz, batch=batch, patience=patience, workers=workers, tag=tag, device=device) best = do_train(cfg, training_catalog_id=catalog_id) console.print(f"[green]Best:[/green] {best}") @train_app.command("build-yolo-dataset") def train_build_yolo_dataset( root: Path = typer.Option(Path("data/processed/yolo")), ) -> None: """Materialize CarDD as Ultralytics YOLO dataset (train/val/test with labels).""" from ccdp.data import cardd_yolo p = cardd_yolo.build(root) console.print(f"[green]Wrote[/green] {p}") @train_app.command("extract-bbox-features") def train_extract_bbox_features( weights: Path = typer.Option(None, help="Detector weights; defaults to promoted run."), out: Path = typer.Option(Path("data/processed/cardd_bbox_features.parquet")), gt: bool = typer.Option(False, "--gt", help="Use ground-truth CarDD bboxes (no detector)."), imgsz: int = typer.Option(640), conf: float = typer.Option(0.25), smoke_per_split: int = typer.Option(0, help="If >0, cap records per split."), ) -> None: """Aggregate bbox stats per image for Variant B XGBoost features.""" from ccdp.train.extract_bbox_features import extract_from_ground_truth, extract_with_detector if gt: extract_from_ground_truth(out_path=out) else: extract_with_detector(weights=weights, out_path=out, imgsz=imgsz, conf=conf, max_records_per_split=(smoke_per_split or None)) @train_app.command("extract-seg-features") def train_extract_seg_features( weights: Path = typer.Option(None, help="yoloseg weights; defaults to promoted run."), out: Path = typer.Option(Path("data/processed/cardd_seg_features.parquet")), gt: bool = typer.Option(False, "--gt", help="Use CarDD polygon areas (no model)."), conf: float = typer.Option(0.25), smoke_per_split: int = typer.Option(0, help="If >0, cap records per split."), ) -> None: """Aggregate YOLOv8-seg mask-area stats per image for Variant C XGBoost features.""" from ccdp.train.extract_seg_features import extract_from_ground_truth, extract_with_seg_model if gt: extract_from_ground_truth(out_path=out) else: extract_with_seg_model(weights=weights, out_path=out, conf=conf, max_records_per_split=(smoke_per_split or None)) @train_app.command("synth-targets") def train_synth_targets( features_path: Path = typer.Option(Path("data/processed/cardd_features.parquet")), out: Path = typer.Option(Path("data/processed/cardd_cost_targets.parquet")), seed: int = typer.Option(42), ) -> None: """Generate synthetic per-image (metadata + cost_usd) targets from the active catalog.""" from ccdp.train.synthesize_cost import generate_targets generate_targets(features_path, out_path=out, seed=seed) @train_app.command("xgb") def train_xgb( variant: str = typer.Option("a", help="'a' (image only) | 'b' (+ bbox) | 'c' (+ seg mask area)."), n_estimators: int = typer.Option(600), max_depth: int = typer.Option(7), learning_rate: float = typer.Option(0.05), tag: str = typer.Option(None, help="Defaults to xgb_."), features_path: Path = typer.Option(Path("data/processed/cardd_features.parquet")), targets_path: Path = typer.Option(Path("data/processed/cardd_cost_targets.parquet")), bbox_features_path: Path = typer.Option(Path("data/processed/cardd_bbox_features.parquet")), seg_features_path: Path = typer.Option(Path("data/processed/cardd_seg_features.parquet")), ) -> None: """Train XGBoost — image features (+ bbox for b / seg mask area for c) + tabular -> cost.""" if variant not in ("a", "b", "c"): console.print("[red]variant must be 'a', 'b', or 'c'.[/red]") raise typer.Exit(2) from ccdp.train.train_xgb import XGBConfig, train as do_train cfg = XGBConfig(n_estimators=n_estimators, max_depth=max_depth, learning_rate=learning_rate, tag=(tag or f"xgb_{variant}"), variant=variant) best = do_train(cfg, features_path=features_path, targets_path=targets_path, bbox_features_path=bbox_features_path, seg_features_path=seg_features_path) console.print(f"[green]Best:[/green] {best}") @train_app.command("classifier") def train_classifier( epochs_stage1: int = typer.Option(3), epochs_stage2: int = typer.Option(12), batch_size: int = typer.Option(32), lr_stage1: float = typer.Option(1e-3), lr_stage2: float = typer.Option(1e-4), num_workers: int = typer.Option(4), image_size: int = typer.Option(224), tag: str = typer.Option("classifier"), resume: Path = typer.Option(None, help="Path to a last.pt to resume from."), smoke_batches: int = typer.Option(0, help="If >0, cap batches/epoch — for smoke runs."), negative_ratio: float = typer.Option( 0.0, help="Ratio of Stanford Cars 'no damage' images to mix into train+val. " "0=legacy CarDD-only; 1.0=balanced; 2.0=2x negatives. Fixes the " "'always predicts some damage' false-positive failure mode on " "undamaged inputs.", ), ) -> None: """Fine-tune ResNet50 multi-label damage-type classifier on CarDD (Variant A).""" from ccdp.train.train_damage_classifier import TrainConfig as ClsConfig, train as do_train from ccdp.costing import load_active try: active = load_active() catalog_id = active.catalog_id except FileNotFoundError: catalog_id = None cfg = ClsConfig( epochs_stage1=epochs_stage1, epochs_stage2=epochs_stage2, batch_size=batch_size, lr_stage1=lr_stage1, lr_stage2=lr_stage2, num_workers=num_workers, image_size=image_size, tag=tag, negative_ratio=negative_ratio, ) best = do_train(cfg, resume=resume, smoke_batches=(smoke_batches or None), training_catalog_id=catalog_id) console.print(f"[green]Best checkpoint:[/green] {best}") # ----------------- registry --------------------------------------------- @registry_app.command("list") def registry_list( variant: str = typer.Option(None, help="Filter by variant."), ) -> None: from ccdp.registry import list_entries, production_target rows = list_entries(variant=variant) if not rows: console.print("[yellow]Registry empty.[/yellow]") return table = Table(title="Registry entries") for col in ("variant", "run_id", "created_at", "best_val_acc", "training_catalog_id", "production?"): table.add_column(col) for r in rows: prod = production_target(r["variant"]) is_prod = "*" if prod and r["run_id"] in str(prod) else "" bva = r.get("metrics", {}).get("best_val_acc", "") table.add_row( r["variant"], r["run_id"], r["created_at"][:19], f"{bva:.4f}" if isinstance(bva, (int, float)) else "", (r.get("training_catalog_id") or "")[:32], is_prod, ) console.print(table) @app.command() def infer( image: Path = typer.Argument(..., help="Path to a car damage image."), model: str = typer.Option("resnet", help="resnet (Variant A) | yolov8 (Variant B) | both"), currency: str = typer.Option("USD"), threshold: float = typer.Option(0.5, help="Variant A sigmoid threshold."), conf: float = typer.Option(0.25, help="Variant B detector confidence threshold."), make: str = typer.Option(None), model_name: str = typer.Option(None, "--model-name"), year: int = typer.Option(None), body_type: str = typer.Option("unknown"), ) -> None: """End-to-end inference. `--model both` runs A and B side-by-side.""" if model not in ("resnet", "yolov8", "both"): console.print("[red]--model must be resnet | yolov8 | both.[/red]") raise typer.Exit(2) from ccdp.identification.car_identifier import IdentificationResult, infer_segment metadata = None if make: metadata = IdentificationResult( image_path=image, make=make.lower(), model=(model_name.lower() if model_name else None), year=year, body_type=body_type, segment=infer_segment(make), confidence=1.0, source="user", ) import json as _json out: dict = {} if model in ("resnet", "both"): from ccdp.infer.variant_a import VariantAPipeline pipe_a = VariantAPipeline() out["variant_a"] = pipe_a.predict(image, metadata=metadata, threshold=threshold, currency=currency).to_dict() if model in ("yolov8", "both"): from ccdp.infer.variant_b import VariantBPipeline pipe_b = VariantBPipeline(conf=conf) out["variant_b"] = pipe_b.predict(image, metadata=metadata, currency=currency).to_dict() console.print_json(_json.dumps(out, default=str)) @registry_app.command("promote") def registry_promote( run_id: str = typer.Argument(...), variant: str = typer.Argument(...), weights: str = typer.Option("best.pt"), ) -> None: from ccdp.registry import promote link = promote(run_id, variant=variant, weights_filename=weights) console.print(f"[green]Promoted[/green] {run_id} -> {link}") # ----------------- serve ------------------------------------------------ @serve_app.command("api") def serve_api( host: str = typer.Option("127.0.0.1", help="Bind address. Use 0.0.0.0 to expose."), port: int = typer.Option(8000), reload: bool = typer.Option(False, help="uvicorn auto-reload (dev only)."), ) -> None: """Run the FastAPI inference service.""" import uvicorn console.print(f"[bold]Starting ccdp API[/bold] on http://{host}:{port}") uvicorn.run("ccdp.api.server:app", host=host, port=port, reload=reload) @serve_app.command("demo") def serve_demo( host: str = typer.Option("127.0.0.1"), port: int = typer.Option(7860), share: bool = typer.Option(False, help="Gradio public share link."), ) -> None: """Run the Gradio demo.""" from ccdp.api.demo import build_demo import gradio as gr demo = build_demo() demo.launch( server_name=host, server_port=port, share=share, show_error=True, theme=gr.themes.Soft(), ) # ----------------- report ----------------------------------------------- @report_app.command("generate") def report_generate( variant: str = typer.Option("both", help="a | b | both"), limit: int = typer.Option(0, help="Cap test images (0 = all). Smoke runs use a small value."), no_pdf: bool = typer.Option(False, "--no-pdf"), ) -> None: """Build the Variant-A-vs-B comparison report (HTML always, PDF optional).""" from ccdp.eval import build_comparison, report as report_mod from ccdp.infer.variant_a import VariantAPipeline pipe_a = VariantAPipeline() if variant in ("a", "both") else None pipe_b = None if variant in ("b", "both"): try: from ccdp.infer.variant_b import VariantBPipeline pipe_b = VariantBPipeline() except FileNotFoundError as e: console.print(f"[yellow]Variant B unavailable: {e}[/yellow]") if pipe_a is None and pipe_b is None: console.print("[red]No pipelines available — nothing to report.[/red]") raise typer.Exit(2) # If user asked only for B but B failed, fall back to A-only if pipe_a is None and pipe_b is not None: pipe_a = pipe_b pipe_b = None cmp = build_comparison(pipe_a, pipe_b, limit=(limit or None)) paths = report_mod.generate(cmp, also_pdf=not no_pdf) console.print(f"[green]HTML:[/green] {paths['html']}") if paths.get("pdf"): console.print(f"[green]PDF: [/green] {paths['pdf']}") if __name__ == "__main__": # pragma: no cover app()