Spaces:
Runtime error
checkpoint-3: comparison report, FastAPI, Gradio, HF Space deploy
Browse filesPhase 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 +77 -0
- README.md +157 -0
- app.py +80 -0
- packages.txt +3 -0
- progress/STATUS.md +2 -2
- progress/phase_3_comparison_and_serving.md +76 -38
- requirements.txt +29 -0
- src/ccdp/api/demo.py +263 -0
- src/ccdp/api/schemas.py +47 -0
- src/ccdp/api/server.py +234 -0
- src/ccdp/cli.py +72 -0
- src/ccdp/eval/__init__.py +19 -2
- src/ccdp/eval/comparison.py +291 -0
- src/ccdp/eval/metrics.py +90 -0
- src/ccdp/eval/report.py +73 -0
- src/ccdp/infer/variant_a.py +14 -5
- src/ccdp/infer/variant_b.py +20 -9
- src/ccdp/preprocess/__init__.py +14 -0
- src/ccdp/preprocess/pipeline.py +140 -0
- tests/test_eval_metrics.py +66 -0
- tests/test_preprocess.py +75 -0
|
@@ -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}"
|
|
@@ -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)
|
|
@@ -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()
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
curl
|
| 2 |
+
libgl1
|
| 3 |
+
libglib2.0-0
|
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Project Status — Car Crash Fix Amount Predictor
|
| 2 |
|
| 3 |
-
**Last updated:** 2026-05-
|
| 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
|
| 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
|
|
@@ -1,54 +1,92 @@
|
|
| 1 |
-
# Phase 3 — Comparison report + FastAPI + Gradio
|
| 2 |
|
| 3 |
-
**Status:**
|
| 4 |
-
**
|
| 5 |
-
**Depends on:** Phases 2A and 2B.
|
| 6 |
|
| 7 |
## Goal
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
## Deliverables
|
| 12 |
|
| 13 |
-
-
|
| 14 |
-
-
|
| 15 |
-
-
|
| 16 |
-
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
-
|
| 23 |
-
-
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
##
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
##
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 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 |
-
#
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
|
| 47 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
ccdp
|
| 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¤cy=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.
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -1,4 +1,21 @@
|
|
| 1 |
-
"""ccdp.eval —
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
]
|
|
@@ -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
|
|
@@ -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))}
|
|
@@ -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")
|
|
@@ -76,7 +76,7 @@ class VariantAPipeline(BaseVariantPipeline):
|
|
| 76 |
|
| 77 |
def predict(
|
| 78 |
self,
|
| 79 |
-
|
| 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(
|
| 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,
|
| 129 |
-
"""Single forward pass; returns (damage_types, prob_dict, 2048-d features).
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -106,19 +106,22 @@ class VariantBPipeline(BaseVariantPipeline):
|
|
| 106 |
|
| 107 |
def predict(
|
| 108 |
self,
|
| 109 |
-
|
| 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(
|
| 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(
|
| 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 |
-
|
| 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 |
-
|
| 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,
|
| 194 |
"""2048-d backbone features shared with Variant A's XGBoost feature schema."""
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
@@ -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"]
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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)
|