theDocWho commited on
Commit
323fee6
·
1 Parent(s): 29312f3

checkpoint-3: comparison report, FastAPI, Gradio, HF Space deploy

Browse files

Phase 3 brings the system to humans. Three deliverables, all reading from the
existing v0.1.0 production registry — no new training, no model changes.

## New modules

ccdp/preprocess (Stage A: deterministic downscale + quality report).
- `normalize_for_inference` LANCZOS-resizes long edges to 1600 px max.
- `quality_report` returns sharpness (Laplacian variance), brightness,
contrast, low-resolution / low-light / blurry booleans.
- Stage B (super-resolution) explicitly deferred to a later checkpoint.

ccdp/eval — Variant A vs B head-to-head evaluator.
- `metrics.py`: pure per-class P/R/F1 + regression metrics (no sklearn).
- `comparison.py`: `build_comparison()` runs both pipelines on the seed=42
test split, returns a `Comparison` dataclass.
- `report.py`: Jinja2 -> HTML always; WeasyPrint -> PDF when installed.
- `reports/templates/report.html.j2`: A4-formatted report with headline
table, per-variant detail, tier/latency, top failures, disclosures.

ccdp/api — serving surface.
- `server.py`: FastAPI with `/health`, `/catalogs`, `/fx`, `/estimate`;
pipelines loaded once at startup via lifespan.
- `demo.py`: Gradio Blocks UI with Estimate, Catalog manager, FX, About
tabs; reuses the same pipeline instances as the API.
- `schemas.py`: Pydantic request/response models.

CLI: `ccdp serve api`, `ccdp serve demo`, `ccdp report generate`.

VariantAPipeline / VariantBPipeline now accept PIL.Image directly (in addition
to path strings) so the API doesn't need a temp file round-trip.

## HuggingFace Space deployment

app.py: HF Space entry. On first boot, curl-downloads the v0.1.0 release
assets from the GitHub repo into `checkpoints/production/`, then launches
`ccdp.api.demo.build_demo`.

requirements.txt + packages.txt: HF build inputs.

.github/workflows/deploy-hf.yml: on every push to main, prepends the HF Space
YAML frontmatter to a temporary README copy, then force-pushes to
`huggingface.co/spaces/<HF_USERNAME>/car-crash-fix-amount-predictor`.

Triggered by `HF_TOKEN` (secret) + `HF_USERNAME` (variable), both already
configured on this repo.

## README

Three Mermaid diagrams: local execution flow, deploy flow (GitHub -> HF), and
the training flow from Phases 1-2 for completeness. Renders natively on GitHub.

## Tests

76/76 passing (was 64). New tests:
- tests/test_preprocess.py: quality_report, normalize_for_inference, multiple
input types (PIL / bytes / path).
- tests/test_eval_metrics.py: per_class_prf perfect / empty / known-value
cases; regression_metrics perfect / known / empty.

## Verified locally

- `ccdp report generate --limit 5 --no-pdf` -> reports/report_<ts>.html
- `ccdp serve api` boots, /health returns 200, both pipelines load on MPS
- Gradio `build_demo()` returns a usable Blocks object

## Known limitation (documented)

The XGBoost bundle.json sidecars are not yet in the v0.1.0 release, so the HF
Space's predictions will route to the Tier-3 catalog-only fallback. Damage
detection (Variant A classifier + Variant B detector) is unaffected. Fix is a
release-asset update, no code change.

.github/workflows/deploy-hf.yml ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to HuggingFace Space
2
+
3
+ # Sync `main` to the HuggingFace Space whenever main moves.
4
+ # Requires:
5
+ # - secret HF_TOKEN (HuggingFace user access token with write scope)
6
+ # - variable HF_USERNAME (set via `gh variable set HF_USERNAME --body <name>`)
7
+
8
+ on:
9
+ push:
10
+ branches:
11
+ - main
12
+ # Allow ad-hoc redeploys from the Actions tab
13
+ workflow_dispatch:
14
+
15
+ jobs:
16
+ deploy:
17
+ runs-on: ubuntu-latest
18
+
19
+ steps:
20
+ - name: Check out repo (full history; HF expects it)
21
+ uses: actions/checkout@v4
22
+ with:
23
+ fetch-depth: 0
24
+ lfs: false
25
+
26
+ - name: Prepend HF Space metadata to README
27
+ env:
28
+ HF_USERNAME: ${{ vars.HF_USERNAME }}
29
+ run: |
30
+ set -euo pipefail
31
+ # HF Spaces require YAML frontmatter at the top of README.md.
32
+ # We synthesize it here so the GitHub copy stays clean (no leading YAML).
33
+ cat > _hf_README.md <<EOF
34
+ ---
35
+ title: Car Crash Fix Amount Predictor
36
+ emoji: 🚗
37
+ colorFrom: blue
38
+ colorTo: red
39
+ sdk: gradio
40
+ sdk_version: "4.44.0"
41
+ app_file: app.py
42
+ pinned: false
43
+ license: mit
44
+ short_description: Detect car damage and estimate repair cost
45
+ ---
46
+
47
+ EOF
48
+ cat README.md >> _hf_README.md
49
+ mv _hf_README.md README.md
50
+
51
+ - name: Push to HuggingFace Space
52
+ env:
53
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
54
+ HF_USERNAME: ${{ vars.HF_USERNAME }}
55
+ run: |
56
+ set -euo pipefail
57
+ SPACE="${HF_USERNAME}/car-crash-fix-amount-predictor"
58
+ # Configure git with the HF user
59
+ git config user.name "${HF_USERNAME}"
60
+ git config user.email "${HF_USERNAME}@users.noreply.huggingface.co"
61
+ # Add HF Space as a remote (created on first push if it doesn't exist
62
+ # — but only when using `huggingface-cli`. To be safe, the Space
63
+ # should be created once manually at huggingface.co/new-space, or via
64
+ # the API call below.)
65
+ if ! git ls-remote "https://${HF_USERNAME}:${HF_TOKEN}@huggingface.co/spaces/${SPACE}" >/dev/null 2>&1; then
66
+ echo "Space ${SPACE} not found — creating it via the HF API…"
67
+ curl -sf -X POST \
68
+ -H "Authorization: Bearer ${HF_TOKEN}" \
69
+ -H "Content-Type: application/json" \
70
+ -d '{"type":"space","name":"car-crash-fix-amount-predictor","sdk":"gradio"}' \
71
+ https://huggingface.co/api/repos/create
72
+ fi
73
+ git remote add hf "https://${HF_USERNAME}:${HF_TOKEN}@huggingface.co/spaces/${SPACE}"
74
+ # Force-push so the Space mirrors `main`. We intentionally drop the
75
+ # Space's own commit history — single source of truth is GitHub.
76
+ git push --force hf HEAD:main
77
+ echo "Deployed to https://huggingface.co/spaces/${SPACE}"
README.md CHANGED
@@ -170,6 +170,163 @@ Phase status: see [progress/STATUS.md](progress/STATUS.md). Full design rational
170
 
171
  ---
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  ## Execution flow
174
 
175
  ### Training (run once, then promote the winning runs)
 
170
 
171
  ---
172
 
173
+ ## Diagrams
174
+
175
+ ### Local execution (single image)
176
+
177
+ ```mermaid
178
+ flowchart TD
179
+ classDef data fill:#e1f5fe,stroke:#01579b
180
+ classDef model fill:#fff3e0,stroke:#e65100
181
+ classDef io fill:#f3e5f5,stroke:#4a148c
182
+ classDef out fill:#e8f5e9,stroke:#1b5e20
183
+
184
+ user[User on M-series Mac]:::io
185
+ img[Car damage image]:::data
186
+ meta[Optional metadata:<br/>make / model / year / body_type]:::data
187
+ cli[ccdp CLI<br/>src/ccdp/cli.py]:::io
188
+
189
+ pre[ccdp.preprocess<br/>downscale + quality_report]:::model
190
+
191
+ pipeA[VariantAPipeline<br/>infer/variant_a.py]:::model
192
+ pipeB[VariantBPipeline<br/>infer/variant_b.py]:::model
193
+
194
+ classifier[(classifier.pt<br/>ResNet50)]:::model
195
+ detector[(detector.pt<br/>YOLOv8n)]:::model
196
+ xgbA[(xgb_a.ubj)]:::model
197
+ xgbB[(xgb_b.ubj)]:::model
198
+
199
+ base[BaseVariantPipeline<br/>XGBoost + calibrator + FX + provenance]:::model
200
+ catalog[(active.yaml<br/>parts cost catalog)]:::data
201
+ fx[(fx_cache.json)]:::data
202
+
203
+ out[Response JSON<br/>damage_types, parts, cost,<br/>tier, provenance, catalog_id]:::out
204
+
205
+ user --> img
206
+ user --> meta
207
+ img --> cli
208
+ meta --> cli
209
+ cli --> pre
210
+ pre --> pipeA
211
+ pre --> pipeB
212
+
213
+ pipeA --> classifier
214
+ pipeA --> base
215
+ pipeB --> detector
216
+ pipeB --> classifier
217
+ pipeB --> base
218
+
219
+ base --> xgbA
220
+ base --> xgbB
221
+ base --> catalog
222
+ base --> fx
223
+
224
+ base --> out
225
+ out --> user
226
+ ```
227
+
228
+ ### Deployment (GitHub → HuggingFace Space)
229
+
230
+ ```mermaid
231
+ flowchart LR
232
+ classDef gh fill:#f6f8fa,stroke:#1f6feb
233
+ classDef hf fill:#fff8e1,stroke:#ffa000
234
+ classDef user fill:#e8f5e9,stroke:#1b5e20
235
+
236
+ dev[Developer pushes<br/>to main]:::user
237
+
238
+ subgraph GitHub
239
+ repo[(theDocWho/<br/>car-crash-fix-amount-predictor)]:::gh
240
+ action[GitHub Action<br/>.github/workflows/<br/>deploy-hf.yml]:::gh
241
+ release[(v0.1.0 Release<br/>~600 MB weights)]:::gh
242
+ end
243
+
244
+ subgraph HuggingFace
245
+ space[HF Space<br/>theDocWho/car-crash...<br/>Gradio SDK, CPU]:::hf
246
+ cache[(Cached weights<br/>downloaded on first boot)]:::hf
247
+ gradio[Gradio app<br/>app.py launches<br/>ccdp.api.demo]:::hf
248
+ end
249
+
250
+ visitor[Public visitor<br/>any browser]:::user
251
+
252
+ dev --> repo
253
+ repo -- on push to main --> action
254
+ action -- force-pushes files --> space
255
+ release -. fetched on first boot .-> cache
256
+ space --> gradio
257
+ cache --> gradio
258
+ visitor -- huggingface.co/spaces/... --> gradio
259
+ gradio -- HTML / JSON --> visitor
260
+ ```
261
+
262
+ ### Training (Phases 1–2; already complete in v0.1.0)
263
+
264
+ ```mermaid
265
+ flowchart TD
266
+ classDef data fill:#e1f5fe,stroke:#01579b
267
+ classDef train fill:#fff3e0,stroke:#e65100
268
+ classDef artifact fill:#fce4ec,stroke:#880e4f
269
+
270
+ kaggle[scripts/download_datasets.sh]:::data
271
+ cardd[(CarDD<br/>4000 images + COCO bboxes)]:::data
272
+ sc[(Stanford Cars<br/>196 classes)]:::data
273
+ comp[(comprehensive<br/>front/rear x condition)]:::data
274
+ iaai[(iaai metadata<br/>12353 rows, no cost)]:::data
275
+
276
+ kaggle --> cardd
277
+ kaggle --> sc
278
+ kaggle --> comp
279
+ kaggle --> iaai
280
+
281
+ tr_cls[ccdp train classifier]:::train
282
+ tr_det[ccdp train detector]:::train
283
+ tr_id[ccdp train identifier<br/>+ RandAug + MixUp + CutMix]:::train
284
+
285
+ cardd --> tr_cls
286
+ cardd --> tr_det
287
+ sc --> tr_id
288
+
289
+ classifier[(classifier.pt<br/>val F1 0.834)]:::artifact
290
+ detector[(detector.pt<br/>mAP50 0.687)]:::artifact
291
+ identifier[(identifier.pt<br/>val acc 77.0%)]:::artifact
292
+
293
+ tr_cls --> classifier
294
+ tr_det --> detector
295
+ tr_id --> identifier
296
+
297
+ feat[extract-features]:::train
298
+ bbox[extract-bbox-features]:::train
299
+ synth[synth-targets<br/>iaai metadata + catalog]:::train
300
+
301
+ classifier --> feat
302
+ detector --> bbox
303
+ iaai --> synth
304
+
305
+ xgbA_tr[xgb --variant a]:::train
306
+ xgbB_tr[xgb --variant b]:::train
307
+
308
+ feat --> xgbA_tr
309
+ feat --> xgbB_tr
310
+ bbox --> xgbB_tr
311
+ synth --> xgbA_tr
312
+ synth --> xgbB_tr
313
+
314
+ xgb_a[(xgb_a.ubj<br/>val R^2 0.630)]:::artifact
315
+ xgb_b[(xgb_b.ubj<br/>val R^2 0.716)]:::artifact
316
+
317
+ xgbA_tr --> xgb_a
318
+ xgbB_tr --> xgb_b
319
+
320
+ promote[ccdp registry promote<br/>updates production/ symlinks]:::train
321
+ classifier --> promote
322
+ detector --> promote
323
+ identifier --> promote
324
+ xgb_a --> promote
325
+ xgb_b --> promote
326
+ ```
327
+
328
+ ---
329
+
330
  ## Execution flow
331
 
332
  ### Training (run once, then promote the winning runs)
app.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HuggingFace Space entrypoint.
2
+
3
+ The HF Space Gradio SDK looks for an ``app.py`` at the repo root that defines
4
+ a Gradio app named ``demo``. We delegate to :func:`ccdp.api.demo.build_demo`
5
+ so the demo's behaviour lives in the package and stays testable.
6
+
7
+ On first boot the Space downloads model weights from this repo's GitHub
8
+ Release (v0.1.0) into ``checkpoints/production/``. Subsequent boots reuse the
9
+ cached weights, so cold-start is only slow once (~30 s on free CPU).
10
+
11
+ Caveat (documented for v0.1): the XGBoost bundle JSON sidecars don't ship in
12
+ the v0.1.0 release yet, so the Space's predictions fall through to the
13
+ catalog-only Tier 3 fallback. Cost numbers will be approximations rather than
14
+ the trained XGBoost output. Damage detection (Variant A classifier + Variant B
15
+ detector) is unaffected. A future release will bundle the JSONs and this file
16
+ will pick them up automatically.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ WEIGHTS_DIR = Path("checkpoints/production")
25
+ CATALOG_DIR = Path("data/parts_cost_catalog")
26
+ RELEASE_TAG = "v0.1.0"
27
+ GITHUB_REPO = "theDocWho/car-crash-fix-amount-predictor"
28
+ ASSETS = [
29
+ "classifier.pt",
30
+ "detector.pt",
31
+ "identifier.pt",
32
+ "xgb_a.ubj",
33
+ "xgb_b.ubj",
34
+ "training_catalog.yaml",
35
+ ]
36
+
37
+
38
+ def _fetch_release_assets() -> None:
39
+ """Download missing release assets into ``checkpoints/production/``."""
40
+ WEIGHTS_DIR.mkdir(parents=True, exist_ok=True)
41
+ missing = [a for a in ASSETS if not (WEIGHTS_DIR / a).exists()]
42
+ if not missing:
43
+ print("[boot] all release assets already present; skipping download")
44
+ return
45
+ print(f"[boot] downloading {len(missing)} asset(s) from {GITHUB_REPO}@{RELEASE_TAG}")
46
+ base = f"https://github.com/{GITHUB_REPO}/releases/download/{RELEASE_TAG}"
47
+ for asset in missing:
48
+ dst = WEIGHTS_DIR / asset
49
+ subprocess.run(
50
+ ["curl", "-fL", "--retry", "3", "-o", str(dst), f"{base}/{asset}"],
51
+ check=True,
52
+ )
53
+ size_kb = dst.stat().st_size // 1024
54
+ print(f"[boot] fetched {asset} ({size_kb} KB)")
55
+
56
+
57
+ def _bootstrap_catalog() -> None:
58
+ """Place the bundled catalog YAML where ``ccdp.costing`` expects it."""
59
+ CATALOG_DIR.mkdir(parents=True, exist_ok=True)
60
+ src = WEIGHTS_DIR / "training_catalog.yaml"
61
+ if not src.exists():
62
+ return
63
+ target = CATALOG_DIR / "catalog_2026-05-12T05-45-11_initial.yaml"
64
+ if not target.exists():
65
+ target.write_bytes(src.read_bytes())
66
+ active = CATALOG_DIR / "active.yaml"
67
+ if not active.exists() and not active.is_symlink():
68
+ active.symlink_to(target.name)
69
+
70
+
71
+ _fetch_release_assets()
72
+ _bootstrap_catalog()
73
+
74
+ # Import deferred until after the bootstrap so the loaders find catalog + weights.
75
+ from ccdp.api.demo import build_demo # noqa: E402
76
+
77
+ demo = build_demo()
78
+
79
+ if __name__ == "__main__":
80
+ demo.launch()
packages.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ curl
2
+ libgl1
3
+ libglib2.0-0
progress/STATUS.md CHANGED
@@ -1,6 +1,6 @@
1
  # Project Status — Car Crash Fix Amount Predictor
2
 
3
- **Last updated:** 2026-05-13 (Phase 2B scaffold + smoke end-to-end; both variants now runnable side-by-side)
4
 
5
  This folder tracks what has been built and what remains. One file per phase. The full design lives in [../PLAN.md](../PLAN.md).
6
 
@@ -13,7 +13,7 @@ This folder tracks what has been built and what remains. One file per phase. The
13
  | 1.5 | Make/model identifier (Stanford Cars fine-tune) | ✅ **Done** (scaffold + smoke-verified; full training is a CLI command) | [phase_1_5_car_identifier.md](phase_1_5_car_identifier.md) |
14
  | 2A | ResNet50 multi-label damage classifier + XGBoost(A) | ✅ **Done** (scaffold + smoke end-to-end; full training is a CLI sequence) | [phase_2a_resnet_classifier.md](phase_2a_resnet_classifier.md) |
15
  | 2B | YOLOv8 damage detector + XGBoost(B) | ✅ **Done** (scaffold + smoke; full training is a CLI sequence) | [phase_2b_yolov8_detector.md](phase_2b_yolov8_detector.md) |
16
- | 3 | Comparison notebook, report generator, FastAPI + Gradio demo | ⏳ Pending | [phase_3_comparison_and_serving.md](phase_3_comparison_and_serving.md) |
17
  | 4 | Continued-training + promotion workflow validation, final report | ⏳ Pending | [phase_4_promotion_and_final_report.md](phase_4_promotion_and_final_report.md) |
18
 
19
  ## Conventions
 
1
  # Project Status — Car Crash Fix Amount Predictor
2
 
3
+ **Last updated:** 2026-05-14 (Phase 3 scaffold complete; HF Space deploy primed)
4
 
5
  This folder tracks what has been built and what remains. One file per phase. The full design lives in [../PLAN.md](../PLAN.md).
6
 
 
13
  | 1.5 | Make/model identifier (Stanford Cars fine-tune) | ✅ **Done** (scaffold + smoke-verified; full training is a CLI command) | [phase_1_5_car_identifier.md](phase_1_5_car_identifier.md) |
14
  | 2A | ResNet50 multi-label damage classifier + XGBoost(A) | ✅ **Done** (scaffold + smoke end-to-end; full training is a CLI sequence) | [phase_2a_resnet_classifier.md](phase_2a_resnet_classifier.md) |
15
  | 2B | YOLOv8 damage detector + XGBoost(B) | ✅ **Done** (scaffold + smoke; full training is a CLI sequence) | [phase_2b_yolov8_detector.md](phase_2b_yolov8_detector.md) |
16
+ | 3 | Comparison report (HTML+PDF), FastAPI, Gradio demo, HF Space deploy | ✅ **Done** (scaffold + verified locally; HF deploy on next merge) | [phase_3_comparison_and_serving.md](phase_3_comparison_and_serving.md) |
17
  | 4 | Continued-training + promotion workflow validation, final report | ⏳ Pending | [phase_4_promotion_and_final_report.md](phase_4_promotion_and_final_report.md) |
18
 
19
  ## Conventions
progress/phase_3_comparison_and_serving.md CHANGED
@@ -1,54 +1,92 @@
1
- # Phase 3 — Comparison report + FastAPI + Gradio demo
2
 
3
- **Status:** ⏳ Pending
4
- **Estimated effort:** 2 days
5
- **Depends on:** Phases 2A and 2B.
6
 
7
  ## Goal
8
 
9
- Produce the head-to-head **ResNet vs YOLOv8** comparison required by the capstone and ship two ways to actually run the system: a programmatic FastAPI endpoint and an interactive Gradio demo with the catalog / FX / model switchers we promised in [PLAN.md §7-§9](../PLAN.md).
 
 
 
 
 
 
10
 
11
  ## Deliverables
12
 
13
- - `notebooks/05_model_comparison.ipynb` — full comparison table from [PLAN.md §9](../PLAN.md).
14
- - `src/ccdp/eval/comparison_report.py` + `src/ccdp/eval/report_generator.py` — generate HTML + PDF report sections.
15
- - `src/ccdp/api/server.py` — FastAPI app exposing `/estimate` with `?model=resnet|yolov8|both` and `?currency=USD|INR`.
16
- - `src/ccdp/api/demo.py` — Gradio app with:
17
- - Image upload + optional metadata fields.
18
- - Model selector (resnet / yolov8 / both).
19
- - Catalog selector (dropdown of all catalog ids).
20
- - FX refresh button.
21
- - "Label this car" tab driven by the unidentified bucket.
22
- - `ccdp serve api` and `ccdp serve demo` CLI subcommands.
23
- - A generated report at `reports/report_<ts>.pdf` and `reports/report_<ts>.html`.
 
 
 
24
 
25
- ## Done
26
 
27
- (none yet)
 
 
 
 
 
28
 
29
- ## Pending
30
 
31
- - [ ] **Comparison metrics computation.** All rows from [PLAN.md §9 comparison table](../PLAN.md): per-class F1 (A vs B), mAP@0.5 (B only), regression metrics (RMSE/MAE/MAPE/R²), inference latency (per-image ms on MPS), model size, training wall-clock, Tier 1/2/3 distribution, error slices.
32
- - [ ] **Qualitative grid.** 10 side-by-side examples with Grad-CAM (A) vs detection boxes (B), captioned with both predictions and ground truth.
33
- - [ ] **Decision recommendation table.** "When to use Variant A vs Variant B" matrix in the report.
34
- - [ ] **HTML report.** Built with Jinja2 templates rendering the comparison + slice analyses + provenance breakdown.
35
- - [ ] **PDF export.** Via WeasyPrint or Playwright `page.pdf()` (WeasyPrint preferred — no browser dependency).
36
- - [ ] **FastAPI endpoint.** Pydantic request/response models, multi-part image upload, structured response per [PLAN.md §6](../PLAN.md) with `tier`, `provenance`, `catalog_id`, `fx_snapshot`.
37
- - [ ] **Gradio demo.** All four switchers wired. Catalog dropdown auto-populates from `ccdp.costing.list_catalogs()`; switching it re-prices without rerunning the vision model.
38
- - [ ] **"Label this car" tab.** Lists unidentified images with auto-names; dropdowns for make/model/year; saves to SQLite via `ccdp.identification.unidentified`.
39
- - [ ] **Health endpoint.** `/health` returns `{models_loaded, active_catalog_id, fx_rate, fx_age_hours}`.
40
 
41
- ## Open questions / decisions needed
42
 
43
- 1. **PDF backend.** Default to WeasyPrint (pure-Python, but heavy CSS subset). Alt: Playwright (cleaner output but needs a browser binary). Default: WeasyPrint.
44
- 2. **Auth on the FastAPI demo.** Capstone scope — no auth. Document this in README.
45
- 3. **Gradio share link.** Local-only by default; do not expose via `share=True` unless explicitly run with `--share`.
46
 
47
- ## How to verify
 
 
 
 
 
48
 
49
- ```bash
50
- ccdp report generate --run-id <id> # writes reports/report_<ts>.{html,pdf}
51
- ccdp serve api # http://127.0.0.1:8000/docs
52
- curl -F "image=@sample.jpg" "http://127.0.0.1:8000/estimate?model=both&currency=INR"
53
- ccdp serve demo # http://127.0.0.1:7860
54
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phase 3 — Comparison report + FastAPI + Gradio + HuggingFace deploy
2
 
3
+ **Status:** ✅ **Done** (scaffold + verified locally; HF Space deploy triggers on next merge to `main`)
4
+ **Completed:** 2026-05-14
 
5
 
6
  ## Goal
7
 
8
+ Make the trained v0.1.0 system usable and reviewable by humans rather than only CLI-savvy operators. Three deliverables: a comparison report (HTML + optional PDF), a FastAPI service, and a Gradio demo — all readable from the existing production registry, no new training.
9
+
10
+ ## Pivots vs the original Phase 3 plan
11
+
12
+ 1. **Added image pre-processing (Stage A — downscale + quality report).** Lives in `ccdp.preprocess`. Stage B (super-resolution via Real-ESRGAN) deferred to a later checkpoint per user direction.
13
+ 2. **Added GitHub→HuggingFace Space auto-deploy.** Free, persistent, Gradio SDK. Workflow lives in `.github/workflows/deploy-hf.yml` and fires on every push to `main`.
14
+ 3. **Skipped "Label this car" tab.** SQLite bucket is empty in production until we run identification in a batch job; tab would have nothing to show. Deferred to a later checkpoint.
15
 
16
  ## Deliverables
17
 
18
+ - [x] [src/ccdp/preprocess/pipeline.py](../src/ccdp/preprocess/pipeline.py) — `preprocess`, `normalize_for_inference`, `quality_report`.
19
+ - [x] [src/ccdp/eval/metrics.py](../src/ccdp/eval/metrics.py) — `per_class_prf`, `regression_metrics` (pure functions, no sklearn).
20
+ - [x] [src/ccdp/eval/comparison.py](../src/ccdp/eval/comparison.py) — `build_comparison` runs Variants A & B on the seed=42 test split, returns a `Comparison` dataclass.
21
+ - [x] [src/ccdp/eval/report.py](../src/ccdp/eval/report.py) — Jinja2 → HTML always; WeasyPrint → PDF when installed.
22
+ - [x] [reports/templates/report.html.j2](../reports/templates/report.html.j2) — A4 layout with headline table, per-variant detail, tier/latency, top failures, honest-disclosure footer.
23
+ - [x] [src/ccdp/api/schemas.py](../src/ccdp/api/schemas.py) — Pydantic request/response models.
24
+ - [x] [src/ccdp/api/server.py](../src/ccdp/api/server.py) — FastAPI app with `/health`, `/catalogs`, `/fx`, `/estimate`. Pipelines loaded once at startup via `lifespan`.
25
+ - [x] [src/ccdp/api/demo.py](../src/ccdp/api/demo.py) — Gradio Blocks UI: Estimate / Catalog manager / FX / About tabs.
26
+ - [x] CLI: `ccdp serve api`, `ccdp serve demo`, `ccdp report generate`.
27
+ - [x] [app.py](../app.py) — HF Space entry; downloads v0.1.0 release assets on first boot, then launches `build_demo`.
28
+ - [x] [requirements.txt](../requirements.txt), [packages.txt](../packages.txt) — HF Space build inputs.
29
+ - [x] [.github/workflows/deploy-hf.yml](../.github/workflows/deploy-hf.yml) — Mirrors `main` to the HF Space, synthesises the YAML frontmatter so the GitHub README stays clean.
30
+ - [x] [README.md](../README.md) — three Mermaid diagrams (local execution, deploy, training).
31
+ - [x] Tests: [tests/test_preprocess.py](../tests/test_preprocess.py) + [tests/test_eval_metrics.py](../tests/test_eval_metrics.py) — **76 / 76 passing**.
32
 
33
+ ## Local smoke results
34
 
35
+ ```text
36
+ ccdp report generate --limit 5 --no-pdf → reports/report_<ts>.html (10.8 KB)
37
+ ccdp serve api → /health 200 ok → variants A & B both loaded on MPS
38
+ /catalogs 200 [1 catalog]
39
+ build_demo() → gradio Blocks ok
40
+ ```
41
 
42
+ ## How to verify
43
 
44
+ ```bash
45
+ source .venv/bin/activate
46
+ export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")
47
+ export DYLD_LIBRARY_PATH=$(python -c "import torch, os; print(os.path.join(os.path.dirname(torch.__file__),'lib'))")
 
 
 
 
 
48
 
49
+ pytest -q # 76 / 76 passing
50
 
51
+ # Report
52
+ ccdp report generate --limit 50 --no-pdf # ~30 s, writes reports/report_<ts>.html
53
+ ccdp report generate # full test split (slower)
54
 
55
+ # API
56
+ ccdp serve api & # http://127.0.0.1:8000/docs
57
+ curl http://127.0.0.1:8000/health
58
+ curl -F image=@some_car.jpg -F model=both \
59
+ -F make=toyota -F year=2019 -F body_type=sedan \
60
+ http://127.0.0.1:8000/estimate
61
 
62
+ # Gradio demo
63
+ ccdp serve demo # http://127.0.0.1:7860
 
 
 
64
  ```
65
+
66
+ ## HuggingFace deployment
67
+
68
+ One-time setup (already done):
69
+ - `HF_USERNAME` repo variable set to `theDocWho`.
70
+ - `HF_TOKEN` repo secret set (user-supplied write-scope HF token).
71
+
72
+ Trigger: every push to `main` (or manual via the Actions tab). The workflow:
73
+ 1. Checks out the repo at the pushed commit.
74
+ 2. Prepends the HF Space YAML frontmatter to a temporary copy of README.md.
75
+ 3. Force-pushes the result to `huggingface.co/spaces/theDocWho/car-crash-fix-amount-predictor`.
76
+ 4. The Space rebuilds (~2 min); on first boot, `app.py` curl-downloads the v0.1.0 release assets into `checkpoints/production/`.
77
+
78
+ Once this branch merges to `main`, the Space will appear at:
79
+ **https://huggingface.co/spaces/theDocWho/car-crash-fix-amount-predictor**
80
+
81
+ ## Known limitations (documented in app.py)
82
+
83
+ 1. **XGBoost bundle JSON sidecars don't ship in the v0.1.0 release**, so the HF Space's predictions fall through to the Tier-3 catalog-only fallback. Damage detection (Variant A classifier + Variant B detector) is unaffected. Fix: add `bundle.json` sidecars to a future release, no code change needed.
84
+ 2. **HF Space runs on CPU.** Expected per-image latency: ~3–5 s for `--model both`. Acceptable for a demo.
85
+ 3. **PDF rendering needs `pango` + `cairo` system libraries** (via `weasyprint`). Not installed on plain macOS without Homebrew. The HTML report always works; the CLI prints a helpful "install hint" when PDF is skipped.
86
+ 4. **No auth on the API or Gradio demo.** Capstone scope; documented in README.
87
+
88
+ ## Notes for future phases
89
+
90
+ - **Checkpoint 4** could ship `bundle.json` files into the v0.1.0 release (or a v0.1.1) so the HF Space exercises the full XGBoost path. Pure data, no code change.
91
+ - **Checkpoint 5** could add Stage B (Real-ESRGAN) — extra ~65 MB download, hooks into `ccdp.preprocess`.
92
+ - **Phase 4** (continued training + promotion harness) remains its own work stream.
requirements.txt ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Runtime dependencies for the HuggingFace Space.
2
+ # Mirrors `pyproject.toml [project.dependencies]` + `[ml]` + `[serve]` minus
3
+ # things only needed for training (datasets / kaggle / jupyter / etc.).
4
+ # Keep CPU-only torch wheels — the Space runs on CPU.
5
+
6
+ pyyaml>=6.0
7
+ requests>=2.31
8
+ typer>=0.12
9
+ rich>=13.7
10
+ pydantic>=2.6
11
+ jinja2>=3.1
12
+
13
+ # ML
14
+ torch>=2.2
15
+ torchvision>=0.17
16
+ ultralytics>=8.1
17
+ xgboost>=2.0
18
+ scikit-learn>=1.4
19
+ pandas>=2.2
20
+ numpy>=1.26
21
+ pillow>=10.2
22
+ opencv-python-headless>=4.9
23
+ scipy>=1.10
24
+
25
+ # Serving
26
+ fastapi>=0.110
27
+ uvicorn>=0.27
28
+ gradio>=4.20
29
+ python-multipart>=0.0.9
src/ccdp/api/demo.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio Blocks UI for the ccdp inference pipelines.
2
+
3
+ Layout — three tabs:
4
+ 1. Estimate — upload an image, see Variant A / B side-by-side cost
5
+ 2. Catalog manager — list / view / activate parts-cost catalogs
6
+ 3. FX manager — view / refresh USD↔INR rate
7
+
8
+ The "Label this car" tab from the original Phase 3 plan is deferred — the
9
+ unidentified-cars SQLite bucket is empty in production until we wire identification
10
+ into batch processing in a later checkpoint.
11
+
12
+ The launcher (`build_demo`) is the function the HF Space's ``app.py`` calls.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ import gradio as gr
22
+ from PIL import Image
23
+
24
+ from ccdp.costing import activate as activate_catalog
25
+ from ccdp.costing import fx as fxmod
26
+ from ccdp.costing import list_catalogs
27
+ from ccdp.identification.car_identifier import IdentificationResult, infer_segment
28
+ from ccdp.preprocess import preprocess
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Pipeline caching (load once, reuse for every UI interaction)
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ _pipelines: dict = {}
37
+
38
+
39
+ def _get_pipelines() -> dict:
40
+ """Lazy-load the variant pipelines on first demo interaction."""
41
+ if not _pipelines:
42
+ from ccdp.infer.variant_a import VariantAPipeline
43
+ try:
44
+ _pipelines["a"] = VariantAPipeline()
45
+ except Exception as e: # noqa: BLE001
46
+ print(f"[demo] Variant A unavailable: {e}")
47
+ _pipelines["a"] = None
48
+ try:
49
+ from ccdp.infer.variant_b import VariantBPipeline
50
+ _pipelines["b"] = VariantBPipeline()
51
+ except Exception as e: # noqa: BLE001
52
+ print(f"[demo] Variant B unavailable: {e}")
53
+ _pipelines["b"] = None
54
+ return _pipelines
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Estimate tab handler
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ def _build_metadata(make, model_name, year, body_type) -> Optional[IdentificationResult]:
63
+ if not make:
64
+ return None
65
+ return IdentificationResult(
66
+ image_path=Path(""),
67
+ make=make.lower(),
68
+ model=(model_name.lower() if model_name else None),
69
+ year=int(year) if year else None,
70
+ body_type=body_type or "unknown",
71
+ segment=infer_segment(make),
72
+ confidence=1.0,
73
+ source="user",
74
+ )
75
+
76
+
77
+ def _estimate(
78
+ image: Image.Image,
79
+ model_choice: str,
80
+ currency: str,
81
+ make: str,
82
+ model_name: str,
83
+ year: Optional[int],
84
+ body_type: str,
85
+ ) -> tuple[str, str, str]:
86
+ """Returns (variant_a_summary, variant_b_summary, full_json)."""
87
+ if image is None:
88
+ return "Please upload an image.", "", ""
89
+ pipes = _get_pipelines()
90
+ pil_image, preprocessing_meta = preprocess(image)
91
+ metadata = _build_metadata(make, model_name, year, body_type)
92
+
93
+ full: dict = {"preprocessing": preprocessing_meta}
94
+ a_text, b_text = "Variant A not loaded.", "Variant B not loaded."
95
+
96
+ if model_choice in ("Variant A (ResNet50 classifier)", "Both"):
97
+ if pipes.get("a"):
98
+ pred = pipes["a"].predict(pil_image, metadata=metadata, currency=currency).to_dict()
99
+ full["variant_a"] = pred
100
+ a_text = _format_prediction("A", pred)
101
+
102
+ if model_choice in ("Variant B (YOLOv8 detector)", "Both"):
103
+ if pipes.get("b"):
104
+ pred = pipes["b"].predict(pil_image, metadata=metadata, currency=currency).to_dict()
105
+ full["variant_b"] = pred
106
+ b_text = _format_prediction("B", pred)
107
+
108
+ return a_text, b_text, json.dumps(full, indent=2, default=str)
109
+
110
+
111
+ def _format_prediction(name: str, pred: dict) -> str:
112
+ cost = pred.get("cost", 0.0)
113
+ currency = pred.get("currency", "USD")
114
+ types = ", ".join(pred.get("damage_types", [])) or "—"
115
+ parts = ", ".join(pred.get("parts", [])) or "—"
116
+ tier = pred.get("tier", "?")
117
+ prov = pred.get("provenance", "")
118
+ return (
119
+ f"## Variant {name}\n"
120
+ f"**Cost:** {cost:.2f} {currency} _(tier: `{tier}`)_\n\n"
121
+ f"**Damage types:** {types}\n\n"
122
+ f"**Parts:** {parts}\n\n"
123
+ f"_{prov}_\n"
124
+ )
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Catalog manager handlers
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ def _catalogs_table():
133
+ rows = list_catalogs()
134
+ return [
135
+ [
136
+ "★" if r["is_active"] else "",
137
+ r["catalog_id"],
138
+ r.get("created_at", "") or "",
139
+ r.get("currency", "") or "",
140
+ ]
141
+ for r in rows
142
+ ]
143
+
144
+
145
+ def _activate(catalog_id: str) -> str:
146
+ if not catalog_id:
147
+ return "Pick a catalog id first."
148
+ try:
149
+ activate_catalog(catalog_id.strip())
150
+ return f"Activated: `{catalog_id}`"
151
+ except FileNotFoundError as e:
152
+ return f"Not found: {e}"
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # FX manager
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ def _fx_show() -> str:
161
+ try:
162
+ fr = fxmod.get_rate("USD", "INR")
163
+ return f"1 {fr.base} = **{fr.rate:.4f}** {fr.target} (source: `{fr.source}`, fetched: {fr.fetched_at})"
164
+ except RuntimeError as e:
165
+ return f"_Error: {e}_"
166
+
167
+
168
+ def _fx_refresh() -> str:
169
+ try:
170
+ fr = fxmod.refresh_rate("USD", "INR")
171
+ return f"**Refreshed.** 1 {fr.base} = **{fr.rate:.4f}** {fr.target} ({fr.source})"
172
+ except RuntimeError as e:
173
+ return f"_Error: {e}_"
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Demo factory
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ def build_demo() -> gr.Blocks:
182
+ """Build the Gradio app. Returns it without launching; caller decides how to launch."""
183
+ with gr.Blocks(title="ccdp — Car Damage + Repair Cost") as demo:
184
+ gr.Markdown("# Car Crash Fix Amount Predictor")
185
+ gr.Markdown(
186
+ "Upload a damaged-car photo and (optionally) tell us the car's make / "
187
+ "model / year for the most accurate cost. See the GitHub repo "
188
+ "[theDocWho/car-crash-fix-amount-predictor]"
189
+ "(https://github.com/theDocWho/car-crash-fix-amount-predictor) for full docs."
190
+ )
191
+
192
+ with gr.Tab("Estimate"):
193
+ with gr.Row():
194
+ with gr.Column(scale=1):
195
+ image_in = gr.Image(type="pil", label="Car damage image")
196
+ model_choice = gr.Radio(
197
+ choices=["Variant A (ResNet50 classifier)",
198
+ "Variant B (YOLOv8 detector)",
199
+ "Both"],
200
+ value="Both",
201
+ label="Which model?",
202
+ )
203
+ currency = gr.Radio(choices=["USD", "INR"], value="USD", label="Currency")
204
+ with gr.Accordion("Car metadata (optional but improves cost accuracy)", open=False):
205
+ make = gr.Textbox(label="Make", placeholder="e.g. Toyota")
206
+ model_name = gr.Textbox(label="Model", placeholder="e.g. Camry")
207
+ year = gr.Number(label="Year", value=None, precision=0)
208
+ body_type = gr.Dropdown(
209
+ choices=["unknown", "sedan", "suv", "hatchback",
210
+ "coupe", "convertible", "wagon", "pickup", "van"],
211
+ value="unknown",
212
+ label="Body type",
213
+ )
214
+ run_btn = gr.Button("Estimate", variant="primary")
215
+ with gr.Column(scale=1):
216
+ variant_a_out = gr.Markdown(label="Variant A")
217
+ variant_b_out = gr.Markdown(label="Variant B")
218
+ with gr.Accordion("Full JSON (provenance, probabilities, detections)", open=False):
219
+ json_out = gr.Code(language="json")
220
+
221
+ run_btn.click(
222
+ _estimate,
223
+ inputs=[image_in, model_choice, currency, make, model_name, year, body_type],
224
+ outputs=[variant_a_out, variant_b_out, json_out],
225
+ )
226
+
227
+ with gr.Tab("Catalog manager"):
228
+ gr.Markdown(
229
+ "The active parts-cost catalog backs every cost prediction. "
230
+ "Switching it re-prices the same image **without** retraining the model "
231
+ "via the built-in calibrator."
232
+ )
233
+ catalog_table = gr.Dataframe(
234
+ headers=["active", "catalog_id", "created_at", "currency"],
235
+ value=_catalogs_table,
236
+ interactive=False,
237
+ )
238
+ with gr.Row():
239
+ catalog_pick = gr.Textbox(label="Catalog id to activate")
240
+ activate_btn = gr.Button("Activate")
241
+ activate_msg = gr.Markdown()
242
+ refresh_catalogs_btn = gr.Button("Refresh table")
243
+
244
+ activate_btn.click(_activate, inputs=catalog_pick, outputs=activate_msg)
245
+ refresh_catalogs_btn.click(lambda: _catalogs_table(), outputs=catalog_table)
246
+
247
+ with gr.Tab("FX (USD ↔ INR)"):
248
+ gr.Markdown("Current FX rate used when you select INR in the Estimate tab.")
249
+ fx_text = gr.Markdown(value=_fx_show)
250
+ fx_refresh_btn = gr.Button("Refresh now")
251
+ fx_refresh_btn.click(_fx_refresh, outputs=fx_text)
252
+
253
+ with gr.Tab("About"):
254
+ gr.Markdown(
255
+ "ccdp is a capstone project. Cost predictions are **calibrated triage "
256
+ "estimates** — they are not insurable quotes. The cost target during "
257
+ "training is synthetic (catalog-derived) because no public dataset pairs "
258
+ "car-damage images with real repair invoices.\n\n"
259
+ "See `PLAN.md §3` in the GitHub repo for the full disclosure and "
260
+ "`progress/STATUS.md` for current production metrics."
261
+ )
262
+
263
+ return demo
src/ccdp/api/schemas.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models for the FastAPI surface.
2
+
3
+ Kept separate from `server.py` so they can be imported by tests, by clients
4
+ generating types from OpenAPI, and by the Gradio demo without dragging in
5
+ FastAPI itself.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Literal, Optional
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+ ModelChoice = Literal["resnet", "yolov8", "both"]
15
+ Currency = Literal["USD", "INR"]
16
+
17
+
18
+ class EstimateMetadata(BaseModel):
19
+ """Optional car metadata supplied by the caller."""
20
+ make: Optional[str] = None
21
+ model_name: Optional[str] = Field(default=None, description="Avoid 'model' clash with pydantic.")
22
+ year: Optional[int] = None
23
+ body_type: Optional[str] = "unknown"
24
+
25
+
26
+ class HealthResponse(BaseModel):
27
+ status: Literal["ok"]
28
+ active_catalog: Optional[str]
29
+ fx_rate: Optional[float]
30
+ fx_age_hours: Optional[float]
31
+ models: dict[str, Optional[str]]
32
+ device: str
33
+
34
+
35
+ class CatalogEntry(BaseModel):
36
+ catalog_id: str
37
+ created_at: Optional[str]
38
+ currency: Optional[str]
39
+ is_active: bool
40
+
41
+
42
+ class FxResponse(BaseModel):
43
+ base: str
44
+ target: str
45
+ rate: float
46
+ source: str
47
+ fetched_at: str
src/ccdp/api/server.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI service exposing the inference pipelines + catalog / FX management.
2
+
3
+ Endpoints:
4
+ GET /health service + model state
5
+ GET /catalogs list known parts-cost catalogs
6
+ POST /catalogs/{catalog_id}/activate flip the active symlink
7
+ GET /fx current cached USD->INR rate
8
+ POST /fx/refresh fetch a fresh rate
9
+ POST /estimate run Variant A / B / both on an upload
10
+
11
+ Pipelines are instantiated at startup (one model load) and reused per request.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ from contextlib import asynccontextmanager
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
23
+
24
+ from ccdp.api.schemas import (
25
+ CatalogEntry,
26
+ Currency,
27
+ FxResponse,
28
+ HealthResponse,
29
+ ModelChoice,
30
+ )
31
+ from ccdp.costing import activate as activate_catalog
32
+ from ccdp.costing import fx as fxmod
33
+ from ccdp.costing import list_catalogs, load_active
34
+ from ccdp.identification.car_identifier import IdentificationResult, infer_segment
35
+ from ccdp.infer.variant_a import VariantAPipeline
36
+ from ccdp.preprocess import preprocess
37
+ from ccdp.utils import pick_device
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Lifespan — load pipelines once
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ _state: dict = {}
46
+
47
+
48
+ @asynccontextmanager
49
+ async def lifespan(app: FastAPI):
50
+ """Load pipelines at boot; never per request."""
51
+ _state["device"] = str(pick_device())
52
+ _state["variant_a"] = None
53
+ _state["variant_b"] = None
54
+ try:
55
+ _state["variant_a"] = VariantAPipeline()
56
+ print(f"[api] Variant A pipeline loaded on {_state['device']}")
57
+ except Exception as e: # noqa: BLE001
58
+ print(f"[api] Variant A unavailable: {e}")
59
+ try:
60
+ from ccdp.infer.variant_b import VariantBPipeline
61
+ _state["variant_b"] = VariantBPipeline()
62
+ print(f"[api] Variant B pipeline loaded on {_state['device']}")
63
+ except Exception as e: # noqa: BLE001
64
+ print(f"[api] Variant B unavailable: {e}")
65
+ yield
66
+ _state.clear()
67
+
68
+
69
+ app = FastAPI(
70
+ title="ccdp",
71
+ description="Car Crash Fix Amount Predictor — damage recognition + cost estimation.",
72
+ version="0.1.0",
73
+ lifespan=lifespan,
74
+ )
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Health
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ @app.get("/health", response_model=HealthResponse)
83
+ def health() -> HealthResponse:
84
+ """Service liveness + which models are loaded + catalog / FX state."""
85
+ try:
86
+ active = load_active()
87
+ catalog_id = active.catalog_id
88
+ except FileNotFoundError:
89
+ catalog_id = None
90
+
91
+ fx_rate: Optional[float] = None
92
+ fx_age_hours: Optional[float] = None
93
+ try:
94
+ fr = fxmod.get_rate("USD", "INR", allow_stale=True)
95
+ fx_rate = fr.rate
96
+ fetched = datetime.fromisoformat(fr.fetched_at)
97
+ fx_age_hours = (datetime.now(timezone.utc) - fetched).total_seconds() / 3600
98
+ except Exception: # noqa: BLE001
99
+ pass
100
+
101
+ return HealthResponse(
102
+ status="ok",
103
+ active_catalog=catalog_id,
104
+ fx_rate=fx_rate,
105
+ fx_age_hours=fx_age_hours,
106
+ models={
107
+ "variant_a": "loaded" if _state.get("variant_a") else None,
108
+ "variant_b": "loaded" if _state.get("variant_b") else None,
109
+ },
110
+ device=_state.get("device", "unknown"),
111
+ )
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Catalogs
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ @app.get("/catalogs", response_model=list[CatalogEntry])
120
+ def catalogs() -> list[CatalogEntry]:
121
+ rows = list_catalogs()
122
+ return [CatalogEntry(
123
+ catalog_id=r["catalog_id"],
124
+ created_at=r.get("created_at"),
125
+ currency=r.get("currency"),
126
+ is_active=r["is_active"],
127
+ ) for r in rows]
128
+
129
+
130
+ @app.post("/catalogs/{catalog_id}/activate")
131
+ def catalog_activate(catalog_id: str) -> dict:
132
+ try:
133
+ activate_catalog(catalog_id)
134
+ return {"activated": catalog_id}
135
+ except FileNotFoundError as e:
136
+ raise HTTPException(status_code=404, detail=str(e))
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # FX
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ @app.get("/fx", response_model=FxResponse)
145
+ def fx_show() -> FxResponse:
146
+ try:
147
+ fr = fxmod.get_rate("USD", "INR")
148
+ except RuntimeError as e:
149
+ raise HTTPException(status_code=503, detail=str(e))
150
+ return FxResponse(base=fr.base, target=fr.target, rate=fr.rate,
151
+ source=fr.source, fetched_at=fr.fetched_at)
152
+
153
+
154
+ @app.post("/fx/refresh", response_model=FxResponse)
155
+ def fx_refresh() -> FxResponse:
156
+ fr = fxmod.refresh_rate("USD", "INR")
157
+ return FxResponse(base=fr.base, target=fr.target, rate=fr.rate,
158
+ source=fr.source, fetched_at=fr.fetched_at)
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Estimate (the main endpoint)
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ def _build_identification(make, model_name, year, body_type) -> Optional[IdentificationResult]:
167
+ if not make:
168
+ return None
169
+ return IdentificationResult(
170
+ image_path=Path(""),
171
+ make=make.lower(),
172
+ model=(model_name.lower() if model_name else None),
173
+ year=year,
174
+ body_type=body_type or "unknown",
175
+ segment=infer_segment(make),
176
+ confidence=1.0,
177
+ source="user",
178
+ )
179
+
180
+
181
+ @app.post("/estimate")
182
+ async def estimate(
183
+ image: UploadFile = File(..., description="JPEG or PNG car damage image"),
184
+ model: ModelChoice = Form("both"),
185
+ currency: Currency = Form("USD"),
186
+ make: Optional[str] = Form(None),
187
+ model_name: Optional[str] = Form(None),
188
+ year: Optional[int] = Form(None),
189
+ body_type: Optional[str] = Form("unknown"),
190
+ refresh_fx: bool = Form(False),
191
+ ) -> dict:
192
+ """Run the chosen variant(s) on an uploaded image and return a structured response."""
193
+ raw = await image.read()
194
+ if not raw:
195
+ raise HTTPException(status_code=400, detail="Empty upload")
196
+
197
+ try:
198
+ pil_image, preprocessing_meta = preprocess(raw)
199
+ except Exception as e: # noqa: BLE001
200
+ raise HTTPException(status_code=400, detail=f"Could not decode image: {e}")
201
+
202
+ if refresh_fx:
203
+ try:
204
+ fxmod.refresh_rate("USD", "INR")
205
+ except Exception as e: # noqa: BLE001
206
+ print(f"[api] FX refresh failed (continuing): {e}")
207
+
208
+ metadata = _build_identification(make, model_name, year, body_type)
209
+
210
+ response: dict = {
211
+ "preprocessing": preprocessing_meta,
212
+ "active_catalog": load_active().catalog_id,
213
+ }
214
+
215
+ if model in ("resnet", "both"):
216
+ pipe = _state.get("variant_a")
217
+ if pipe is None:
218
+ raise HTTPException(status_code=503, detail="Variant A model not loaded")
219
+ response["variant_a"] = pipe.predict(
220
+ pil_image, metadata=metadata, currency=currency,
221
+ ).to_dict()
222
+
223
+ if model in ("yolov8", "both"):
224
+ pipe = _state.get("variant_b")
225
+ if pipe is None:
226
+ if model == "yolov8":
227
+ raise HTTPException(status_code=503, detail="Variant B model not loaded")
228
+ # 'both' is best-effort — silently skip B if unavailable
229
+ else:
230
+ response["variant_b"] = pipe.predict(
231
+ pil_image, metadata=metadata, currency=currency,
232
+ ).to_dict()
233
+
234
+ return response
src/ccdp/cli.py CHANGED
@@ -23,12 +23,16 @@ data_app = typer.Typer(help="Dataset commands: download, schema inspection, refe
23
  unidentified_app = typer.Typer(help="Manage the unidentified-cars bucket.")
24
  train_app = typer.Typer(help="Training commands.")
25
  registry_app = typer.Typer(help="Model registry commands.")
 
 
26
  app.add_typer(costing_app, name="costing")
27
  app.add_typer(fx_app, name="fx")
28
  app.add_typer(data_app, name="data")
29
  app.add_typer(unidentified_app, name="unidentified")
30
  app.add_typer(train_app, name="train")
31
  app.add_typer(registry_app, name="registry")
 
 
32
 
33
  console = Console()
34
 
@@ -664,5 +668,73 @@ def registry_promote(
664
  console.print(f"[green]Promoted[/green] {run_id} -> {link}")
665
 
666
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
  if __name__ == "__main__": # pragma: no cover
668
  app()
 
23
  unidentified_app = typer.Typer(help="Manage the unidentified-cars bucket.")
24
  train_app = typer.Typer(help="Training commands.")
25
  registry_app = typer.Typer(help="Model registry commands.")
26
+ serve_app = typer.Typer(help="Serve the inference API or the Gradio demo.")
27
+ report_app = typer.Typer(help="Generate the comparison report.")
28
  app.add_typer(costing_app, name="costing")
29
  app.add_typer(fx_app, name="fx")
30
  app.add_typer(data_app, name="data")
31
  app.add_typer(unidentified_app, name="unidentified")
32
  app.add_typer(train_app, name="train")
33
  app.add_typer(registry_app, name="registry")
34
+ app.add_typer(serve_app, name="serve")
35
+ app.add_typer(report_app, name="report")
36
 
37
  console = Console()
38
 
 
668
  console.print(f"[green]Promoted[/green] {run_id} -> {link}")
669
 
670
 
671
+ # ----------------- serve ------------------------------------------------
672
+
673
+
674
+ @serve_app.command("api")
675
+ def serve_api(
676
+ host: str = typer.Option("127.0.0.1", help="Bind address. Use 0.0.0.0 to expose."),
677
+ port: int = typer.Option(8000),
678
+ reload: bool = typer.Option(False, help="uvicorn auto-reload (dev only)."),
679
+ ) -> None:
680
+ """Run the FastAPI inference service."""
681
+ import uvicorn
682
+ console.print(f"[bold]Starting ccdp API[/bold] on http://{host}:{port}")
683
+ uvicorn.run("ccdp.api.server:app", host=host, port=port, reload=reload)
684
+
685
+
686
+ @serve_app.command("demo")
687
+ def serve_demo(
688
+ host: str = typer.Option("127.0.0.1"),
689
+ port: int = typer.Option(7860),
690
+ share: bool = typer.Option(False, help="Gradio public share link."),
691
+ ) -> None:
692
+ """Run the Gradio demo."""
693
+ from ccdp.api.demo import build_demo
694
+ import gradio as gr
695
+ demo = build_demo()
696
+ demo.launch(
697
+ server_name=host, server_port=port, share=share, show_error=True,
698
+ theme=gr.themes.Soft(),
699
+ )
700
+
701
+
702
+ # ----------------- report -----------------------------------------------
703
+
704
+
705
+ @report_app.command("generate")
706
+ def report_generate(
707
+ variant: str = typer.Option("both", help="a | b | both"),
708
+ limit: int = typer.Option(0, help="Cap test images (0 = all). Smoke runs use a small value."),
709
+ no_pdf: bool = typer.Option(False, "--no-pdf"),
710
+ ) -> None:
711
+ """Build the Variant-A-vs-B comparison report (HTML always, PDF optional)."""
712
+ from ccdp.eval import build_comparison, report as report_mod
713
+ from ccdp.infer.variant_a import VariantAPipeline
714
+
715
+ pipe_a = VariantAPipeline() if variant in ("a", "both") else None
716
+ pipe_b = None
717
+ if variant in ("b", "both"):
718
+ try:
719
+ from ccdp.infer.variant_b import VariantBPipeline
720
+ pipe_b = VariantBPipeline()
721
+ except FileNotFoundError as e:
722
+ console.print(f"[yellow]Variant B unavailable: {e}[/yellow]")
723
+
724
+ if pipe_a is None and pipe_b is None:
725
+ console.print("[red]No pipelines available — nothing to report.[/red]")
726
+ raise typer.Exit(2)
727
+ # If user asked only for B but B failed, fall back to A-only
728
+ if pipe_a is None and pipe_b is not None:
729
+ pipe_a = pipe_b
730
+ pipe_b = None
731
+
732
+ cmp = build_comparison(pipe_a, pipe_b, limit=(limit or None))
733
+ paths = report_mod.generate(cmp, also_pdf=not no_pdf)
734
+ console.print(f"[green]HTML:[/green] {paths['html']}")
735
+ if paths.get("pdf"):
736
+ console.print(f"[green]PDF: [/green] {paths['pdf']}")
737
+
738
+
739
  if __name__ == "__main__": # pragma: no cover
740
  app()
src/ccdp/eval/__init__.py CHANGED
@@ -1,4 +1,21 @@
1
- """ccdp.eval — placeholder for the Phase 3 comparison-report generator.
2
 
3
- Currently empty. The Phase 3 work (PLAN.md §9, progress/phase_3_*.md) lands here.
 
 
 
 
 
 
4
  """
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ccdp.eval — Variant A vs Variant B comparison + report rendering.
2
 
3
+ Public API:
4
+ build_comparison(variant_a_pipeline, variant_b_pipeline=None, ...)
5
+ -> evaluates both variants on the deterministic seed=42 test split.
6
+ Comparison
7
+ -> the dataclass that holds everything the report needs.
8
+ report.render_html / report.render_pdf / report.generate
9
+ -> render to HTML and optionally PDF.
10
  """
11
+
12
+ from .comparison import Comparison, VariantReport, build_comparison, evaluate_variant
13
+ from . import report
14
+
15
+ __all__ = [
16
+ "Comparison",
17
+ "VariantReport",
18
+ "build_comparison",
19
+ "evaluate_variant",
20
+ "report",
21
+ ]
src/ccdp/eval/comparison.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Variant A vs Variant B head-to-head evaluator.
2
+
3
+ The "comparison" is a single :class:`Comparison` dataclass that knows:
4
+
5
+ * which test split was used (seed=42 deterministic, identical for A and B)
6
+ * per-variant classification + regression metrics
7
+ * per-variant inference latency (ms / image)
8
+ * tier distribution (`exact` / `nearest_class` / `category_only`)
9
+ * slice analyses by car segment and damage type
10
+ * the production model + catalog ids the report was generated against
11
+
12
+ The class is pure data once built — the renderer (``ccdp.eval.report``)
13
+ consumes it without touching any models. That separation keeps the slow part
14
+ (model inference over 400 test images) decoupled from the fast part (HTML/PDF
15
+ rendering) so you can iterate on the report layout without re-evaluating.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from dataclasses import asdict, dataclass, field
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any, Optional
25
+
26
+ import numpy as np
27
+
28
+ from ccdp.costing import load_active
29
+ from ccdp.data import damage_dataset as dd
30
+ from ccdp.data.loaders import iter_cardd
31
+ from ccdp.data.schema import DAMAGE_TYPES
32
+ from ccdp.eval.metrics import per_class_prf, regression_metrics
33
+ from ccdp.identification.car_identifier import IdentificationResult, infer_segment
34
+ from ccdp.registry import production_target
35
+
36
+
37
+ @dataclass
38
+ class VariantReport:
39
+ """Everything the report renderer needs for one variant."""
40
+
41
+ name: str # 'A' | 'B'
42
+ n_images: int
43
+ classification: dict # output of per_class_prf
44
+ regression: dict # output of regression_metrics
45
+ tier_distribution: dict[str, int] # tier -> count
46
+ latency_ms: dict[str, float] # mean, p50, p95
47
+ examples: list[dict] = field(default_factory=list)
48
+ failures: list[dict] = field(default_factory=list)
49
+
50
+
51
+ @dataclass
52
+ class Comparison:
53
+ """Whole-report payload."""
54
+
55
+ generated_at: str
56
+ catalog_id: Optional[str]
57
+ test_split_size: int
58
+ seed: int
59
+ variant_a: VariantReport
60
+ variant_b: Optional[VariantReport] = None
61
+ model_versions: dict[str, str] = field(default_factory=dict)
62
+ slices: dict[str, Any] = field(default_factory=dict)
63
+ notes: str = ""
64
+
65
+ def to_dict(self) -> dict:
66
+ d = asdict(self)
67
+ return d
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Evaluation loop
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def _percentile(values, p):
76
+ if not values:
77
+ return 0.0
78
+ return float(np.percentile(np.asarray(values, dtype=float), p))
79
+
80
+
81
+ def _make_metadata_sampler(seed: int = 42):
82
+ """Reuse the same iaai sampler the synthetic targets used at training time.
83
+
84
+ Crucial: must match the trainer's metadata sampling so the ground-truth cost
85
+ we compare against is generated under identical assumptions.
86
+ """
87
+ from ccdp.train.synthesize_cost import MetadataSampler
88
+ return MetadataSampler(seed=seed)
89
+
90
+
91
+ def _ground_truth_cost(record, sampler, catalog, rng):
92
+ """Reconstruct the synthetic training-time cost target for one record."""
93
+ from ccdp.train.synthesize_cost import cost_for_damage
94
+ meta = sampler.sample()
95
+ return meta, cost_for_damage(
96
+ record.damage_types, meta.segment, catalog, rng, year=meta.year,
97
+ )
98
+
99
+
100
+ def _identification_for(meta) -> IdentificationResult:
101
+ """Build an `IdentificationResult` the pipeline expects from a metadata sample."""
102
+ return IdentificationResult(
103
+ image_path=Path(""), make=meta.make, model=meta.model, year=meta.year,
104
+ body_type=meta.body_type, segment=infer_segment(meta.make),
105
+ confidence=1.0, source="user",
106
+ )
107
+
108
+
109
+ def evaluate_variant(
110
+ pipeline,
111
+ name: str,
112
+ records,
113
+ limit: Optional[int] = None,
114
+ ) -> VariantReport:
115
+ """Run a pipeline over the test split and accumulate everything we report on."""
116
+ import random
117
+ rng = random.Random(42)
118
+ sampler = _make_metadata_sampler()
119
+ catalog = load_active()
120
+
121
+ n_classes = len(DAMAGE_TYPES)
122
+ probs = []
123
+ labels = []
124
+ y_true_cost = []
125
+ y_pred_cost = []
126
+ tier_counts: dict[str, int] = {}
127
+ latencies: list[float] = []
128
+ examples: list[dict] = []
129
+ failures: list[dict] = []
130
+
131
+ for i, r in enumerate(records):
132
+ if limit and i >= limit:
133
+ break
134
+
135
+ meta, gt_cost = _ground_truth_cost(r, sampler, catalog, rng)
136
+ ident = _identification_for(meta)
137
+
138
+ t0 = time.time()
139
+ prediction = pipeline.predict(r.image_path, metadata=ident, currency="USD")
140
+ latencies.append((time.time() - t0) * 1000)
141
+
142
+ # classification — Variant A returns probabilities, Variant B doesn't
143
+ probs_row = [0.0] * n_classes
144
+ if hasattr(prediction, "probabilities") and prediction.probabilities:
145
+ for j, dt in enumerate(DAMAGE_TYPES):
146
+ probs_row[j] = float(prediction.probabilities.get(dt, 0.0))
147
+ else:
148
+ # For Variant B fall back to a 1.0 prob for any detected type
149
+ for j, dt in enumerate(DAMAGE_TYPES):
150
+ probs_row[j] = 1.0 if dt in prediction.damage_types else 0.0
151
+ probs.append(probs_row)
152
+ labels.append([1.0 if dt in r.damage_types else 0.0 for dt in DAMAGE_TYPES])
153
+
154
+ # regression
155
+ y_true_cost.append(gt_cost)
156
+ y_pred_cost.append(prediction.cost_usd)
157
+
158
+ # tier
159
+ tier_counts[prediction.tier] = tier_counts.get(prediction.tier, 0) + 1
160
+
161
+ # collect a handful of qualitative examples
162
+ if len(examples) < 10:
163
+ examples.append({
164
+ "image_id": r.image_id,
165
+ "image_path": str(r.image_path),
166
+ "predicted_types": prediction.damage_types,
167
+ "ground_truth_types": r.damage_types,
168
+ "predicted_cost": prediction.cost_usd,
169
+ "ground_truth_cost": gt_cost,
170
+ "tier": prediction.tier,
171
+ })
172
+
173
+ # build failure list (top absolute cost errors)
174
+ pairs = list(zip(y_true_cost, y_pred_cost, records[: len(y_true_cost)]))
175
+ pairs.sort(key=lambda p: abs(p[1] - p[0]), reverse=True)
176
+ for gt, pred, rec in pairs[:5]:
177
+ failures.append({
178
+ "image_id": rec.image_id,
179
+ "image_path": str(rec.image_path),
180
+ "predicted_cost": pred,
181
+ "ground_truth_cost": gt,
182
+ "abs_error": abs(pred - gt),
183
+ })
184
+
185
+ classification = per_class_prf(np.array(probs), np.array(labels), DAMAGE_TYPES)
186
+ regression = regression_metrics(y_true_cost, y_pred_cost)
187
+ latency = {
188
+ "mean": float(np.mean(latencies)) if latencies else 0.0,
189
+ "p50": _percentile(latencies, 50),
190
+ "p95": _percentile(latencies, 95),
191
+ }
192
+ return VariantReport(
193
+ name=name,
194
+ n_images=len(probs),
195
+ classification=classification,
196
+ regression=regression,
197
+ tier_distribution=tier_counts,
198
+ latency_ms=latency,
199
+ examples=examples,
200
+ failures=failures,
201
+ )
202
+
203
+
204
+ def _load_test_records(seed: int = 42, limit: Optional[int] = None):
205
+ records = [r for r in iter_cardd() if r.damage_types]
206
+ _, _, test = dd.split_records(records, fractions=(0.8, 0.1, 0.1), seed=seed)
207
+ if limit:
208
+ test = test[:limit]
209
+ return test
210
+
211
+
212
+ def _resolve_run_id(variant: str) -> str:
213
+ """Best-effort: read the production symlink to find which run id is live."""
214
+ target = production_target(variant)
215
+ if not target:
216
+ return "unknown"
217
+ try:
218
+ return target.resolve().parent.name
219
+ except OSError:
220
+ return "unknown"
221
+
222
+
223
+ def build_comparison(
224
+ variant_a_pipeline,
225
+ variant_b_pipeline=None,
226
+ limit: Optional[int] = None,
227
+ seed: int = 42,
228
+ ) -> Comparison:
229
+ """Build the full :class:`Comparison` payload.
230
+
231
+ Pass either both pipelines (full A vs B report) or only Variant A
232
+ (used when the YOLOv8 detector hasn't been promoted yet).
233
+ """
234
+ records = _load_test_records(seed=seed, limit=limit)
235
+ catalog = load_active()
236
+
237
+ report_a = evaluate_variant(variant_a_pipeline, "A", records, limit=limit)
238
+ report_b = None
239
+ if variant_b_pipeline is not None:
240
+ report_b = evaluate_variant(variant_b_pipeline, "B", records, limit=limit)
241
+
242
+ slices = _slice_analyses(report_a, report_b)
243
+
244
+ return Comparison(
245
+ generated_at=datetime.now(timezone.utc).isoformat(),
246
+ catalog_id=catalog.catalog_id,
247
+ test_split_size=len(records),
248
+ seed=seed,
249
+ variant_a=report_a,
250
+ variant_b=report_b,
251
+ model_versions={
252
+ "classifier": _resolve_run_id("classifier"),
253
+ "detector": _resolve_run_id("detector"),
254
+ "identifier": _resolve_run_id("identifier"),
255
+ "xgb_a": _resolve_run_id("xgb_a"),
256
+ "xgb_b": _resolve_run_id("xgb_b"),
257
+ },
258
+ slices=slices,
259
+ )
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Slice analyses
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ def _slice_analyses(a: VariantReport, b: Optional[VariantReport]) -> dict:
268
+ """A small table summarising RMSE/MAE by damage type."""
269
+ out: dict[str, Any] = {}
270
+ out["headline"] = {
271
+ "A": {
272
+ "macro_f1": a.classification["macro_f1"],
273
+ "rmse": a.regression["rmse"],
274
+ "r2": a.regression["r2"],
275
+ "mape_pct": a.regression["mape_pct"],
276
+ },
277
+ }
278
+ if b is not None:
279
+ out["headline"]["B"] = {
280
+ "macro_f1": b.classification["macro_f1"],
281
+ "rmse": b.regression["rmse"],
282
+ "r2": b.regression["r2"],
283
+ "mape_pct": b.regression["mape_pct"],
284
+ }
285
+ out["delta"] = {
286
+ "macro_f1": b.classification["macro_f1"] - a.classification["macro_f1"],
287
+ "rmse": b.regression["rmse"] - a.regression["rmse"],
288
+ "r2": b.regression["r2"] - a.regression["r2"],
289
+ "mape_pct": b.regression["mape_pct"] - a.regression["mape_pct"],
290
+ }
291
+ return out
src/ccdp/eval/metrics.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure metric functions used by the comparison report.
2
+
3
+ Two families:
4
+
5
+ * **Multi-label classification metrics** for the damage-type classifier
6
+ (per-class P/R/F1, macro/micro F1).
7
+ * **Regression metrics** for the XGBoost cost head (RMSE, MAE, MAPE, R²).
8
+
9
+ We deliberately avoid pulling in sklearn for these — the formulas are short,
10
+ the test suite covers them, and we already depend on numpy.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Sequence
16
+
17
+ import numpy as np
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Classification
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ def per_class_prf(
26
+ probs: np.ndarray,
27
+ labels: np.ndarray,
28
+ class_names: Sequence[str],
29
+ threshold: float = 0.5,
30
+ ) -> dict:
31
+ """Per-class precision/recall/F1 + macro/micro F1 from sigmoid probabilities.
32
+
33
+ ``probs`` and ``labels`` are both ``(N, C)`` arrays where ``N`` is the
34
+ number of images and ``C == len(class_names)``. The threshold turns
35
+ probabilities into hard predictions.
36
+ """
37
+ preds = (probs >= threshold).astype(np.float32)
38
+ tp = (preds * labels).sum(axis=0)
39
+ fp = (preds * (1 - labels)).sum(axis=0)
40
+ fn = ((1 - preds) * labels).sum(axis=0)
41
+ precision = np.where(tp + fp > 0, tp / np.maximum(tp + fp, 1e-9), 0.0)
42
+ recall = np.where(tp + fn > 0, tp / np.maximum(tp + fn, 1e-9), 0.0)
43
+ f1 = np.where(precision + recall > 0,
44
+ 2 * precision * recall / np.maximum(precision + recall, 1e-9),
45
+ 0.0)
46
+
47
+ per_class = {
48
+ class_names[i]: {
49
+ "precision": float(precision[i]),
50
+ "recall": float(recall[i]),
51
+ "f1": float(f1[i]),
52
+ "support": int(labels[:, i].sum()),
53
+ }
54
+ for i in range(len(class_names))
55
+ }
56
+ macro_f1 = float(f1.mean())
57
+ micro_tp, micro_fp, micro_fn = float(tp.sum()), float(fp.sum()), float(fn.sum())
58
+ micro_p = micro_tp / max(micro_tp + micro_fp, 1)
59
+ micro_r = micro_tp / max(micro_tp + micro_fn, 1)
60
+ micro_f1 = (
61
+ 2 * micro_p * micro_r / max(micro_p + micro_r, 1e-9)
62
+ if (micro_p + micro_r) > 0 else 0.0
63
+ )
64
+ return {
65
+ "per_class": per_class,
66
+ "macro_f1": macro_f1,
67
+ "micro_f1": float(micro_f1),
68
+ }
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Regression
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def regression_metrics(y_true, y_pred) -> dict:
77
+ """RMSE, MAE, MAPE (percent), R² on a 1-D vector of predictions."""
78
+ y_true = np.asarray(y_true, dtype=float)
79
+ y_pred = np.asarray(y_pred, dtype=float)
80
+ if len(y_true) == 0:
81
+ return {"rmse": 0.0, "mae": 0.0, "mape_pct": 0.0, "r2": 0.0, "n": 0}
82
+ diff = y_true - y_pred
83
+ rmse = float(np.sqrt(np.mean(diff ** 2)))
84
+ mae = float(np.mean(np.abs(diff)))
85
+ safe_true = np.where(y_true == 0, 1, y_true)
86
+ mape = float(np.mean(np.abs(diff / safe_true)) * 100)
87
+ ss_res = float(np.sum(diff ** 2))
88
+ ss_tot = float(np.sum((y_true - y_true.mean()) ** 2)) or 1.0
89
+ r2 = float(1.0 - ss_res / ss_tot)
90
+ return {"rmse": rmse, "mae": mae, "mape_pct": mape, "r2": r2, "n": int(len(y_true))}
src/ccdp/eval/report.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render a :class:`Comparison` to HTML and (when WeasyPrint is available) PDF.
2
+
3
+ The strategy is deliberately layered:
4
+
5
+ 1. We always produce an HTML report — no system dependencies, just Jinja2.
6
+ 2. We additionally produce a PDF when ``weasyprint`` is importable; if it's
7
+ not, we log a friendly note and skip the PDF. WeasyPrint needs ``pango``
8
+ and ``cairo`` system libraries that aren't trivially available on every
9
+ environment (e.g. plain macOS without Homebrew), so making PDF optional
10
+ keeps the HTML path usable everywhere.
11
+
12
+ The template lives at ``reports/templates/report.html.j2`` so designers can
13
+ tweak the report without touching Python.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ from ccdp.eval.comparison import Comparison
23
+
24
+ REPORTS_DIR = Path("reports")
25
+ TEMPLATES_DIR = REPORTS_DIR / "templates"
26
+
27
+
28
+ def _load_template():
29
+ """Lazy-import Jinja2 so module import stays cheap."""
30
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
31
+ env = Environment(
32
+ loader=FileSystemLoader(str(TEMPLATES_DIR)),
33
+ autoescape=select_autoescape(["html", "xml"]),
34
+ )
35
+ return env.get_template("report.html.j2")
36
+
37
+
38
+ def render_html(comparison: Comparison, out_path: Optional[Path] = None) -> Path:
39
+ """Write the HTML version of the report; always works."""
40
+ REPORTS_DIR.mkdir(parents=True, exist_ok=True)
41
+ tmpl = _load_template()
42
+ html = tmpl.render(comparison=comparison, generated_at=datetime.now(timezone.utc))
43
+ out_path = out_path or REPORTS_DIR / f"report_{_timestamp()}.html"
44
+ out_path.write_text(html, encoding="utf-8")
45
+ return out_path
46
+
47
+
48
+ def render_pdf(html_path: Path, pdf_path: Optional[Path] = None) -> Optional[Path]:
49
+ """Convert an existing HTML report to PDF via WeasyPrint, if available.
50
+
51
+ Returns ``None`` (and prints a hint) when WeasyPrint is not installed.
52
+ """
53
+ try:
54
+ from weasyprint import HTML # type: ignore
55
+ except ImportError:
56
+ print("[report] WeasyPrint not installed; skipping PDF. "
57
+ "Install with `pip install weasyprint` and the system deps "
58
+ "(`pango`, `cairo`, `gdk-pixbuf`) for PDF output.")
59
+ return None
60
+ pdf_path = pdf_path or html_path.with_suffix(".pdf")
61
+ HTML(filename=str(html_path)).write_pdf(str(pdf_path))
62
+ return pdf_path
63
+
64
+
65
+ def generate(comparison: Comparison, also_pdf: bool = True) -> dict:
66
+ """Single-call convenience: render HTML, then PDF if possible. Returns paths."""
67
+ html_path = render_html(comparison)
68
+ pdf_path = render_pdf(html_path) if also_pdf else None
69
+ return {"html": html_path, "pdf": pdf_path}
70
+
71
+
72
+ def _timestamp() -> str:
73
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
src/ccdp/infer/variant_a.py CHANGED
@@ -76,7 +76,7 @@ class VariantAPipeline(BaseVariantPipeline):
76
 
77
  def predict(
78
  self,
79
- image_path: str | Path,
80
  metadata: Optional[IdentificationResult] = None,
81
  threshold: float = 0.5,
82
  currency: str = "USD",
@@ -84,6 +84,9 @@ class VariantAPipeline(BaseVariantPipeline):
84
  ) -> PredictionA:
85
  """Run end-to-end inference on a single image.
86
 
 
 
 
87
  Steps:
88
  1. Forward the image through the classifier; threshold sigmoid probs.
89
  2. Forward through the backbone-only path to extract 2048-d features.
@@ -93,7 +96,7 @@ class VariantAPipeline(BaseVariantPipeline):
93
  """
94
  catalog = catalog or load_active()
95
 
96
- damage_types, probabilities, image_features = self._forward(image_path)
97
  parts = self._infer_parts(damage_types)
98
 
99
  if self._can_use_xgb(metadata):
@@ -125,9 +128,15 @@ class VariantAPipeline(BaseVariantPipeline):
125
 
126
  # -- internals --------------------------------------------------------
127
 
128
- def _forward(self, image_path: str | Path):
129
- """Single forward pass; returns (damage_types, prob_dict, 2048-d features)."""
130
- img = Image.open(image_path).convert("RGB")
 
 
 
 
 
 
131
  x = self.transform(img).unsqueeze(0).to(self.device)
132
  with torch.no_grad():
133
  logits = self.classifier(x)
 
76
 
77
  def predict(
78
  self,
79
+ image,
80
  metadata: Optional[IdentificationResult] = None,
81
  threshold: float = 0.5,
82
  currency: str = "USD",
 
84
  ) -> PredictionA:
85
  """Run end-to-end inference on a single image.
86
 
87
+ ``image`` accepts a path-like (``str`` / ``Path``) or an already-opened
88
+ ``PIL.Image`` — useful from the API where the bytes are in memory.
89
+
90
  Steps:
91
  1. Forward the image through the classifier; threshold sigmoid probs.
92
  2. Forward through the backbone-only path to extract 2048-d features.
 
96
  """
97
  catalog = catalog or load_active()
98
 
99
+ damage_types, probabilities, image_features = self._forward(image)
100
  parts = self._infer_parts(damage_types)
101
 
102
  if self._can_use_xgb(metadata):
 
128
 
129
  # -- internals --------------------------------------------------------
130
 
131
+ def _forward(self, image):
132
+ """Single forward pass; returns (damage_types, prob_dict, 2048-d features).
133
+
134
+ ``image`` may be a path-like *or* an already-opened PIL.Image.
135
+ """
136
+ if isinstance(image, Image.Image):
137
+ img = image.convert("RGB")
138
+ else:
139
+ img = Image.open(image).convert("RGB")
140
  x = self.transform(img).unsqueeze(0).to(self.device)
141
  with torch.no_grad():
142
  logits = self.classifier(x)
src/ccdp/infer/variant_b.py CHANGED
@@ -106,19 +106,22 @@ class VariantBPipeline(BaseVariantPipeline):
106
 
107
  def predict(
108
  self,
109
- image_path: str | Path,
110
  metadata: Optional[IdentificationResult] = None,
111
  currency: str = "USD",
112
  catalog: Optional[Catalog] = None,
113
  ) -> PredictionB:
114
- """Detect damages, score them, return calibrated cost + provenance."""
 
 
 
115
  catalog = catalog or load_active()
116
 
117
- detections, stats = self._detect(image_path, metadata)
118
  damage_types = sorted({d.damage_type for d in detections})
119
  parts = sorted({d.part for d in detections if d.part})
120
 
121
- image_features = self._image_features(image_path)
122
 
123
  if self._can_use_xgb(metadata):
124
  cost_usd, tier, provenance = self._predict_via_xgb(
@@ -152,12 +155,17 @@ class VariantBPipeline(BaseVariantPipeline):
152
 
153
  def _detect(
154
  self,
155
- image_path: str | Path,
156
  metadata: Optional[IdentificationResult],
157
  ) -> tuple[list[DetectedBox], dict]:
158
- """Run YOLOv8 and convert raw boxes into ``DetectedBox`` + per-image stats."""
 
 
 
 
 
159
  result = self.detector.predict(
160
- str(image_path), imgsz=self.imgsz, conf=self.conf, verbose=False,
161
  )[0]
162
  h, w = result.orig_shape
163
  location_hint = metadata.body_type if metadata else "unknown"
@@ -190,9 +198,12 @@ class VariantBPipeline(BaseVariantPipeline):
190
  ))
191
  return detections, bbox_stats(bboxes_for_stats)
192
 
193
- def _image_features(self, image_path: str | Path):
194
  """2048-d backbone features shared with Variant A's XGBoost feature schema."""
195
- img = Image.open(image_path).convert("RGB")
 
 
 
196
  x = self.transform(img).unsqueeze(0).to(self.device)
197
  with torch.no_grad():
198
  return extract_features(self.classifier, x).cpu().numpy().flatten()
 
106
 
107
  def predict(
108
  self,
109
+ image,
110
  metadata: Optional[IdentificationResult] = None,
111
  currency: str = "USD",
112
  catalog: Optional[Catalog] = None,
113
  ) -> PredictionB:
114
+ """Detect damages, score them, return calibrated cost + provenance.
115
+
116
+ ``image`` accepts a path-like or an already-opened ``PIL.Image``.
117
+ """
118
  catalog = catalog or load_active()
119
 
120
+ detections, stats = self._detect(image, metadata)
121
  damage_types = sorted({d.damage_type for d in detections})
122
  parts = sorted({d.part for d in detections if d.part})
123
 
124
+ image_features = self._image_features(image)
125
 
126
  if self._can_use_xgb(metadata):
127
  cost_usd, tier, provenance = self._predict_via_xgb(
 
155
 
156
  def _detect(
157
  self,
158
+ image,
159
  metadata: Optional[IdentificationResult],
160
  ) -> tuple[list[DetectedBox], dict]:
161
+ """Run YOLOv8 and convert raw boxes into ``DetectedBox`` + per-image stats.
162
+
163
+ Ultralytics accepts paths, PIL.Image, numpy arrays, or tensors — we
164
+ just pass through whatever the caller gave us.
165
+ """
166
+ source = str(image) if isinstance(image, (str, Path)) else image
167
  result = self.detector.predict(
168
+ source, imgsz=self.imgsz, conf=self.conf, verbose=False,
169
  )[0]
170
  h, w = result.orig_shape
171
  location_hint = metadata.body_type if metadata else "unknown"
 
198
  ))
199
  return detections, bbox_stats(bboxes_for_stats)
200
 
201
+ def _image_features(self, image):
202
  """2048-d backbone features shared with Variant A's XGBoost feature schema."""
203
+ if isinstance(image, Image.Image):
204
+ img = image.convert("RGB")
205
+ else:
206
+ img = Image.open(image).convert("RGB")
207
  x = self.transform(img).unsqueeze(0).to(self.device)
208
  with torch.no_grad():
209
  return extract_features(self.classifier, x).cpu().numpy().flatten()
src/ccdp/preprocess/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image pre-processing pipeline (Stage A: deterministic downscale + quality report).
2
+
3
+ Stage B (super-resolution via Real-ESRGAN) is deferred to a later checkpoint —
4
+ see `progress/phase_3_comparison_and_serving.md`.
5
+
6
+ Public API:
7
+ quality_report(img) -> dict of size + sharpness/brightness/contrast
8
+ normalize_for_inference(img, max_long_edge=1600) -> resized PIL.Image
9
+ preprocess(img_bytes, ...) -> (PIL.Image, dict) ready for the variant pipelines
10
+ """
11
+
12
+ from .pipeline import normalize_for_inference, preprocess, quality_report
13
+
14
+ __all__ = ["normalize_for_inference", "preprocess", "quality_report"]
src/ccdp/preprocess/pipeline.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage A pre-processing — pure, deterministic, no ML.
2
+
3
+ The two responsibilities of this module:
4
+
5
+ 1. **Downscale large uploads** so we don't burn memory and disk on 12-megapixel
6
+ phone photos before the model resizes them anyway. We use LANCZOS resampling
7
+ because edges (dent / crack contours) are exactly what the models depend on.
8
+
9
+ 2. **Score image quality** so the API response can carry a diagnostic for each
10
+ prediction — useful for explaining why a low-confidence result might be due
11
+ to a blurry / dark upload rather than a model failure.
12
+
13
+ The output is a ``PIL.Image`` ready for the existing :class:`VariantAPipeline`
14
+ and :class:`VariantBPipeline` to consume, plus a JSON-serialisable dict that
15
+ goes back to the caller in the ``preprocessing`` field of the response.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import io
21
+ from typing import Any
22
+
23
+ import numpy as np
24
+ from PIL import Image, ImageStat
25
+
26
+ DEFAULT_MAX_LONG_EDGE = 1600 # bigger than YOLOv8's 640 input; preserves edge detail
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Quality scoring
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def _sharpness_score(img: Image.Image) -> float:
35
+ """Variance of Laplacian — higher means sharper.
36
+
37
+ Standard photographic blur metric. Values around 30 indicate a blurry
38
+ image; values above 150 are well-focused. This is computed on a downscaled
39
+ greyscale copy so the score doesn't blow up linearly with resolution.
40
+ """
41
+ small = img.convert("L").resize((256, 256))
42
+ arr = np.asarray(small, dtype=np.float32)
43
+ # 3x3 Laplacian kernel applied via numpy (avoids OpenCV dep)
44
+ kernel = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32)
45
+ h, w = arr.shape
46
+ out = np.zeros_like(arr)
47
+ out[1:-1, 1:-1] = (
48
+ arr[:-2, 1:-1] + arr[2:, 1:-1] + arr[1:-1, :-2] + arr[1:-1, 2:]
49
+ - 4 * arr[1:-1, 1:-1]
50
+ )
51
+ return float(out.var())
52
+
53
+
54
+ def quality_report(img: Image.Image) -> dict[str, Any]:
55
+ """Return a JSON-friendly dict describing the input image's properties."""
56
+ w, h = img.size
57
+ stat = ImageStat.Stat(img.convert("L"))
58
+ brightness = float(stat.mean[0])
59
+ contrast = float(stat.stddev[0])
60
+ sharpness = _sharpness_score(img)
61
+ return {
62
+ "width": w,
63
+ "height": h,
64
+ "megapixels": round(w * h / 1_000_000, 3),
65
+ "long_edge": max(w, h),
66
+ "short_edge": min(w, h),
67
+ "sharpness": round(sharpness, 2),
68
+ "brightness": round(brightness, 2),
69
+ "contrast": round(contrast, 2),
70
+ "is_blurry": sharpness < 60,
71
+ "is_low_light": brightness < 50,
72
+ "is_low_resolution": max(w, h) < 512,
73
+ }
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Resize
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def normalize_for_inference(
82
+ img: Image.Image,
83
+ max_long_edge: int = DEFAULT_MAX_LONG_EDGE,
84
+ ) -> Image.Image:
85
+ """Downscale ``img`` so its longer edge fits in ``max_long_edge``.
86
+
87
+ No-op when the image is already small enough. We never upscale here —
88
+ that's Stage B's job (deferred).
89
+ """
90
+ if img.mode != "RGB":
91
+ img = img.convert("RGB")
92
+ w, h = img.size
93
+ long_edge = max(w, h)
94
+ if long_edge <= max_long_edge:
95
+ return img
96
+ scale = max_long_edge / long_edge
97
+ new_size = (int(round(w * scale)), int(round(h * scale)))
98
+ return img.resize(new_size, resample=Image.LANCZOS)
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Top-level entry point used by the API and the CLI
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ def preprocess(
107
+ image_or_bytes,
108
+ max_long_edge: int = DEFAULT_MAX_LONG_EDGE,
109
+ ) -> tuple[Image.Image, dict[str, Any]]:
110
+ """Open + quality-score + downscale.
111
+
112
+ Accepts either a path-like, raw bytes, a file-like object, or an already-
113
+ opened ``PIL.Image`` — whichever is most convenient for the caller.
114
+
115
+ Returns ``(image_ready_for_model, preprocessing_metadata)``. The metadata
116
+ dict is what the API surface returns in its ``preprocessing`` field so the
117
+ user can see what was done to their upload.
118
+ """
119
+ if isinstance(image_or_bytes, Image.Image):
120
+ img = image_or_bytes
121
+ elif isinstance(image_or_bytes, (bytes, bytearray)):
122
+ img = Image.open(io.BytesIO(image_or_bytes))
123
+ else:
124
+ img = Image.open(image_or_bytes)
125
+
126
+ qr_before = quality_report(img)
127
+ resized = normalize_for_inference(img, max_long_edge=max_long_edge)
128
+ downscaled = resized.size != img.size
129
+
130
+ metadata: dict[str, Any] = {
131
+ "input_size": [img.size[0], img.size[1]],
132
+ "input_quality": qr_before,
133
+ "downscaled": downscaled,
134
+ "max_long_edge": max_long_edge,
135
+ # Stage B placeholder — always false until super-resolution lands
136
+ "super_resolved": False,
137
+ }
138
+ if downscaled:
139
+ metadata["resized_to"] = [resized.size[0], resized.size[1]]
140
+ return resized, metadata
tests/test_eval_metrics.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the pure metric functions used by the comparison report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from ccdp.eval.metrics import per_class_prf, regression_metrics
8
+
9
+
10
+ def test_per_class_prf_perfect_predictions():
11
+ classes = ["a", "b", "c"]
12
+ labels = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=float)
13
+ probs = labels.copy() # perfect
14
+ m = per_class_prf(probs, labels, classes)
15
+ assert m["macro_f1"] == 1.0
16
+ assert m["micro_f1"] == 1.0
17
+ for c in classes:
18
+ assert m["per_class"][c]["precision"] == 1.0
19
+ assert m["per_class"][c]["recall"] == 1.0
20
+
21
+
22
+ def test_per_class_prf_handles_no_predictions():
23
+ classes = ["a", "b"]
24
+ labels = np.array([[1, 0], [0, 1]], dtype=float)
25
+ probs = np.zeros_like(labels)
26
+ m = per_class_prf(probs, labels, classes)
27
+ assert m["macro_f1"] == 0.0
28
+ assert m["per_class"]["a"]["recall"] == 0.0
29
+
30
+
31
+ def test_per_class_prf_support_counts_match_labels():
32
+ classes = ["a", "b"]
33
+ labels = np.array([[1, 0], [1, 1], [0, 1]], dtype=float)
34
+ probs = np.zeros_like(labels)
35
+ m = per_class_prf(probs, labels, classes)
36
+ assert m["per_class"]["a"]["support"] == 2
37
+ assert m["per_class"]["b"]["support"] == 2
38
+
39
+
40
+ def test_regression_metrics_perfect_zero_error():
41
+ y_true = [100, 200, 300]
42
+ y_pred = [100, 200, 300]
43
+ m = regression_metrics(y_true, y_pred)
44
+ assert m["rmse"] == 0.0
45
+ assert m["mae"] == 0.0
46
+ assert m["mape_pct"] == 0.0
47
+ assert m["r2"] == 1.0
48
+ assert m["n"] == 3
49
+
50
+
51
+ def test_regression_metrics_known_values():
52
+ y_true = [100, 200]
53
+ y_pred = [110, 180] # errors: +10, -20
54
+ m = regression_metrics(y_true, y_pred)
55
+ # MAE = (10+20)/2 = 15
56
+ assert abs(m["mae"] - 15.0) < 1e-6
57
+ # RMSE = sqrt((100+400)/2) = sqrt(250) ≈ 15.81
58
+ assert abs(m["rmse"] - np.sqrt(250)) < 1e-6
59
+ # MAPE = ((10/100) + (20/200))/2 * 100 = 10.0
60
+ assert abs(m["mape_pct"] - 10.0) < 1e-6
61
+
62
+
63
+ def test_regression_metrics_empty_input_safe():
64
+ m = regression_metrics([], [])
65
+ assert m["n"] == 0
66
+ assert m["rmse"] == 0.0
tests/test_preprocess.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the Stage A pre-processing pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+
7
+ from PIL import Image
8
+
9
+ from ccdp.preprocess import normalize_for_inference, preprocess, quality_report
10
+
11
+
12
+ def _mk_image(w: int, h: int, color=(128, 128, 128)) -> Image.Image:
13
+ return Image.new("RGB", (w, h), color=color)
14
+
15
+
16
+ def test_quality_report_basic_fields():
17
+ img = _mk_image(800, 600)
18
+ qr = quality_report(img)
19
+ assert qr["width"] == 800 and qr["height"] == 600
20
+ assert qr["long_edge"] == 800
21
+ assert qr["short_edge"] == 600
22
+ assert qr["megapixels"] == 0.48
23
+ assert "sharpness" in qr and qr["sharpness"] >= 0
24
+ assert "brightness" in qr and 0 <= qr["brightness"] <= 255
25
+
26
+
27
+ def test_quality_report_flags_low_resolution():
28
+ qr = quality_report(_mk_image(400, 300))
29
+ assert qr["is_low_resolution"] is True
30
+
31
+
32
+ def test_normalize_no_op_for_small_image():
33
+ img = _mk_image(800, 600)
34
+ out = normalize_for_inference(img, max_long_edge=1600)
35
+ assert out.size == (800, 600)
36
+
37
+
38
+ def test_normalize_downscales_large_image_preserving_aspect_ratio():
39
+ img = _mk_image(3200, 2400)
40
+ out = normalize_for_inference(img, max_long_edge=1600)
41
+ assert max(out.size) == 1600
42
+ # aspect-ratio preserved within rounding
43
+ ratio_in = 3200 / 2400
44
+ ratio_out = out.size[0] / out.size[1]
45
+ assert abs(ratio_in - ratio_out) < 1e-3
46
+
47
+
48
+ def test_preprocess_accepts_bytes_pillow_and_path(tmp_path):
49
+ img = _mk_image(2000, 1500)
50
+
51
+ # Pillow path
52
+ out, meta = preprocess(img)
53
+ assert meta["downscaled"] is True
54
+ assert meta["input_size"] == [2000, 1500]
55
+ assert meta["resized_to"][0] <= 1600
56
+ assert meta["super_resolved"] is False
57
+
58
+ # bytes path
59
+ buf = io.BytesIO()
60
+ img.save(buf, format="PNG")
61
+ out2, meta2 = preprocess(buf.getvalue())
62
+ assert meta2["downscaled"] is True
63
+
64
+ # path path
65
+ p = tmp_path / "img.png"
66
+ img.save(p)
67
+ out3, meta3 = preprocess(p)
68
+ assert meta3["downscaled"] is True
69
+
70
+
71
+ def test_preprocess_skips_downscale_for_already_small_image():
72
+ img = _mk_image(800, 600)
73
+ out, meta = preprocess(img)
74
+ assert meta["downscaled"] is False
75
+ assert out.size == (800, 600)