Spaces:
Runtime error
Runtime error
Download src/ccdp/cli.py from theDocWho/car-crash-fix-amount-predictor: direct link, hf CLI and curl.
- Browser
- Download file 34.3 kB
-
https://huggingface.co/spaces/theDocWho/car-crash-fix-amount-predictor/resolve/main/src/ccdp/cli.py
- Command line
-
hf download hf://spaces/theDocWho/car-crash-fix-amount-predictor/src/ccdp/cli.py
-
curl -L -o cli.py https://huggingface.co/spaces/theDocWho/car-crash-fix-amount-predictor/resolve/main/src/ccdp/cli.py
34.3 kB
| """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() | |
| def version() -> None: | |
| """Print the ccdp version.""" | |
| console.print(f"ccdp {__version__}") | |
| # ----------------- costing ----------------------------------------------- | |
| 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}") | |
| 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) | |
| 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) | |
| 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}") | |
| 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) | |
| 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 ---------------------------------------------------- | |
| 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}") | |
| 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}") | |
| 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 -------------------------------------------------- | |
| 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) | |
| 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}") | |
| 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 ----------------------------------------- | |
| 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]") | |
| 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}") | |
| def un_stats() -> None: | |
| from ccdp.identification.unidentified import stats | |
| console.print(stats()) | |
| # ----------------- costing: 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 ------------------------------------------------- | |
| 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}") | |
| 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_<dataset>."), | |
| 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 <run_dir>/last.pt --resume-run-dir <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}") | |
| 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)) | |
| 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}") | |
| 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}") | |
| 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)) | |
| 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)) | |
| 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) | |
| 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_<variant>."), | |
| 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}") | |
| 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 --------------------------------------------- | |
| 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) | |
| 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)) | |
| 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 ------------------------------------------------ | |
| 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) | |
| 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 ----------------------------------------------- | |
| 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() | |