An end-to-end full-stack computer vision application designed to evaluate visual image quality, detect degradation artifacts (blur, over/underexposure, noise, spatial corruption), and produce explainable quality assessments with continuous scoring (
A pure deep learning approach lacks deterministic explainability on basic statistical degradations, while a purely classical CV rule engine fails to generalize to complex spatial defects. This system implements a Hybrid Fusion Architecture:
-
Deterministic CV Feature Extractor:
-
Sharpness: Variance of the Laplacian kernel (
$\sigma^2_{\nabla^2}$ ) detecting high-frequency spatial gradients. -
Exposure: Mean pixel intensity (
$\mu_I$ ) combined with extreme clipping ratios ($I \le 5$ and$I \ge 250$ ). -
Noise Estimation: Mean absolute residual between original grayscale image and a
$3\times3$ spatial median filter. - Contrast: Root Mean Square (RMS) contrast across intensity distribution.
-
Sharpness: Variance of the Laplacian kernel (
-
Deep Learning Feature Extractor:
- Pretrained MobileNetV2 backbone extracting 1,280-dimensional spatial visual embeddings.
-
Multi-Modal Feature Fusion:
- Handcrafted CV metrics are standard-scaled (
StandardScaler) and passed through a dedicated projection layer (Linear(6, 32)$\to$ BatchNorm$\to$ ReLU). - Classical embeddings and deep visual embeddings are concatenated (
1280 + 32 = 1312) into a dense multi-label classification head trained with Binary Cross-Entropy with Logits (BCEWithLogitsLoss).
- Handcrafted CV metrics are standard-scaled (
To ensure strict evaluation rigor, synthetic degradations were generated following a zero-leakage protocol:
- Source Partitioning: 100 clean high-resolution baseline images from DIV2K were partitioned at the source image level (70% Train / 15% Validation / 15% Test) prior to synthetic degradation. The test split contains zero underlying scene content from the training set.
-
Degradation Generation:
-
Blur: Gaussian smoothing filters with varying kernel sizes ($k \in [3, 15]$ ). -
Over/Underexposure: Linear scale factors ($[0.2, 2.5]$ ) with pixel intensity clipping. -
Noise: Additive zero-mean Gaussian noise ($\sigma \in [10, 50]$ ). -
Corruption: Spatial block-region replacement simulating localized packet loss and sensor dropout.
-
- Target Distribution: 1,050 training samples, 225 validation samples, and 225 test samples across clean (25%), single-defect (45%), and multi-defect (30%) permutations.
The table below reflects inference at the same confidence threshold (> 0.40) used by the live /analyze endpoint in main.py, not the conventional 0.50 default — this is the honest representation of production behavior rather than an idealized number.
precision recall f1-score support
blur 0.84 0.98 0.91 54
overexposure 0.75 0.80 0.77 51
underexposure 0.73 0.93 0.82 58
noise 0.78 0.93 0.85 42
corruption 1.00 0.96 0.98 48
micro avg 0.81 0.92 0.86 253
macro avg 0.82 0.92 0.86 253
weighted avg 0.82 0.92 0.86 253
samples avg 0.69 0.72 0.69 253
Note: a secondary evaluation using the conventional 0.50 threshold is included as artifacts/evaluation_report.txt (weighted F1 0.87) — we report at 0.40 above since that is the threshold the deployed API actually uses. Lowering the decision threshold trades a small amount of precision for higher recall, which we judged the better trade-off for a quality-flagging tool where missing a real defect is costlier than an extra false positive.
weighted avg(0.86) vs.samples avg(0.69): strong per-class F1 shows each defect head is well-calibrated in isolation, butsamples avgrequires an exact label-set match per image. Multi-defect images (2–3 simultaneous issues) are harder to get exactly right than single-defect images, since one borderline confidence on a secondary defect breaks the full-match score even when the primary defect is identified correctly.- Precision/recall trade visible per class: recall is consistently higher than precision across most classes at this threshold (e.g. underexposure: 0.73 precision / 0.93 recall), confirming the model is tuned toward catching more true defects at the cost of some false positives — consistent with the threshold choice above.
| Filename | Ground Truth | Predicted | Confidence (all classes) | Root Cause |
|---|---|---|---|---|
test_00005.jpg |
blur, overexposure, underexposure | blur, underexposure | blur 1.00, overexposure 0.16, underexposure 0.95, noise 0.00, corruption 0.01 | Overexposure confidence (0.16) fell well below the 0.40 threshold despite being a true label — likely because the co-occurring severe underexposure/blur dominated the visual signal the CNN branch attended to. |
test_00006.jpg |
clean | underexposure, noise | blur 0.00, overexposure 0.04, underexposure 0.62, noise 0.50, corruption 0.02 | False positive on a nominally clean image; CV stats show a high noise residual (14.23) despite no synthetic noise applied — natural DIV2K image texture in low/mid-frequency detail was likely misread as noise by both the CV feature and the CNN branch. |
test_00016.jpg |
clean | underexposure | blur 0.00, overexposure 0.28, underexposure 0.96, noise 0.00, corruption 0.00 | False positive at high confidence (0.96) on a clean image with mean_brightness = 48.21 — a naturally dark scene (not synthetically underexposed) was indistinguishable from true underexposure using brightness-based features alone. |
test_00018.jpg |
overexposure, corruption | overexposure | blur 0.00, overexposure 0.99, underexposure 0.06, noise 0.00, corruption 0.09 | Corruption confidence (0.09) missed the true label; co-occurring severe overexposure (clipping 13% of pixels) likely washed out the localized block artifact the corruption detector relies on. |
- Naturally dark/textured scenes are conflated with synthetic defects.
test_00006.jpgandtest_00016.jpgshow the model's most consistent failure mode: images that are naturally dark or low-contrast (not synthetically degraded) get flagged as underexposed or noisy. This is a direct consequence of training only on synthetically clean-vs-degraded pairs — the model has not learned to distinguish "naturally moody photography" from "underexposure defect," which real-world deployment would need to address with a broader, more stylistically diverse clean-image set. - Co-occurring defects can mask each other. In 2 of the 4 sampled failures, a dominant defect (severe blur, severe overexposure) suppressed the model's confidence on a secondary, true co-occurring defect. This is a known limitation of independent per-class sigmoid heads on a shared backbone and is a candidate for future work (e.g. defect-specific attention, or a curriculum that oversamples multi-defect combinations).
- Uniform synthetic degradations vs. real optical flaws. Synthetic Gaussian blur/noise apply uniformly across the frame, while real-world defects (motion streaks, radial lens distortion, sensor-specific chroma noise) are spatially non-uniform. Real deployment accuracy is expected to be lower than these test numbers until validated against genuinely defective camera images.
- Limited scene diversity in the test split. The 70/15/15 split is applied at the source-image level across only 100 DIV2K images, meaning the test set draws from 15 unique underlying scenes. This is sufficient to guarantee zero train/test content leakage, but is a small diversity pool — real-world generalization claims should be treated as preliminary pending evaluation on a larger, independent image set.
- Docker Desktop (with Docker Compose)
- ~9 GB free disk space (base Python image + PyTorch/torchvision layers)
# From the project root
docker compose --progress=plain build
docker compose up -dThe first build pulls a python:3.13-slim base image and installs pinned dependencies (scikit-learn==1.9.0, torch==2.13.0, torchvision==0.28.0) — expect several minutes on first run. Subsequent builds are cached and much faster.
The container exposes the app on port 8000. Once running:
- Web UI:
http://localhost:8000 - Health check:
http://localhost:8000/health
To stop the stack:
docker compose downpython -m venv venv
venv\Scripts\Activate.ps1 # Windows PowerShell
# source venv/bin/activate # macOS/Linux
pip install -r requirements.txt
uvicorn main:app --host 0.0.0.0 --port 8000SQLite is used for persistence (artifacts/results.db), created automatically on first run via SQLAlchemy — no manual setup required. The artifacts/ folder is bind-mounted into the container (see docker-compose.yml) and must contain best_model.pth and scaler.pkl on the host before docker compose up, or the container's startup validation will fail loudly rather than serve a broken model.
The following was run and confirmed working end-to-end against the live Docker container prior to submission:
$ curl.exe http://localhost:8000/health
{"status":"healthy","device":"cpu","model_loaded":true,"scaler_loaded":true}$ curl.exe -F "file=@sample.png" http://localhost:8000/analyze
{"id":7,"filename":"49229bfc_0840.png","quality_score":80,"quality_label":"ACCEPTABLE",
"issues":[{"type":"underexposure","severity":"high","confidence":0.98}],
"metrics":{"sharpness_laplacian":660.15,"mean_brightness":71.59,
"underexposed_clip_ratio":0.0052,"overexposed_clip_ratio":0.0,
"noise_residual":2.93,"rms_contrast":45.58},
"timestamp":"2026-08-29T15:19:34.215558"}$ curl.exe "http://localhost:8000/gradcam/7?target_class=underexposure" --output heatmap.png
# 1.23 MB PNG heatmap returned and visually confirmedBase URL (local): http://localhost:8000
Health/status check. Confirms the service is up and the model + scaler artifacts loaded successfully.
Response
{"status": "healthy", "device": "cpu", "model_loaded": true, "scaler_loaded": true}Uploads an image and runs the full hybrid CV + deep learning quality analysis.
Request — multipart/form-data, field name file
curl -F "file=@your_image.jpg" http://localhost:8000/analyzeResponse
{
"id": 7,
"filename": "49229bfc_0840.png",
"quality_score": 80,
"quality_label": "ACCEPTABLE",
"issues": [
{"type": "underexposure", "severity": "high", "confidence": 0.98}
],
"metrics": {
"sharpness_laplacian": 660.15,
"mean_brightness": 71.59,
"underexposed_clip_ratio": 0.0052,
"overexposed_clip_ratio": 0.0,
"noise_residual": 2.93,
"rms_contrast": 45.58
},
"timestamp": "2026-08-29T15:19:34.215558"
}Invalid or unreadable files return a 422 with a structured Pydantic validation error rather than a server crash.
Returns the full history of past analyses.
curl http://localhost:8000/resultsReturns a single stored analysis by its ID.
curl http://localhost:8000/results/7Returns a Grad-CAM heatmap PNG explaining the model's attention for a specific detected defect class on a previously analyzed image. target_class must be one of: blur, overexposure, underexposure, noise, corruption.
curl "http://localhost:8000/gradcam/7?target_class=underexposure" --output heatmap.pngRequesting an invalid class returns a 422 explaining the accepted values instead of failing silently.