Ritvik Shrivastava commited on
Commit Β·
41c0a9e
1
Parent(s): 37df0df
feat: initial GharScan deployment
Browse files- README.md +99 -7
- agent_trace.py +134 -0
- app.py +142 -0
- cost_matrix.py +213 -0
- inference.py +276 -0
- requirements.txt +32 -0
- static/app.js +240 -0
- static/index.html +239 -0
- static/styles.css +480 -0
README.md
CHANGED
|
@@ -1,14 +1,106 @@
|
|
| 1 |
---
|
| 2 |
title: GharScan
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: gray
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
-
pinned:
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: GharScan
|
| 3 |
+
emoji: ποΈ
|
| 4 |
colorFrom: gray
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: "6.14.0"
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
pinned: true
|
| 10 |
+
license: mit
|
| 11 |
+
tags:
|
| 12 |
+
- computer-vision
|
| 13 |
+
- building-inspection
|
| 14 |
+
- defect-detection
|
| 15 |
+
- india
|
| 16 |
+
- minicpm-v
|
| 17 |
+
- openbmb
|
| 18 |
+
- gradio
|
| 19 |
+
- backyard-ai
|
| 20 |
+
- build-small-hackathon
|
| 21 |
+
models:
|
| 22 |
+
- ritvik412/gharscan-minicpm-v2-lora
|
| 23 |
+
datasets:
|
| 24 |
+
- ritvik412/gharscan-defect-dataset
|
| 25 |
---
|
| 26 |
|
| 27 |
+
# ποΈ GharScan β AI Building Defect Inspector for India
|
| 28 |
+
|
| 29 |
+
**Track:** Backyard AI Β· Build Small Hackathon 2026
|
| 30 |
+
|
| 31 |
+
> *"My neighbor Aunty Puja, a retired teacher in a 1982 DDA flat in Delhi,
|
| 32 |
+
> had three masons give her three different quotes for a crack she didn't
|
| 33 |
+
> understand. GharScan told her in 12 seconds: settlement crack, Severity 2/5,
|
| 34 |
+
> not structural, seal before monsoon β βΉ400β600, local mason."*
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## What It Does
|
| 39 |
+
|
| 40 |
+
Point your phone camera at any defect in your home β a crack, a damp patch,
|
| 41 |
+
rust stains, salt deposits β and get an instant expert-grade triage report:
|
| 42 |
+
|
| 43 |
+
- **Defect type** from an 8-class Indian residential taxonomy
|
| 44 |
+
- **Severity score** (1β5, color-coded) with structural risk flag
|
| 45 |
+
- **Immediate action** in plain language (English or Hindi)
|
| 46 |
+
- **Cost estimate in INR** from a 2026 Delhi/NCR market rate matrix
|
| 47 |
+
- **Who to call**: painter / mason / waterproofing contractor / civil engineer
|
| 48 |
+
- **Monsoon risk** flag (critical for Indian users pre-June)
|
| 49 |
+
|
| 50 |
+
## The Problem
|
| 51 |
+
|
| 52 |
+
Over 62% of India's urban housing was built before 1990. When a homeowner
|
| 53 |
+
sees a wall crack, they have three options: pay βΉ2,000β8,000 for a civil
|
| 54 |
+
engineer, ask a mason who has a conflict of interest, or Google it and get
|
| 55 |
+
scared. GharScan is the affordable first answer.
|
| 56 |
+
|
| 57 |
+
## Model
|
| 58 |
+
|
| 59 |
+
- **Base**: MiniCPM-V 2.0 by OpenBMB (2.8B parameters)
|
| 60 |
+
- **Fine-tuned on**: 14,000+ building defect images (CODEBRIM, CrackSeg9k,
|
| 61 |
+
SDNET2018, MCrack1300, dacl1k) + India-specific web-scraped images
|
| 62 |
+
- **Runtime**: HuggingFace ZeroGPU (no cloud API β fully offline inference)
|
| 63 |
+
- **GGUF**: Available at `ritvik412/gharscan-minicpm-v2-gguf` for llama.cpp
|
| 64 |
+
|
| 65 |
+
## Technical Stack
|
| 66 |
+
|
| 67 |
+
| Layer | Technology |
|
| 68 |
+
|---|---|
|
| 69 |
+
| Frontend | Custom HTML/CSS/JS via `gr.Server` |
|
| 70 |
+
| Backend | Gradio 6.14+ agentic pipeline |
|
| 71 |
+
| Model | MiniCPM-V 2.0 LoRA fine-tune |
|
| 72 |
+
| Inference | HuggingFace ZeroGPU |
|
| 73 |
+
| Training | Modal Labs A100-80GB |
|
| 74 |
+
| Cost estimation | Deterministic INR lookup table |
|
| 75 |
+
| Trace logging | Auto-uploaded to `ritvik412/gharscan-agent-traces` |
|
| 76 |
+
|
| 77 |
+
## Bonus Quest Badges Claimed
|
| 78 |
+
|
| 79 |
+
| Badge | Status |
|
| 80 |
+
|---|---|
|
| 81 |
+
| π Off the Grid (no cloud APIs) | β
|
|
| 82 |
+
| π― Well-Tuned (published fine-tuned model) | β
|
|
| 83 |
+
| π¨ Off-Brand (custom gr.Server UI) | β
|
|
| 84 |
+
| π¦ Llama Champion (GGUF + llama.cpp) | β
|
|
| 85 |
+
| π‘ Sharing is Caring (agent traces) | β
|
|
| 86 |
+
| π Field Notes (blog post) | β
|
|
| 87 |
+
|
| 88 |
+
## How to Use on Mobile
|
| 89 |
+
|
| 90 |
+
1. Open this Space on your phone browser
|
| 91 |
+
2. Tap **Take Photo** β your rear camera opens directly
|
| 92 |
+
3. Photograph the defect (crack, stain, damage)
|
| 93 |
+
4. Wait ~12 seconds for analysis
|
| 94 |
+
5. Read your inspection report
|
| 95 |
+
|
| 96 |
+
## Citation
|
| 97 |
+
|
| 98 |
+
If you use the GharScan dataset or model:
|
| 99 |
+
```
|
| 100 |
+
@misc{gharscan2026,
|
| 101 |
+
title = {GharScan: AI Building Defect Inspector for Indian Residential Properties},
|
| 102 |
+
author = {ritvik412},
|
| 103 |
+
year = {2026},
|
| 104 |
+
url = {https://huggingface.co/spaces/build-small-hackathon/gharscan}
|
| 105 |
+
}
|
| 106 |
+
```
|
agent_trace.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
agent_trace.py
|
| 3 |
+
ββββββββββββββ
|
| 4 |
+
Logs the 3-step GharScan agentic reasoning chain per inference call.
|
| 5 |
+
Traces are periodically pushed to HuggingFace Hub as a public dataset.
|
| 6 |
+
|
| 7 |
+
Qualifies for: π‘ Sharing is Caring badge
|
| 8 |
+
Dataset target: <hf_username>/gharscan-agent-traces
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import uuid
|
| 13 |
+
import time
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Optional
|
| 17 |
+
from loguru import logger
|
| 18 |
+
from huggingface_hub import HfApi, login
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
HF_TRACE_REPO = os.getenv("HF_TRACE_REPO", "ritvik360/gharscan-agent-traces")
|
| 23 |
+
LOCAL_TRACE_DIR = Path("/tmp/gharscan_traces")
|
| 24 |
+
FLUSH_EVERY_N = 20 # Push to Hub every N traces to avoid rate-limiting
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TraceSession:
|
| 28 |
+
"""Represents a single user inference session with 3 reasoning steps."""
|
| 29 |
+
|
| 30 |
+
def __init__(self, session_id: Optional[str] = None):
|
| 31 |
+
self.session_id = session_id or str(uuid.uuid4())[:8]
|
| 32 |
+
self.started_at = datetime.now(timezone.utc).isoformat()
|
| 33 |
+
self.steps: list = []
|
| 34 |
+
self.final_report: Optional[dict] = None
|
| 35 |
+
self.duration_ms: Optional[int] = None
|
| 36 |
+
self._t0 = time.monotonic()
|
| 37 |
+
|
| 38 |
+
def log_step(self, step_name: str, step_input: dict, step_output: dict) -> None:
|
| 39 |
+
self.steps.append({
|
| 40 |
+
"step": step_name, # "classify" | "severity" | "cost"
|
| 41 |
+
"input": step_input,
|
| 42 |
+
"output": step_output,
|
| 43 |
+
"elapsed_ms": int((time.monotonic() - self._t0) * 1000),
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
def finalize(self, final_report: dict) -> None:
|
| 47 |
+
self.final_report = final_report
|
| 48 |
+
self.duration_ms = int((time.monotonic() - self._t0) * 1000)
|
| 49 |
+
|
| 50 |
+
def to_dict(self) -> dict:
|
| 51 |
+
return {
|
| 52 |
+
"session_id": self.session_id,
|
| 53 |
+
"started_at": self.started_at,
|
| 54 |
+
"duration_ms": self.duration_ms,
|
| 55 |
+
"steps": self.steps,
|
| 56 |
+
"final_report": self.final_report,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class AgentTraceLogger:
|
| 61 |
+
"""
|
| 62 |
+
Manages trace collection and periodic HF Hub uploads.
|
| 63 |
+
Keeps traces in local JSONL buffer; flushes to Hub every FLUSH_EVERY_N calls.
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
def __init__(self):
|
| 67 |
+
LOCAL_TRACE_DIR.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
self._buffer_path = LOCAL_TRACE_DIR / "traces_buffer.jsonl"
|
| 69 |
+
self._count = 0
|
| 70 |
+
self._api = None
|
| 71 |
+
self._hf_ready = False
|
| 72 |
+
self._init_hf()
|
| 73 |
+
|
| 74 |
+
def _init_hf(self):
|
| 75 |
+
"""Try to authenticate with HF Hub. Silently skip if no token."""
|
| 76 |
+
hf_token = os.getenv("HF_TOKEN")
|
| 77 |
+
if hf_token:
|
| 78 |
+
try:
|
| 79 |
+
login(token=hf_token)
|
| 80 |
+
self._api = HfApi()
|
| 81 |
+
self._hf_ready = True
|
| 82 |
+
# Ensure dataset repo exists
|
| 83 |
+
self._api.create_repo(
|
| 84 |
+
repo_id=HF_TRACE_REPO,
|
| 85 |
+
repo_type="dataset",
|
| 86 |
+
exist_ok=True,
|
| 87 |
+
private=False,
|
| 88 |
+
)
|
| 89 |
+
logger.info(f"AgentTraceLogger ready β {HF_TRACE_REPO}")
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.warning(f"HF trace upload disabled: {e}")
|
| 92 |
+
else:
|
| 93 |
+
logger.warning("HF_TOKEN not set β traces saved locally only.")
|
| 94 |
+
|
| 95 |
+
def start_trace(self) -> TraceSession:
|
| 96 |
+
return TraceSession()
|
| 97 |
+
|
| 98 |
+
def save_trace(self, session: TraceSession) -> None:
|
| 99 |
+
"""Append trace to local buffer; flush to HF Hub every N traces."""
|
| 100 |
+
record = json.dumps(session.to_dict(), ensure_ascii=False)
|
| 101 |
+
with open(self._buffer_path, "a", encoding="utf-8") as f:
|
| 102 |
+
f.write(record + "\n")
|
| 103 |
+
|
| 104 |
+
self._count += 1
|
| 105 |
+
logger.debug(f"Trace saved [{self._count}]: session={session.session_id}")
|
| 106 |
+
|
| 107 |
+
if self._count % FLUSH_EVERY_N == 0 and self._hf_ready:
|
| 108 |
+
self._flush_to_hub()
|
| 109 |
+
|
| 110 |
+
def _flush_to_hub(self) -> None:
|
| 111 |
+
"""Upload local JSONL buffer to HF Hub dataset repo."""
|
| 112 |
+
try:
|
| 113 |
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
| 114 |
+
remote_filename = f"traces_{timestamp}.jsonl"
|
| 115 |
+
|
| 116 |
+
self._api.upload_file(
|
| 117 |
+
path_or_fileobj=str(self._buffer_path),
|
| 118 |
+
path_in_repo=f"data/{remote_filename}",
|
| 119 |
+
repo_id=HF_TRACE_REPO,
|
| 120 |
+
repo_type="dataset",
|
| 121 |
+
commit_message=f"Auto-flush: {self._count} traces",
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Reset local buffer after successful upload
|
| 125 |
+
self._buffer_path.write_text("")
|
| 126 |
+
logger.info(f"Traces flushed to Hub β {HF_TRACE_REPO}/data/{remote_filename}")
|
| 127 |
+
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"Trace flush failed: {e}")
|
| 130 |
+
|
| 131 |
+
def force_flush(self) -> None:
|
| 132 |
+
"""Call manually before Space shutdown to push remaining traces."""
|
| 133 |
+
if self._hf_ready and self._buffer_path.exists():
|
| 134 |
+
self._flush_to_hub()
|
app.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py
|
| 3 |
+
ββββββ
|
| 4 |
+
GharScan β Building Defect Inspector
|
| 5 |
+
HuggingFace Space entry point.
|
| 6 |
+
|
| 7 |
+
Gradio version: >= 6.14.0 (strict requirement for gr.Server)
|
| 8 |
+
Hardware: ZeroGPU (free tier on HF Spaces)
|
| 9 |
+
Model: MiniCPM-V 2.0 fine-tuned (2.8B, OpenBMB)
|
| 10 |
+
|
| 11 |
+
Architecture:
|
| 12 |
+
gr.Server() β custom HTML/CSS/JS frontend (Off-Brand badge)
|
| 13 |
+
@app.api("analyze_defect") β ZeroGPU inference endpoint
|
| 14 |
+
@app.get("/") β serves static/index.html
|
| 15 |
+
|
| 16 |
+
Watch-Out 1 (ZeroGPU Cold Start):
|
| 17 |
+
@spaces.GPU is scoped only around the inference function call.
|
| 18 |
+
Model is globally cached; device management (cpu β cuda) is handled
|
| 19 |
+
inside inference.py's run_gharscan_pipeline().
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
import json
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import spaces # HF Spaces ZeroGPU decorator
|
| 27 |
+
from gradio import Server
|
| 28 |
+
from fastapi import Request
|
| 29 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 30 |
+
from fastapi.staticfiles import StaticFiles
|
| 31 |
+
from PIL import Image
|
| 32 |
+
|
| 33 |
+
from inference import run_gharscan_pipeline
|
| 34 |
+
from agent_trace import AgentTraceLogger
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ββ Static paths βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
STATIC_DIR = Path(__file__).parent / "static"
|
| 39 |
+
|
| 40 |
+
# ββ Trace logger (initialized once at startup) ββββββββββββββββββββββββββββββββ
|
| 41 |
+
trace_logger = AgentTraceLogger()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ββ App initialization βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
app = Server()
|
| 46 |
+
|
| 47 |
+
# Serve static assets (CSS, JS, icons) at /static/*
|
| 48 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ββ Page routes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
@app.get("/", response_class=HTMLResponse)
|
| 53 |
+
async def homepage(request: Request):
|
| 54 |
+
"""Serve the custom GharScan frontend."""
|
| 55 |
+
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
| 56 |
+
return HTMLResponse(content=html)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@app.get("/health")
|
| 60 |
+
async def health():
|
| 61 |
+
return {"status": "ok", "model": "gharscan-minicpm-v2-lora"}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ββ Core inference API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 65 |
+
@app.api(name="analyze_defect")
|
| 66 |
+
@spaces.GPU # GPU allocated only for this function's duration
|
| 67 |
+
def analyze_defect(
|
| 68 |
+
image_path: dict, # FileData from @gradio/client handle_file()
|
| 69 |
+
language: str = "en",
|
| 70 |
+
) -> dict:
|
| 71 |
+
"""
|
| 72 |
+
Primary inference endpoint.
|
| 73 |
+
|
| 74 |
+
Accepts: image file + language preference
|
| 75 |
+
Returns: full JSON defect report
|
| 76 |
+
|
| 77 |
+
ZeroGPU note: GPU is requested here and released at function return.
|
| 78 |
+
Cold-start (~30s first call) is expected; subsequent calls are fast.
|
| 79 |
+
"""
|
| 80 |
+
# ββ Load image ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
+
try:
|
| 82 |
+
img_path = image_path.get("path") or image_path.get("url")
|
| 83 |
+
if img_path is None:
|
| 84 |
+
return _error_response("No image path received.")
|
| 85 |
+
image = Image.open(img_path).convert("RGB")
|
| 86 |
+
except Exception as e:
|
| 87 |
+
return _error_response(f"Could not read image: {e}")
|
| 88 |
+
|
| 89 |
+
# ββ Run 3-step pipeline βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 90 |
+
try:
|
| 91 |
+
session = trace_logger.start_trace()
|
| 92 |
+
report = run_gharscan_pipeline(image, language=language, trace_session=session)
|
| 93 |
+
trace_logger.save_trace(session)
|
| 94 |
+
return report
|
| 95 |
+
|
| 96 |
+
except Exception as e:
|
| 97 |
+
return _error_response(f"Inference failed: {e}")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ββ Trace export endpoint (for Sharing is Caring badge) ββββββββββββββββββββββ
|
| 101 |
+
@app.get("/flush-traces")
|
| 102 |
+
async def flush_traces(request: Request):
|
| 103 |
+
"""
|
| 104 |
+
Manually flush buffered traces to HF Hub.
|
| 105 |
+
Called automatically on Space shutdown or can be triggered manually.
|
| 106 |
+
"""
|
| 107 |
+
api_key = request.headers.get("X-Admin-Key", "")
|
| 108 |
+
if api_key != os.getenv("ADMIN_KEY", "gharscan-admin"):
|
| 109 |
+
return JSONResponse({"error": "unauthorized"}, status_code=403)
|
| 110 |
+
|
| 111 |
+
trace_logger.force_flush()
|
| 112 |
+
return {"status": "flushed"}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ββ Helper βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 116 |
+
def _error_response(msg: str) -> dict:
|
| 117 |
+
return {
|
| 118 |
+
"analysis_ok": False,
|
| 119 |
+
"defect_type": "no_defect",
|
| 120 |
+
"defect_display": "Analysis Failed",
|
| 121 |
+
"description": msg,
|
| 122 |
+
"severity": 0,
|
| 123 |
+
"severity_label": "Unknown",
|
| 124 |
+
"severity_color": "#6b7280",
|
| 125 |
+
"is_structural": False,
|
| 126 |
+
"structural_reasoning": "",
|
| 127 |
+
"immediate_action": "Please retake the photo with better lighting and a clearer view of the defect.",
|
| 128 |
+
"cost_range_inr": "N/A",
|
| 129 |
+
"professional_display": "N/A",
|
| 130 |
+
"urgency_display": "N/A",
|
| 131 |
+
"monsoon_risk": False,
|
| 132 |
+
"show_liability_banner":False,
|
| 133 |
+
"liability_text": None,
|
| 134 |
+
"requires_engineer": False,
|
| 135 |
+
"disclaimer": None,
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ββ Launch βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 140 |
+
if __name__ == "__main__":
|
| 141 |
+
import uvicorn
|
| 142 |
+
uvicorn.run(app, host="0.0.0.0", port=7860, log_level="info")
|
cost_matrix.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cost_matrix.py
|
| 3 |
+
ββββββββββββββ
|
| 4 |
+
Deterministic INR cost lookup table for GharScan.
|
| 5 |
+
No model hallucination here β pure market-rate data (Delhi/NCR, 2026).
|
| 6 |
+
|
| 7 |
+
Cost ranges sourced from: HouseYog, Comaron, UrbanCompany quoted rates,
|
| 8 |
+
and standard PWD schedule-of-rates for residential repairs.
|
| 9 |
+
|
| 10 |
+
Lower bound = Tier-2/3 city rates OR small affected area (< 2 sq ft)
|
| 11 |
+
Upper bound = Delhi/NCR metro rates OR large affected area (> 10 sq ft)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class CostEntry:
|
| 20 |
+
cost_range_inr: str
|
| 21 |
+
professional_type: str # painter | mason | waterproofing_contractor | civil_engineer
|
| 22 |
+
urgency_timeline: str # next_renovation | within_6_months | within_1_month | this_week | immediately
|
| 23 |
+
disclaimer: Optional[str] = None
|
| 24 |
+
requires_engineer: bool = False
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ββ Severity labels ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
SEVERITY_LABELS = {
|
| 29 |
+
1: "Cosmetic",
|
| 30 |
+
2: "Minor",
|
| 31 |
+
3: "Moderate",
|
| 32 |
+
4: "Serious",
|
| 33 |
+
5: "Critical",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
SEVERITY_COLORS = {
|
| 37 |
+
1: "#22c55e", # green
|
| 38 |
+
2: "#84cc16", # lime
|
| 39 |
+
3: "#f59e0b", # amber
|
| 40 |
+
4: "#ef4444", # red
|
| 41 |
+
5: "#7f1d1d", # dark red
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
# ββ Main cost matrix βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
# Structure: COST_MATRIX[defect_type][severity_1_to_5] = CostEntry
|
| 46 |
+
COST_MATRIX: dict[str, dict[int, CostEntry]] = {
|
| 47 |
+
|
| 48 |
+
"hairline_crack": {
|
| 49 |
+
1: CostEntry("βΉ200ββΉ400", "painter", "next_renovation"),
|
| 50 |
+
2: CostEntry("βΉ400ββΉ800", "painter", "next_renovation"),
|
| 51 |
+
3: CostEntry("βΉ800ββΉ1,500", "mason", "within_6_months"),
|
| 52 |
+
4: CostEntry("βΉ1,500ββΉ3,000","mason", "within_1_month"),
|
| 53 |
+
5: CostEntry("βΉ3,000ββΉ6,000","civil_engineer", "this_week",
|
| 54 |
+
disclaimer="Multiple hairline cracks over large area β professional assessment strongly advised."),
|
| 55 |
+
},
|
| 56 |
+
|
| 57 |
+
"settlement_crack": {
|
| 58 |
+
1: CostEntry("βΉ400ββΉ600", "mason", "next_renovation"),
|
| 59 |
+
2: CostEntry("βΉ600ββΉ1,200", "mason", "within_6_months"),
|
| 60 |
+
3: CostEntry("βΉ1,500ββΉ4,000", "mason", "within_6_months"),
|
| 61 |
+
4: CostEntry("βΉ5,000ββΉ15,000", "civil_engineer", "within_1_month",
|
| 62 |
+
disclaimer="Severity 4 settlement cracks indicate significant differential movement. Professional structural assessment required before any repair.",
|
| 63 |
+
requires_engineer=True),
|
| 64 |
+
5: CostEntry("Assessment required", "civil_engineer", "this_week",
|
| 65 |
+
disclaimer="URGENT: Wide or active settlement cracks may indicate ongoing foundation failure. Do not delay professional structural inspection.",
|
| 66 |
+
requires_engineer=True),
|
| 67 |
+
},
|
| 68 |
+
|
| 69 |
+
"structural_crack": {
|
| 70 |
+
1: CostEntry("βΉ800ββΉ1,500", "mason", "within_6_months"),
|
| 71 |
+
2: CostEntry("βΉ2,000ββΉ6,000", "civil_engineer", "within_1_month"),
|
| 72 |
+
3: CostEntry("βΉ8,000ββΉ25,000", "civil_engineer", "this_week",
|
| 73 |
+
requires_engineer=True),
|
| 74 |
+
4: CostEntry("Assessment required", "civil_engineer", "this_week",
|
| 75 |
+
disclaimer="SERIOUS: Structural cracks at this severity require immediate professional inspection. Do not attempt DIY repairs.",
|
| 76 |
+
requires_engineer=True),
|
| 77 |
+
5: CostEntry("Vacate and call engineer", "civil_engineer", "immediately",
|
| 78 |
+
disclaimer="CRITICAL STRUCTURAL RISK: This defect may compromise load-bearing capacity. Vacate the area immediately and contact a structural engineer.",
|
| 79 |
+
requires_engineer=True),
|
| 80 |
+
},
|
| 81 |
+
|
| 82 |
+
"water_seepage": {
|
| 83 |
+
1: CostEntry("βΉ500ββΉ1,000", "mason", "next_renovation"),
|
| 84 |
+
2: CostEntry("βΉ1,500ββΉ4,000", "waterproofing_contractor", "within_6_months"),
|
| 85 |
+
3: CostEntry("βΉ4,000ββΉ12,000", "waterproofing_contractor", "within_1_month"),
|
| 86 |
+
4: CostEntry("βΉ10,000ββΉ25,000","waterproofing_contractor", "this_week",
|
| 87 |
+
disclaimer="Extensive seepage can weaken structural elements over time. Address before next monsoon season."),
|
| 88 |
+
5: CostEntry("βΉ20,000ββΉ60,000","waterproofing_contractor", "this_week",
|
| 89 |
+
disclaimer="Active water ingress at this scale indicates waterproofing system failure. Professional treatment of source (terrace/external wall) is required, not just surface patching.",
|
| 90 |
+
requires_engineer=True),
|
| 91 |
+
},
|
| 92 |
+
|
| 93 |
+
"efflorescence": {
|
| 94 |
+
1: CostEntry("βΉ200ββΉ500", "painter", "next_renovation"),
|
| 95 |
+
2: CostEntry("βΉ500ββΉ1,500", "mason", "next_renovation"),
|
| 96 |
+
3: CostEntry("βΉ1,500ββΉ4,000","waterproofing_contractor", "within_6_months"),
|
| 97 |
+
4: CostEntry("βΉ4,000ββΉ10,000","waterproofing_contractor", "within_1_month",
|
| 98 |
+
disclaimer="Heavy efflorescence indicates sustained moisture migration β treat the moisture source, not just the surface deposits."),
|
| 99 |
+
5: CostEntry("βΉ10,000ββΉ20,000","waterproofing_contractor", "this_week",
|
| 100 |
+
disclaimer="Severe salt deposits with visible masonry deterioration. Source moisture must be identified and stopped before cosmetic repair."),
|
| 101 |
+
},
|
| 102 |
+
|
| 103 |
+
"spalling": {
|
| 104 |
+
1: CostEntry("βΉ1,000ββΉ2,500", "mason", "within_6_months"),
|
| 105 |
+
2: CostEntry("βΉ3,000ββΉ8,000", "mason", "within_1_month"),
|
| 106 |
+
3: CostEntry("βΉ8,000ββΉ20,000", "civil_engineer", "this_week",
|
| 107 |
+
requires_engineer=True),
|
| 108 |
+
4: CostEntry("Assessment required", "civil_engineer", "this_week",
|
| 109 |
+
disclaimer="SERIOUS: Concrete spalling with exposed reinforcement requires professional structural assessment β the scope of repair cannot be estimated from image alone.",
|
| 110 |
+
requires_engineer=True),
|
| 111 |
+
5: CostEntry("Vacate area β engineer required", "civil_engineer", "immediately",
|
| 112 |
+
disclaimer="CRITICAL: Extensive spalling with exposed steel poses imminent risk of further concrete fall. Vacate the area below.",
|
| 113 |
+
requires_engineer=True),
|
| 114 |
+
},
|
| 115 |
+
|
| 116 |
+
"rebar_rust": {
|
| 117 |
+
1: CostEntry("βΉ2,000ββΉ5,000", "mason", "within_1_month"),
|
| 118 |
+
2: CostEntry("βΉ5,000ββΉ15,000", "civil_engineer", "this_week",
|
| 119 |
+
requires_engineer=True),
|
| 120 |
+
3: CostEntry("βΉ15,000ββΉ40,000", "civil_engineer", "this_week",
|
| 121 |
+
disclaimer="Rebar corrosion reduces structural capacity. Extent of rust along rebar length must be assessed by a structural engineer.",
|
| 122 |
+
requires_engineer=True),
|
| 123 |
+
4: CostEntry("Assessment required", "civil_engineer", "immediately",
|
| 124 |
+
disclaimer="SERIOUS: Visible rebar corrosion with concrete delamination. Structural capacity may be significantly compromised.",
|
| 125 |
+
requires_engineer=True),
|
| 126 |
+
5: CostEntry("Vacate area β urgent inspection", "civil_engineer", "immediately",
|
| 127 |
+
disclaimer="CRITICAL STRUCTURAL RISK: Extensive rebar corrosion threatens structural integrity. Immediate professional inspection mandatory.",
|
| 128 |
+
requires_engineer=True),
|
| 129 |
+
},
|
| 130 |
+
|
| 131 |
+
"plaster_delamination": {
|
| 132 |
+
1: CostEntry("βΉ300ββΉ600", "painter", "next_renovation"),
|
| 133 |
+
2: CostEntry("βΉ600ββΉ1,500", "mason", "next_renovation"),
|
| 134 |
+
3: CostEntry("βΉ1,500ββΉ5,000", "mason", "within_6_months"),
|
| 135 |
+
4: CostEntry("βΉ5,000ββΉ12,000","mason", "within_1_month",
|
| 136 |
+
disclaimer="Large-area delamination often indicates substrate moisture. Identify and treat moisture source before replastering."),
|
| 137 |
+
5: CostEntry("βΉ12,000ββΉ25,000","mason", "this_week",
|
| 138 |
+
disclaimer="Extensive plaster delamination over large wall area. Complete replastering required after identifying and treating root cause."),
|
| 139 |
+
},
|
| 140 |
+
|
| 141 |
+
"no_defect": {
|
| 142 |
+
1: CostEntry("βΉ0 β No action needed", "none", "next_renovation"),
|
| 143 |
+
2: CostEntry("βΉ0 β No action needed", "none", "next_renovation"),
|
| 144 |
+
3: CostEntry("βΉ0 β No action needed", "none", "next_renovation"),
|
| 145 |
+
4: CostEntry("βΉ0 β No action needed", "none", "next_renovation"),
|
| 146 |
+
5: CostEntry("βΉ0 β No action needed", "none", "next_renovation"),
|
| 147 |
+
},
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ββ Professional display names βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 152 |
+
PROFESSIONAL_DISPLAY = {
|
| 153 |
+
"painter": "Local Painter / Decorator",
|
| 154 |
+
"mason": "Licensed Mason (Raj Mistri)",
|
| 155 |
+
"waterproofing_contractor": "Waterproofing Contractor",
|
| 156 |
+
"civil_engineer": "Licensed Civil / Structural Engineer",
|
| 157 |
+
"none": "No professional required",
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
TIMELINE_DISPLAY = {
|
| 161 |
+
"next_renovation": "At your next renovation",
|
| 162 |
+
"within_6_months": "Within 6 months",
|
| 163 |
+
"within_1_month": "Within 1 month",
|
| 164 |
+
"this_week": "This week",
|
| 165 |
+
"immediately": "IMMEDIATELY",
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def get_cost_entry(defect_type: str, severity: int) -> CostEntry:
|
| 170 |
+
"""
|
| 171 |
+
Primary lookup function.
|
| 172 |
+
Returns CostEntry for a given defect_type Γ severity combination.
|
| 173 |
+
Falls back to a safe default if defect type is not in matrix.
|
| 174 |
+
"""
|
| 175 |
+
if defect_type not in COST_MATRIX:
|
| 176 |
+
return CostEntry(
|
| 177 |
+
cost_range_inr="Assessment required",
|
| 178 |
+
professional_type="civil_engineer",
|
| 179 |
+
urgency_timeline="within_1_month",
|
| 180 |
+
disclaimer="Unrecognized defect type. Please consult a professional for assessment.",
|
| 181 |
+
requires_engineer=True,
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
severity = max(1, min(5, int(severity))) # Clamp to valid range
|
| 185 |
+
return COST_MATRIX[defect_type][severity]
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def build_cost_response(defect_type: str, severity: int) -> dict:
|
| 189 |
+
"""
|
| 190 |
+
Returns a fully-structured dict ready for the frontend renderer.
|
| 191 |
+
Includes display-friendly strings + disclaimer flag.
|
| 192 |
+
"""
|
| 193 |
+
entry = get_cost_entry(defect_type, severity)
|
| 194 |
+
|
| 195 |
+
return {
|
| 196 |
+
"cost_range_inr": entry.cost_range_inr,
|
| 197 |
+
"professional_type": entry.professional_type,
|
| 198 |
+
"professional_display": PROFESSIONAL_DISPLAY.get(entry.professional_type, entry.professional_type),
|
| 199 |
+
"urgency_timeline": entry.urgency_timeline,
|
| 200 |
+
"urgency_display": TIMELINE_DISPLAY.get(entry.urgency_timeline, entry.urgency_timeline),
|
| 201 |
+
"severity_label": SEVERITY_LABELS.get(severity, "Unknown"),
|
| 202 |
+
"severity_color": SEVERITY_COLORS.get(severity, "#6b7280"),
|
| 203 |
+
"requires_engineer": entry.requires_engineer,
|
| 204 |
+
"disclaimer": entry.disclaimer,
|
| 205 |
+
# Hard disclaimer for all Severity 4-5 (Watch-Out 2)
|
| 206 |
+
"show_liability_banner": severity >= 4,
|
| 207 |
+
"liability_text": (
|
| 208 |
+
"β οΈ These are estimated market rates only. "
|
| 209 |
+
"For Severity 4β5 defects, always obtain a professional site assessment "
|
| 210 |
+
"before starting any repair work. GharScan is a triage tool, not a substitute "
|
| 211 |
+
"for a licensed engineer or contractor."
|
| 212 |
+
) if severity >= 4 else None,
|
| 213 |
+
}
|
inference.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
inference.py
|
| 3 |
+
ββββββββββββ
|
| 4 |
+
GharScan VLM inference pipeline.
|
| 5 |
+
|
| 6 |
+
Model: MiniCPM-V 2.0 (2.8B) β fine-tuned LoRA adapter
|
| 7 |
+
Engine: HuggingFace Transformers + ZeroGPU (HF Spaces)
|
| 8 |
+
|
| 9 |
+
Watch-Out 1 (ZeroGPU Cold Start):
|
| 10 |
+
Model is loaded GLOBALLY at module level (CPU).
|
| 11 |
+
Inside @spaces.GPU, model.to("cuda") is called β GPU allocation is
|
| 12 |
+
only requested when actually needed. After inference, model.to("cpu")
|
| 13 |
+
frees the ZeroGPU allocation back to the pool.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
import json
|
| 18 |
+
import time
|
| 19 |
+
import torch
|
| 20 |
+
from PIL import Image
|
| 21 |
+
from loguru import logger
|
| 22 |
+
from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
|
| 23 |
+
from cost_matrix import build_cost_response
|
| 24 |
+
|
| 25 |
+
# ββ Model Configuration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
LORA_MODEL_ID = "ritvik360/gharscan-qwen2vl-lora"
|
| 27 |
+
BASE_MODEL_ID = "Qwen/Qwen2-VL-2B-Instruct" # published after fine-tune
|
| 28 |
+
MAX_NEW_TOKENS = 384
|
| 29 |
+
TEMPERATURE = 0.05 # Near-greedy for structured JSON output
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ββ Global model references (loaded once, stays on CPU between calls) βββββββββ
|
| 33 |
+
_model = None
|
| 34 |
+
_tokenizer = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _load_model_if_needed():
|
| 38 |
+
"""
|
| 39 |
+
Lazy model loading. Called inside @spaces.GPU context.
|
| 40 |
+
First call: loads from HF Hub (~30s on cold boot).
|
| 41 |
+
Subsequent calls: instant (already in memory).
|
| 42 |
+
"""
|
| 43 |
+
global _model, _tokenizer
|
| 44 |
+
|
| 45 |
+
if _model is not None:
|
| 46 |
+
return
|
| 47 |
+
|
| 48 |
+
logger.info(f"Loading GharScan model from {LORA_MODEL_ID} β¦")
|
| 49 |
+
t0 = time.monotonic()
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
# Try fine-tuned LoRA model first
|
| 53 |
+
from peft import PeftModel
|
| 54 |
+
base = AutoModel.from_pretrained(
|
| 55 |
+
BASE_MODEL_ID,
|
| 56 |
+
trust_remote_code=True,
|
| 57 |
+
torch_dtype=torch.bfloat16,
|
| 58 |
+
)
|
| 59 |
+
_model = PeftModel.from_pretrained(base, LORA_MODEL_ID)
|
| 60 |
+
_model = _model.merge_and_unload()
|
| 61 |
+
logger.info("Loaded fine-tuned LoRA adapter β
")
|
| 62 |
+
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.warning(f"LoRA load failed ({e}) β falling back to base model.")
|
| 65 |
+
_model = AutoModel.from_pretrained(
|
| 66 |
+
BASE_MODEL_ID,
|
| 67 |
+
trust_remote_code=True,
|
| 68 |
+
torch_dtype=torch.bfloat16,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
_tokenizer = AutoTokenizer.from_pretrained(
|
| 72 |
+
BASE_MODEL_ID, trust_remote_code=True
|
| 73 |
+
)
|
| 74 |
+
_model.eval()
|
| 75 |
+
logger.info(f"Model ready in {time.monotonic()-t0:.1f}s")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ββ Prompts ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 79 |
+
_CLASSIFY_PROMPT = """You are GharScan, an expert Indian building inspector.
|
| 80 |
+
Analyze this image of a building defect and return ONLY valid JSON β no other text.
|
| 81 |
+
|
| 82 |
+
Required JSON schema:
|
| 83 |
+
{
|
| 84 |
+
"defect_type": "<hairline_crack|settlement_crack|structural_crack|water_seepage|efflorescence|spalling|rebar_rust|plaster_delamination|no_defect>",
|
| 85 |
+
"description": "<25-word plain English description of exactly what you see>",
|
| 86 |
+
"primary_cause": "<one sentence cause in simple language>",
|
| 87 |
+
"monsoon_risk": <true|false>,
|
| 88 |
+
"confidence": <0.0-1.0>
|
| 89 |
+
}"""
|
| 90 |
+
|
| 91 |
+
_SEVERITY_PROMPT = """You are GharScan. The defect in this image is: {defect_type}.
|
| 92 |
+
Assess the severity and return ONLY valid JSON β no other text.
|
| 93 |
+
|
| 94 |
+
Required JSON schema:
|
| 95 |
+
{{
|
| 96 |
+
"severity": <1|2|3|4|5>,
|
| 97 |
+
"is_structural": <true|false>,
|
| 98 |
+
"structural_reasoning": "<one sentence explanation>",
|
| 99 |
+
"immediate_action": "<specific actionable instruction in plain language>",
|
| 100 |
+
"urgency_timeline": "<next_renovation|within_6_months|within_1_month|this_week|immediately>",
|
| 101 |
+
"visible_width_estimate": "<hairline|<1mm|1-3mm|3-10mm|>10mm|not_applicable>"
|
| 102 |
+
}}
|
| 103 |
+
|
| 104 |
+
Severity scale:
|
| 105 |
+
1 = Cosmetic only, no action needed soon
|
| 106 |
+
2 = Minor, address at next renovation
|
| 107 |
+
3 = Moderate, fix within 6 months
|
| 108 |
+
4 = Serious, fix within 1 month, consider engineer
|
| 109 |
+
5 = Critical, structural risk, immediate attention"""
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ββ Core inference helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 113 |
+
def _call_vlm(image: Image.Image, prompt: str) -> dict:
|
| 114 |
+
"""Single VLM forward pass β parsed JSON dict."""
|
| 115 |
+
msgs = [{"role": "user", "content": [image, prompt]}]
|
| 116 |
+
|
| 117 |
+
raw_output = _model.chat(
|
| 118 |
+
image=None,
|
| 119 |
+
msgs=msgs,
|
| 120 |
+
tokenizer=_tokenizer,
|
| 121 |
+
sampling=True,
|
| 122 |
+
temperature=TEMPERATURE,
|
| 123 |
+
max_new_tokens=MAX_NEW_TOKENS,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
return _parse_json(raw_output)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _parse_json(text: str) -> dict:
|
| 130 |
+
"""Robustly extract JSON from model output, even with surrounding text."""
|
| 131 |
+
text = text.strip()
|
| 132 |
+
|
| 133 |
+
# Try direct parse first
|
| 134 |
+
try:
|
| 135 |
+
return json.loads(text)
|
| 136 |
+
except json.JSONDecodeError:
|
| 137 |
+
pass
|
| 138 |
+
|
| 139 |
+
# Extract JSON object from surrounding text
|
| 140 |
+
match = re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)?\}', text, re.DOTALL)
|
| 141 |
+
if match:
|
| 142 |
+
try:
|
| 143 |
+
return json.loads(match.group())
|
| 144 |
+
except json.JSONDecodeError:
|
| 145 |
+
pass
|
| 146 |
+
|
| 147 |
+
# Final fallback: return safe defaults
|
| 148 |
+
logger.warning(f"JSON parse failed on: {text[:200]}")
|
| 149 |
+
return {
|
| 150 |
+
"defect_type": "no_defect",
|
| 151 |
+
"description": "Could not analyze image clearly. Please retake with better lighting.",
|
| 152 |
+
"primary_cause": "Analysis inconclusive.",
|
| 153 |
+
"monsoon_risk": False,
|
| 154 |
+
"confidence": 0.0,
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _preprocess_image(image: Image.Image) -> Image.Image:
|
| 159 |
+
"""Resize to MiniCPM-V optimal resolution without distorting aspect ratio."""
|
| 160 |
+
MAX_SIDE = 1344
|
| 161 |
+
w, h = image.size
|
| 162 |
+
if max(w, h) > MAX_SIDE:
|
| 163 |
+
scale = MAX_SIDE / max(w, h)
|
| 164 |
+
image = image.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
|
| 165 |
+
return image.convert("RGB")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ββ Public pipeline function (called inside @spaces.GPU in app.py) ββββββββββββ
|
| 169 |
+
def run_gharscan_pipeline(
|
| 170 |
+
image: Image.Image,
|
| 171 |
+
language: str = "en",
|
| 172 |
+
trace_session=None,
|
| 173 |
+
) -> dict:
|
| 174 |
+
"""
|
| 175 |
+
3-step agentic reasoning chain:
|
| 176 |
+
Step 1 β Defect classification
|
| 177 |
+
Step 2 β Severity + structural assessment
|
| 178 |
+
Step 3 β Cost lookup (deterministic, no model call)
|
| 179 |
+
|
| 180 |
+
Returns a fully structured report dict ready for the frontend.
|
| 181 |
+
"""
|
| 182 |
+
# ββ Ensure model is loaded (cold-start safe) ββββββββββββββββββββββββββββββ
|
| 183 |
+
_load_model_if_needed()
|
| 184 |
+
|
| 185 |
+
# ββ Move model to CUDA for this inference window ββββββββββββββββββββββββββ
|
| 186 |
+
_model.to("cuda")
|
| 187 |
+
|
| 188 |
+
try:
|
| 189 |
+
image = _preprocess_image(image)
|
| 190 |
+
|
| 191 |
+
# ββ Step 1: Classify defect βββββββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
logger.info("Step 1: Classifying defect β¦")
|
| 193 |
+
classify_input = {"prompt": _CLASSIFY_PROMPT}
|
| 194 |
+
classify_output = _call_vlm(image, _CLASSIFY_PROMPT)
|
| 195 |
+
|
| 196 |
+
defect_type = classify_output.get("defect_type", "no_defect")
|
| 197 |
+
logger.info(f" β defect_type={defect_type}")
|
| 198 |
+
|
| 199 |
+
if trace_session:
|
| 200 |
+
trace_session.log_step("classify", classify_input, classify_output)
|
| 201 |
+
|
| 202 |
+
# ββ Step 2: Severity assessment βββββββββββββββββββββββββββββββββββββββ
|
| 203 |
+
logger.info("Step 2: Assessing severity β¦")
|
| 204 |
+
severity_prompt = _SEVERITY_PROMPT.format(defect_type=defect_type)
|
| 205 |
+
severity_input = {"defect_type": defect_type, "prompt": severity_prompt}
|
| 206 |
+
severity_output = _call_vlm(image, severity_prompt)
|
| 207 |
+
|
| 208 |
+
severity = int(severity_output.get("severity", 2))
|
| 209 |
+
logger.info(f" β severity={severity}, structural={severity_output.get('is_structural')}")
|
| 210 |
+
|
| 211 |
+
if trace_session:
|
| 212 |
+
trace_session.log_step("severity", severity_input, severity_output)
|
| 213 |
+
|
| 214 |
+
# ββ Step 3: Cost lookup (deterministic) βββββββββββββββββββββββββββββββ
|
| 215 |
+
logger.info("Step 3: Cost lookup β¦")
|
| 216 |
+
cost_data = build_cost_response(defect_type, severity)
|
| 217 |
+
cost_input = {"defect_type": defect_type, "severity": severity}
|
| 218 |
+
|
| 219 |
+
if trace_session:
|
| 220 |
+
trace_session.log_step("cost", cost_input, cost_data)
|
| 221 |
+
|
| 222 |
+
# ββ Assemble final report βββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
+
report = {
|
| 224 |
+
# Classification
|
| 225 |
+
"defect_type": defect_type,
|
| 226 |
+
"defect_display": _DEFECT_DISPLAY_NAMES.get(defect_type, defect_type.replace("_", " ").title()),
|
| 227 |
+
"description": classify_output.get("description", ""),
|
| 228 |
+
"primary_cause": classify_output.get("primary_cause", ""),
|
| 229 |
+
"monsoon_risk": classify_output.get("monsoon_risk", False),
|
| 230 |
+
"confidence": round(float(classify_output.get("confidence", 0.7)), 2),
|
| 231 |
+
# Severity
|
| 232 |
+
"severity": severity,
|
| 233 |
+
"severity_label": cost_data["severity_label"],
|
| 234 |
+
"severity_color": cost_data["severity_color"],
|
| 235 |
+
"is_structural": severity_output.get("is_structural", False),
|
| 236 |
+
"structural_reasoning": severity_output.get("structural_reasoning", ""),
|
| 237 |
+
"immediate_action": severity_output.get("immediate_action", ""),
|
| 238 |
+
"urgency_timeline": severity_output.get("urgency_timeline", "within_6_months"),
|
| 239 |
+
"urgency_display": cost_data["urgency_display"],
|
| 240 |
+
"visible_width": severity_output.get("visible_width_estimate", "not_applicable"),
|
| 241 |
+
# Cost & professional
|
| 242 |
+
"cost_range_inr": cost_data["cost_range_inr"],
|
| 243 |
+
"professional_type": cost_data["professional_type"],
|
| 244 |
+
"professional_display": cost_data["professional_display"],
|
| 245 |
+
"requires_engineer": cost_data["requires_engineer"],
|
| 246 |
+
"disclaimer": cost_data["disclaimer"],
|
| 247 |
+
# Liability banner (Watch-Out 2: shown for severity >= 4)
|
| 248 |
+
"show_liability_banner":cost_data["show_liability_banner"],
|
| 249 |
+
"liability_text": cost_data["liability_text"],
|
| 250 |
+
# Meta
|
| 251 |
+
"analysis_ok": defect_type != "no_defect",
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
if trace_session:
|
| 255 |
+
trace_session.finalize(report)
|
| 256 |
+
|
| 257 |
+
return report
|
| 258 |
+
|
| 259 |
+
finally:
|
| 260 |
+
# ββ CRITICAL: Free ZeroGPU allocation after every call ββββββββββββββββ
|
| 261 |
+
_model.to("cpu")
|
| 262 |
+
torch.cuda.empty_cache()
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# ββ Display name mapping βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 266 |
+
_DEFECT_DISPLAY_NAMES = {
|
| 267 |
+
"hairline_crack": "Hairline Plaster Crack",
|
| 268 |
+
"settlement_crack": "Settlement Crack (Diagonal)",
|
| 269 |
+
"structural_crack": "Structural Crack",
|
| 270 |
+
"water_seepage": "Water Seepage / Damp Patch",
|
| 271 |
+
"efflorescence": "Efflorescence (Salt Deposits)",
|
| 272 |
+
"spalling": "Concrete Spalling",
|
| 273 |
+
"rebar_rust": "Rebar Rust Staining",
|
| 274 |
+
"plaster_delamination": "Plaster Delamination / Bubbling",
|
| 275 |
+
"no_defect": "No Defect Detected",
|
| 276 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ββ Core ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
gradio>=6.14.0 # gr.Server custom routing (strict minimum)
|
| 3 |
+
fastapi>=0.111.0 # gr.Server extends FastAPI
|
| 4 |
+
uvicorn>=0.29.0
|
| 5 |
+
|
| 6 |
+
# ββ Model / Inference βββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
torch==2.1.2
|
| 8 |
+
torchvision==0.16.2
|
| 9 |
+
transformers==4.40.0
|
| 10 |
+
peft==0.10.0 # LoRA adapter loading
|
| 11 |
+
accelerate==0.27.0
|
| 12 |
+
sentencepiece==0.1.99 # MiniCPM-V tokenizer
|
| 13 |
+
Pillow==10.1.0
|
| 14 |
+
|
| 15 |
+
# ββ HuggingFace Ecosystem βββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
huggingface_hub>=0.22.0
|
| 17 |
+
datasets>=2.18.0
|
| 18 |
+
spaces>=0.28.0 # @spaces.GPU decorator for ZeroGPU
|
| 19 |
+
|
| 20 |
+
# ββ Dataset Pipeline (scripts only) ββββββββββββββββββββββββββββββ
|
| 21 |
+
requests>=2.31.0
|
| 22 |
+
beautifulsoup4>=4.12.0
|
| 23 |
+
selenium>=4.18.0 # Tier-2 scraping
|
| 24 |
+
open-clip-torch>=2.24.0 # CLIP deduplication
|
| 25 |
+
tqdm>=4.66.0
|
| 26 |
+
pandas>=2.2.0
|
| 27 |
+
scikit-learn>=1.4.0
|
| 28 |
+
|
| 29 |
+
# ββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
python-dotenv>=1.0.0
|
| 31 |
+
loguru>=0.7.0
|
| 32 |
+
numpy>=1.26.0
|
static/app.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* app.js β GharScan frontend logic
|
| 3 |
+
*
|
| 4 |
+
* Uses @gradio/client to connect to the gr.Server backend.
|
| 5 |
+
* Handles: camera capture, image upload, API call, report rendering.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { Client, handle_file } from
|
| 9 |
+
"https://cdn.jsdelivr.net/npm/@gradio/client@1.5.0/dist/index.min.js";
|
| 10 |
+
|
| 11 |
+
// ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 12 |
+
let selectedFile = null;
|
| 13 |
+
let selectedLang = "en";
|
| 14 |
+
let gradioClient = null;
|
| 15 |
+
|
| 16 |
+
const SEVERITY_COLORS = {
|
| 17 |
+
1: "#22c55e",
|
| 18 |
+
2: "#84cc16",
|
| 19 |
+
3: "#f59e0b",
|
| 20 |
+
4: "#ef4444",
|
| 21 |
+
5: "#991b1b",
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
// ββ Gradio client init (lazy, connects on first analysis) βββββββββββββββββββββ
|
| 25 |
+
async function getClient() {
|
| 26 |
+
if (!gradioClient) {
|
| 27 |
+
gradioClient = await Client.connect(window.location.origin);
|
| 28 |
+
}
|
| 29 |
+
return gradioClient;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// ββ Language toggle ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
window.setLang = function(lang) {
|
| 34 |
+
selectedLang = lang;
|
| 35 |
+
document.querySelectorAll(".lang-btn").forEach(btn => {
|
| 36 |
+
btn.classList.toggle("active", btn.dataset.lang === lang);
|
| 37 |
+
});
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
// ββ Image input handlers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 41 |
+
function bindInputs() {
|
| 42 |
+
document.getElementById("cameraInput").addEventListener("change", handleFileChange);
|
| 43 |
+
document.getElementById("uploadInput").addEventListener("change", handleFileChange);
|
| 44 |
+
|
| 45 |
+
// Drag-and-drop on capture card
|
| 46 |
+
const dropZone = document.getElementById("dropZone");
|
| 47 |
+
dropZone.addEventListener("dragover", e => { e.preventDefault(); dropZone.style.borderColor = "#3b82f6"; });
|
| 48 |
+
dropZone.addEventListener("dragleave", () => { dropZone.style.borderColor = ""; });
|
| 49 |
+
dropZone.addEventListener("drop", e => {
|
| 50 |
+
e.preventDefault();
|
| 51 |
+
dropZone.style.borderColor = "";
|
| 52 |
+
const file = e.dataTransfer.files[0];
|
| 53 |
+
if (file && file.type.startsWith("image/")) setSelectedFile(file);
|
| 54 |
+
});
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function handleFileChange(e) {
|
| 58 |
+
const file = e.target.files[0];
|
| 59 |
+
if (file) setSelectedFile(file);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function setSelectedFile(file) {
|
| 63 |
+
selectedFile = file;
|
| 64 |
+
|
| 65 |
+
// Show preview
|
| 66 |
+
const reader = new FileReader();
|
| 67 |
+
reader.onload = ev => {
|
| 68 |
+
const img = document.getElementById("previewImg");
|
| 69 |
+
img.src = ev.target.result;
|
| 70 |
+
img.classList.remove("hidden");
|
| 71 |
+
document.getElementById("capturePlaceholder").classList.add("hidden");
|
| 72 |
+
};
|
| 73 |
+
reader.readAsDataURL(file);
|
| 74 |
+
|
| 75 |
+
// Enable analyse button
|
| 76 |
+
document.getElementById("analyzeBtn").disabled = false;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// ββ Main analysis flow βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 80 |
+
window.runAnalysis = async function() {
|
| 81 |
+
if (!selectedFile) return;
|
| 82 |
+
|
| 83 |
+
showLoading();
|
| 84 |
+
|
| 85 |
+
try {
|
| 86 |
+
const client = await getClient();
|
| 87 |
+
|
| 88 |
+
// Simulate step progression in loading UI
|
| 89 |
+
progressLoadingSteps();
|
| 90 |
+
|
| 91 |
+
const result = await client.predict("/analyze_defect", {
|
| 92 |
+
image_path: handle_file(selectedFile),
|
| 93 |
+
language: selectedLang,
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
const report = result.data[0]; // API returns dict as first element
|
| 97 |
+
|
| 98 |
+
if (report.analysis_ok === false) {
|
| 99 |
+
showError(report.description || "Analysis failed. Please retake the photo.");
|
| 100 |
+
} else {
|
| 101 |
+
renderReport(report);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
} catch (err) {
|
| 105 |
+
console.error("GharScan API error:", err);
|
| 106 |
+
// Cold-start timeout is common on ZeroGPU β show friendly message
|
| 107 |
+
const msg = err.message?.includes("timeout")
|
| 108 |
+
? "The model is warming up (first request takes ~30s). Please try again in a moment."
|
| 109 |
+
: `Analysis failed: ${err.message}`;
|
| 110 |
+
showError(msg);
|
| 111 |
+
}
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
// ββ Report rendering βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
+
function renderReport(r) {
|
| 116 |
+
// ββ Structural banners ββ
|
| 117 |
+
const structBanner = document.getElementById("structuralBanner");
|
| 118 |
+
const safeBanner = document.getElementById("safeBanner");
|
| 119 |
+
if (r.is_structural) {
|
| 120 |
+
structBanner.classList.remove("hidden");
|
| 121 |
+
safeBanner.classList.add("hidden");
|
| 122 |
+
document.getElementById("structuralReasoning").textContent = r.structural_reasoning || "";
|
| 123 |
+
} else {
|
| 124 |
+
safeBanner.classList.remove("hidden");
|
| 125 |
+
structBanner.classList.add("hidden");
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// ββ Defect label ββ
|
| 129 |
+
document.getElementById("defectLabel").textContent = r.defect_display || r.defect_type;
|
| 130 |
+
document.getElementById("defectTypeSub").textContent = r.defect_type?.replace(/_/g, " ").toUpperCase();
|
| 131 |
+
|
| 132 |
+
// ββ Severity chip οΏ½οΏ½β
|
| 133 |
+
const chip = document.getElementById("severityChip");
|
| 134 |
+
const color = SEVERITY_COLORS[r.severity] || "#6b7280";
|
| 135 |
+
chip.style.borderColor = color;
|
| 136 |
+
document.getElementById("severityNum").textContent = r.severity;
|
| 137 |
+
document.getElementById("severityNum").style.color = color;
|
| 138 |
+
document.getElementById("severityWord").textContent = r.severity_label || "";
|
| 139 |
+
|
| 140 |
+
// ββ Severity meter animation ββ
|
| 141 |
+
const fill = document.getElementById("severityFill");
|
| 142 |
+
const pct = ((r.severity / 5) * 100).toFixed(1);
|
| 143 |
+
fill.style.width = pct + "%";
|
| 144 |
+
fill.style.background = color;
|
| 145 |
+
|
| 146 |
+
// ββ Text rows ββ
|
| 147 |
+
document.getElementById("defectDescription").textContent = r.description || "";
|
| 148 |
+
document.getElementById("primaryCause").textContent = r.primary_cause || "";
|
| 149 |
+
document.getElementById("immediateAction").textContent = r.immediate_action || "";
|
| 150 |
+
document.getElementById("urgencyDisplay").textContent = r.urgency_display || "";
|
| 151 |
+
|
| 152 |
+
// ββ Cost block ββ
|
| 153 |
+
const costRange = document.getElementById("costRange");
|
| 154 |
+
// Strip the leading βΉ if present (it's already in the HTML)
|
| 155 |
+
costRange.textContent = (r.cost_range_inr || "").replace(/^βΉ\s*/, "");
|
| 156 |
+
document.getElementById("professionalDisplay").textContent = r.professional_display || "";
|
| 157 |
+
|
| 158 |
+
// ββ Monsoon risk ββ
|
| 159 |
+
document.getElementById("monsoonWarning").classList.toggle("hidden", !r.monsoon_risk);
|
| 160 |
+
|
| 161 |
+
// ββ Liability banner β ALWAYS shown for severity >= 4 (Watch-Out 2) ββ
|
| 162 |
+
const liabBanner = document.getElementById("liabilityBanner");
|
| 163 |
+
if (r.show_liability_banner && r.liability_text) {
|
| 164 |
+
document.getElementById("liabilityText").textContent = r.liability_text;
|
| 165 |
+
liabBanner.classList.remove("hidden");
|
| 166 |
+
} else {
|
| 167 |
+
liabBanner.classList.add("hidden");
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// ββ Defect-specific disclaimer ββ
|
| 171 |
+
const discCard = document.getElementById("disclaimerCard");
|
| 172 |
+
if (r.disclaimer) {
|
| 173 |
+
document.getElementById("disclaimerText").textContent = r.disclaimer;
|
| 174 |
+
discCard.classList.remove("hidden");
|
| 175 |
+
} else {
|
| 176 |
+
discCard.classList.add("hidden");
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
showSection("reportSection");
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// ββ UI state helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 183 |
+
function showLoading() {
|
| 184 |
+
showSection("loadingSection");
|
| 185 |
+
// Reset step states
|
| 186 |
+
["step1","step2","step3"].forEach(id => {
|
| 187 |
+
const el = document.getElementById(id);
|
| 188 |
+
el.classList.remove("active","done");
|
| 189 |
+
});
|
| 190 |
+
document.getElementById("step1").classList.add("active");
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function progressLoadingSteps() {
|
| 194 |
+
const steps = ["step1","step2","step3"];
|
| 195 |
+
let i = 0;
|
| 196 |
+
const interval = setInterval(() => {
|
| 197 |
+
if (i > 0) document.getElementById(steps[i-1]).classList.replace("active","done");
|
| 198 |
+
if (i < steps.length) {
|
| 199 |
+
document.getElementById(steps[i]).classList.add("active");
|
| 200 |
+
const labels = [
|
| 201 |
+
"Step 1 of 3: Classifying defect type",
|
| 202 |
+
"Step 2 of 3: Assessing severity",
|
| 203 |
+
"Step 3 of 3: Calculating cost estimate",
|
| 204 |
+
];
|
| 205 |
+
document.getElementById("loadingSub").textContent = labels[i];
|
| 206 |
+
}
|
| 207 |
+
i++;
|
| 208 |
+
if (i >= steps.length + 1) clearInterval(interval);
|
| 209 |
+
}, 4000); // Advance every 4s (inference ~12-15s total)
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
function showError(msg) {
|
| 213 |
+
document.getElementById("errorMsg").textContent = msg;
|
| 214 |
+
showSection("errorSection");
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
function showSection(id) {
|
| 218 |
+
["captureSection","loadingSection","reportSection","errorSection"].forEach(s => {
|
| 219 |
+
document.getElementById(s)?.classList.toggle("hidden", s !== id);
|
| 220 |
+
});
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
window.resetToCapture = function() {
|
| 224 |
+
selectedFile = null;
|
| 225 |
+
document.getElementById("previewImg").src = "";
|
| 226 |
+
document.getElementById("previewImg").classList.add("hidden");
|
| 227 |
+
document.getElementById("capturePlaceholder").classList.remove("hidden");
|
| 228 |
+
document.getElementById("analyzeBtn").disabled = true;
|
| 229 |
+
document.getElementById("cameraInput").value = "";
|
| 230 |
+
document.getElementById("uploadInput").value = "";
|
| 231 |
+
showSection("captureSection");
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
// ββ Boot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 235 |
+
document.addEventListener("DOMContentLoaded", () => {
|
| 236 |
+
bindInputs();
|
| 237 |
+
|
| 238 |
+
// Warm up the Gradio client connection early (reduces first-click latency)
|
| 239 |
+
getClient().catch(() => {});
|
| 240 |
+
});
|
static/index.html
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
| 6 |
+
<title>GharScan β Wall Defect Inspector</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/styles.css" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
| 10 |
+
</head>
|
| 11 |
+
|
| 12 |
+
<body>
|
| 13 |
+
|
| 14 |
+
<!-- ββββββββββββββββββββββββββββββββ -->
|
| 15 |
+
<!-- β HEADER β -->
|
| 16 |
+
<!-- ββββββββββββββββββββββββββββββββ -->
|
| 17 |
+
<header class="site-header">
|
| 18 |
+
<div class="header-inner">
|
| 19 |
+
<div class="brand">
|
| 20 |
+
<span class="brand-icon">ποΈ</span>
|
| 21 |
+
<div>
|
| 22 |
+
<h1 class="brand-name">GharScan</h1>
|
| 23 |
+
<p class="brand-tagline">AI Building Defect Inspector Β· India</p>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
<div class="model-badge">
|
| 27 |
+
<span class="model-dot"></span>
|
| 28 |
+
<span class="mono">MiniCPM-V 2.0 Β· 2.8B</span>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</header>
|
| 32 |
+
|
| 33 |
+
<!-- ββββββββββββββββββββββββββββββββ -->
|
| 34 |
+
<!-- β CAPTURE ZONE β -->
|
| 35 |
+
<!-- ββββββββββββββββββββββββββββββββ -->
|
| 36 |
+
<main class="app-main">
|
| 37 |
+
<section class="capture-section" id="captureSection">
|
| 38 |
+
<p class="section-label mono">// STEP 1 β PHOTOGRAPH THE DEFECT</p>
|
| 39 |
+
|
| 40 |
+
<div class="capture-card" id="dropZone">
|
| 41 |
+
<div class="capture-preview" id="capturePreview">
|
| 42 |
+
<div class="capture-placeholder" id="capturePlaceholder">
|
| 43 |
+
<span class="placeholder-icon">π·</span>
|
| 44 |
+
<p>Point at any crack, stain, or damage on your wall, ceiling, or floor</p>
|
| 45 |
+
</div>
|
| 46 |
+
<img id="previewImg" class="preview-image hidden" alt="Uploaded defect image" />
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div class="capture-actions">
|
| 50 |
+
<!-- Mobile: opens rear camera directly via capture="environment" -->
|
| 51 |
+
<label class="btn btn-primary" for="cameraInput">
|
| 52 |
+
<span>πΈ</span> Take Photo
|
| 53 |
+
</label>
|
| 54 |
+
<input
|
| 55 |
+
type="file"
|
| 56 |
+
id="cameraInput"
|
| 57 |
+
accept="image/*"
|
| 58 |
+
capture="environment"
|
| 59 |
+
hidden
|
| 60 |
+
/>
|
| 61 |
+
|
| 62 |
+
<label class="btn btn-secondary" for="uploadInput">
|
| 63 |
+
<span>π</span> Upload Image
|
| 64 |
+
</label>
|
| 65 |
+
<input
|
| 66 |
+
type="file"
|
| 67 |
+
id="uploadInput"
|
| 68 |
+
accept="image/jpeg,image/png,image/webp"
|
| 69 |
+
hidden
|
| 70 |
+
/>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div class="language-toggle">
|
| 74 |
+
<span class="toggle-label">Output language:</span>
|
| 75 |
+
<button class="lang-btn active" data-lang="en" onclick="setLang('en')">English</button>
|
| 76 |
+
<button class="lang-btn" data-lang="hi" onclick="setLang('hi')">ΰ€Ήΰ€Ώΰ€ΰ€¦ΰ₯</button>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<button class="btn btn-analyze" id="analyzeBtn" disabled onclick="runAnalysis()">
|
| 81 |
+
<span id="analyzeBtnIcon">π</span>
|
| 82 |
+
<span id="analyzeBtnText">Analyse Defect</span>
|
| 83 |
+
</button>
|
| 84 |
+
</section>
|
| 85 |
+
|
| 86 |
+
<!-- ββββββββββββββββββββββββββββββββ -->
|
| 87 |
+
<!-- β LOADING STATE β -->
|
| 88 |
+
<!-- ββββββββββββββββββββββββββββββββ -->
|
| 89 |
+
<section class="loading-section hidden" id="loadingSection">
|
| 90 |
+
<div class="loading-card">
|
| 91 |
+
<div class="loading-spinner"></div>
|
| 92 |
+
<p class="loading-title" id="loadingTitle">Initialising inspectorβ¦</p>
|
| 93 |
+
<p class="loading-sub mono" id="loadingSub">Step 1 of 3: Classifying defect type</p>
|
| 94 |
+
<div class="loading-steps">
|
| 95 |
+
<div class="load-step" id="step1" data-active="true">π Defect Classification</div>
|
| 96 |
+
<div class="load-step" id="step2">π Severity Assessment</div>
|
| 97 |
+
<div class="load-step" id="step3">βΉ Cost Estimation</div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
</section>
|
| 101 |
+
|
| 102 |
+
<!-- ββββββββββββββββββββββββββββββββ -->
|
| 103 |
+
<!-- β INSPECTION REPORT β -->
|
| 104 |
+
<!-- ββββββββββββββββββββββββββββββββ -->
|
| 105 |
+
<section class="report-section hidden" id="reportSection">
|
| 106 |
+
<p class="section-label mono">// INSPECTION REPORT</p>
|
| 107 |
+
|
| 108 |
+
<!-- Structural risk banner (shown only when is_structural = true) -->
|
| 109 |
+
<div class="structural-banner danger hidden" id="structuralBanner">
|
| 110 |
+
<span class="banner-icon">β οΈ</span>
|
| 111 |
+
<div>
|
| 112 |
+
<strong>STRUCTURAL RISK DETECTED</strong>
|
| 113 |
+
<p id="structuralReasoning"></p>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<!-- Safe banner (shown when is_structural = false) -->
|
| 118 |
+
<div class="structural-banner safe hidden" id="safeBanner">
|
| 119 |
+
<span class="banner-icon">β
</span>
|
| 120 |
+
<strong>Not Structural β No Immediate Safety Risk</strong>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<!-- Main report card -->
|
| 124 |
+
<div class="report-card">
|
| 125 |
+
|
| 126 |
+
<!-- Header row: defect label + severity chip -->
|
| 127 |
+
<div class="report-header">
|
| 128 |
+
<div>
|
| 129 |
+
<span class="defect-label" id="defectLabel">Settlement Crack</span>
|
| 130 |
+
<span class="defect-type-sub mono" id="defectTypeSub"></span>
|
| 131 |
+
</div>
|
| 132 |
+
<div class="severity-chip" id="severityChip">
|
| 133 |
+
<span class="severity-num" id="severityNum">2</span>
|
| 134 |
+
<span class="severity-word" id="severityWord">Minor</span>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<!-- Severity meter bar -->
|
| 139 |
+
<div class="severity-meter-wrap">
|
| 140 |
+
<div class="severity-track">
|
| 141 |
+
<div class="severity-fill" id="severityFill"></div>
|
| 142 |
+
</div>
|
| 143 |
+
<div class="severity-ticks">
|
| 144 |
+
<span>Cosmetic</span><span></span><span>Moderate</span><span></span><span>Critical</span>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<div class="report-divider"></div>
|
| 149 |
+
|
| 150 |
+
<!-- Description row -->
|
| 151 |
+
<div class="report-row">
|
| 152 |
+
<span class="row-key mono">WHAT IT IS</span>
|
| 153 |
+
<span class="row-val" id="defectDescription"></span>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<div class="report-row">
|
| 157 |
+
<span class="row-key mono">WHY IT HAPPENS</span>
|
| 158 |
+
<span class="row-val" id="primaryCause"></span>
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
+
<div class="report-divider"></div>
|
| 162 |
+
|
| 163 |
+
<!-- Action row (highlighted) -->
|
| 164 |
+
<div class="report-row action-highlight">
|
| 165 |
+
<span class="row-key mono">WHAT TO DO</span>
|
| 166 |
+
<span class="row-val action-text" id="immediateAction"></span>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
<div class="report-row">
|
| 170 |
+
<span class="row-key mono">WHEN TO ACT</span>
|
| 171 |
+
<span class="row-val urgency-text" id="urgencyDisplay"></span>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
<div class="report-divider"></div>
|
| 175 |
+
|
| 176 |
+
<!-- Cost + Professional -->
|
| 177 |
+
<div class="cost-block">
|
| 178 |
+
<div class="cost-main">
|
| 179 |
+
<span class="cost-rupee">βΉ</span>
|
| 180 |
+
<span class="cost-range" id="costRange"></span>
|
| 181 |
+
</div>
|
| 182 |
+
<div class="professional-line">
|
| 183 |
+
<span class="pro-icon">π·</span>
|
| 184 |
+
<span id="professionalDisplay">Licensed Mason</span>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
<!-- Monsoon risk flag -->
|
| 189 |
+
<div class="monsoon-warning hidden" id="monsoonWarning">
|
| 190 |
+
π§οΈ <strong>Monsoon Risk:</strong> This defect worsens significantly during heavy rainfall.
|
| 191 |
+
Address before June if possible.
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
</div><!-- end report-card -->
|
| 195 |
+
|
| 196 |
+
<!-- Liability disclaimer β ALWAYS shown for Severity 4-5 (Watch-Out 2) -->
|
| 197 |
+
<div class="liability-banner hidden" id="liabilityBanner">
|
| 198 |
+
<span>β οΈ</span>
|
| 199 |
+
<p id="liabilityText"></p>
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
<!-- Defect-specific disclaimer -->
|
| 203 |
+
<div class="disclaimer-card hidden" id="disclaimerCard">
|
| 204 |
+
<p id="disclaimerText"></p>
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
<!-- Scan again -->
|
| 208 |
+
<button class="btn btn-secondary btn-full" onclick="resetToCapture()">
|
| 209 |
+
πΈ Scan Another Defect
|
| 210 |
+
</button>
|
| 211 |
+
|
| 212 |
+
</section><!-- end report-section -->
|
| 213 |
+
|
| 214 |
+
<!-- ββββββββββββββββββββββββββββββββ -->
|
| 215 |
+
<!-- β ERROR STATE β -->
|
| 216 |
+
<!-- ββββββββββββββββββββββββββββββββ -->
|
| 217 |
+
<section class="error-section hidden" id="errorSection">
|
| 218 |
+
<div class="error-card">
|
| 219 |
+
<span class="error-icon">π·</span>
|
| 220 |
+
<p class="error-title">Analysis Incomplete</p>
|
| 221 |
+
<p class="error-msg" id="errorMsg">Please retake the photo with better lighting.</p>
|
| 222 |
+
<button class="btn btn-secondary" onclick="resetToCapture()">Try Again</button>
|
| 223 |
+
</div>
|
| 224 |
+
</section>
|
| 225 |
+
|
| 226 |
+
</main>
|
| 227 |
+
|
| 228 |
+
<!-- ββββββββββββββββββββββββββββββββ -->
|
| 229 |
+
<!-- β FOOTER β -->
|
| 230 |
+
<!-- ββββββββββββββββββββββββββββββββ -->
|
| 231 |
+
<footer class="site-footer">
|
| 232 |
+
<p class="mono">MiniCPM-V 2.0 by OpenBMB Β· Fine-tuned for Indian residential defects</p>
|
| 233 |
+
<p>ποΈ Built for <a href="https://huggingface.co/build-small-hackathon" target="_blank">Build Small Hackathon 2026</a> Β· Track: Backyard AI</p>
|
| 234 |
+
<p class="disclaimer-footer">GharScan is a triage aid, not a substitute for professional structural assessment.</p>
|
| 235 |
+
</footer>
|
| 236 |
+
|
| 237 |
+
<script src="/static/app.js" type="module"></script>
|
| 238 |
+
</body>
|
| 239 |
+
</html>
|
static/styles.css
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
GharScan β styles.css
|
| 3 |
+
Concrete-grey inspection report aesthetic.
|
| 4 |
+
Mobile-first. Custom UI for Off-Brand badge.
|
| 5 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 6 |
+
|
| 7 |
+
/* ββ Reset & base βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 8 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 9 |
+
|
| 10 |
+
:root {
|
| 11 |
+
--bg-primary: #0f1114;
|
| 12 |
+
--bg-card: #181b20;
|
| 13 |
+
--bg-elevated: #20242b;
|
| 14 |
+
--bg-input: #1a1d23;
|
| 15 |
+
|
| 16 |
+
--border: #2a2f38;
|
| 17 |
+
--border-subtle: #1e2229;
|
| 18 |
+
|
| 19 |
+
--text-primary: #e8eaed;
|
| 20 |
+
--text-secondary: #8b9099;
|
| 21 |
+
--text-muted: #555d6b;
|
| 22 |
+
--text-mono: #6fb3e0;
|
| 23 |
+
|
| 24 |
+
--accent-blue: #3b82f6;
|
| 25 |
+
--accent-green: #22c55e;
|
| 26 |
+
--accent-yellow: #f59e0b;
|
| 27 |
+
--accent-orange: #ef4444;
|
| 28 |
+
--accent-dark-red: #7f1d1d;
|
| 29 |
+
|
| 30 |
+
--severity-1: #22c55e;
|
| 31 |
+
--severity-2: #84cc16;
|
| 32 |
+
--severity-3: #f59e0b;
|
| 33 |
+
--severity-4: #ef4444;
|
| 34 |
+
--severity-5: #991b1b;
|
| 35 |
+
|
| 36 |
+
--font-sans: 'Inter', system-ui, sans-serif;
|
| 37 |
+
--font-mono: 'IBM Plex Mono', 'Courier New', monospace;
|
| 38 |
+
|
| 39 |
+
--radius-sm: 6px;
|
| 40 |
+
--radius-md: 10px;
|
| 41 |
+
--radius-lg: 14px;
|
| 42 |
+
|
| 43 |
+
--shadow-card: 0 2px 12px rgba(0,0,0,0.4);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
html { scroll-behavior: smooth; }
|
| 47 |
+
|
| 48 |
+
body {
|
| 49 |
+
font-family: var(--font-sans);
|
| 50 |
+
background: var(--bg-primary);
|
| 51 |
+
color: var(--text-primary);
|
| 52 |
+
min-height: 100vh;
|
| 53 |
+
font-size: 15px;
|
| 54 |
+
line-height: 1.55;
|
| 55 |
+
-webkit-font-smoothing: antialiased;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.mono { font-family: var(--font-mono); }
|
| 59 |
+
.hidden { display: none !important; }
|
| 60 |
+
|
| 61 |
+
/* ββ Header ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 62 |
+
.site-header {
|
| 63 |
+
background: var(--bg-card);
|
| 64 |
+
border-bottom: 1px solid var(--border);
|
| 65 |
+
padding: 14px 16px;
|
| 66 |
+
position: sticky;
|
| 67 |
+
top: 0;
|
| 68 |
+
z-index: 100;
|
| 69 |
+
}
|
| 70 |
+
.header-inner {
|
| 71 |
+
max-width: 640px;
|
| 72 |
+
margin: 0 auto;
|
| 73 |
+
display: flex;
|
| 74 |
+
align-items: center;
|
| 75 |
+
justify-content: space-between;
|
| 76 |
+
}
|
| 77 |
+
.brand { display: flex; align-items: center; gap: 10px; }
|
| 78 |
+
.brand-icon { font-size: 26px; }
|
| 79 |
+
.brand-name { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
|
| 80 |
+
.brand-tagline { font-size: 11px; color: var(--text-secondary); margin-top: 1px; }
|
| 81 |
+
.model-badge {
|
| 82 |
+
display: flex;
|
| 83 |
+
align-items: center;
|
| 84 |
+
gap: 6px;
|
| 85 |
+
background: var(--bg-elevated);
|
| 86 |
+
border: 1px solid var(--border);
|
| 87 |
+
border-radius: 20px;
|
| 88 |
+
padding: 4px 10px;
|
| 89 |
+
font-size: 11px;
|
| 90 |
+
color: var(--text-mono);
|
| 91 |
+
font-family: var(--font-mono);
|
| 92 |
+
}
|
| 93 |
+
.model-dot {
|
| 94 |
+
width: 7px;
|
| 95 |
+
height: 7px;
|
| 96 |
+
border-radius: 50%;
|
| 97 |
+
background: var(--accent-green);
|
| 98 |
+
animation: pulse 2s ease-in-out infinite;
|
| 99 |
+
}
|
| 100 |
+
@keyframes pulse {
|
| 101 |
+
0%, 100% { opacity: 1; }
|
| 102 |
+
50% { opacity: 0.4; }
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/* ββ Main layout βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 106 |
+
.app-main {
|
| 107 |
+
max-width: 640px;
|
| 108 |
+
margin: 0 auto;
|
| 109 |
+
padding: 20px 16px 60px;
|
| 110 |
+
}
|
| 111 |
+
.section-label {
|
| 112 |
+
font-size: 11px;
|
| 113 |
+
font-weight: 500;
|
| 114 |
+
color: var(--text-mono);
|
| 115 |
+
letter-spacing: .06em;
|
| 116 |
+
margin-bottom: 10px;
|
| 117 |
+
font-family: var(--font-mono);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/* ββ Capture section ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 121 |
+
.capture-card {
|
| 122 |
+
background: var(--bg-card);
|
| 123 |
+
border: 1px solid var(--border);
|
| 124 |
+
border-radius: var(--radius-lg);
|
| 125 |
+
overflow: hidden;
|
| 126 |
+
margin-bottom: 14px;
|
| 127 |
+
}
|
| 128 |
+
.capture-preview {
|
| 129 |
+
width: 100%;
|
| 130 |
+
aspect-ratio: 4/3;
|
| 131 |
+
background: var(--bg-input);
|
| 132 |
+
display: flex;
|
| 133 |
+
align-items: center;
|
| 134 |
+
justify-content: center;
|
| 135 |
+
overflow: hidden;
|
| 136 |
+
border-bottom: 1px solid var(--border);
|
| 137 |
+
}
|
| 138 |
+
.capture-placeholder {
|
| 139 |
+
display: flex;
|
| 140 |
+
flex-direction: column;
|
| 141 |
+
align-items: center;
|
| 142 |
+
gap: 10px;
|
| 143 |
+
color: var(--text-muted);
|
| 144 |
+
padding: 20px;
|
| 145 |
+
text-align: center;
|
| 146 |
+
}
|
| 147 |
+
.placeholder-icon { font-size: 40px; }
|
| 148 |
+
.capture-placeholder p { font-size: 13px; max-width: 240px; }
|
| 149 |
+
|
| 150 |
+
.preview-image {
|
| 151 |
+
width: 100%;
|
| 152 |
+
height: 100%;
|
| 153 |
+
object-fit: cover;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.capture-actions {
|
| 157 |
+
display: flex;
|
| 158 |
+
gap: 10px;
|
| 159 |
+
padding: 14px;
|
| 160 |
+
}
|
| 161 |
+
.language-toggle {
|
| 162 |
+
display: flex;
|
| 163 |
+
align-items: center;
|
| 164 |
+
gap: 8px;
|
| 165 |
+
padding: 0 14px 14px;
|
| 166 |
+
font-size: 12px;
|
| 167 |
+
color: var(--text-secondary);
|
| 168 |
+
}
|
| 169 |
+
.lang-btn {
|
| 170 |
+
background: var(--bg-elevated);
|
| 171 |
+
border: 1px solid var(--border);
|
| 172 |
+
color: var(--text-secondary);
|
| 173 |
+
border-radius: 4px;
|
| 174 |
+
padding: 3px 10px;
|
| 175 |
+
font-size: 12px;
|
| 176 |
+
cursor: pointer;
|
| 177 |
+
}
|
| 178 |
+
.lang-btn.active {
|
| 179 |
+
background: var(--accent-blue);
|
| 180 |
+
border-color: var(--accent-blue);
|
| 181 |
+
color: #fff;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/* ββ Buttons ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 185 |
+
.btn {
|
| 186 |
+
display: inline-flex;
|
| 187 |
+
align-items: center;
|
| 188 |
+
justify-content: center;
|
| 189 |
+
gap: 7px;
|
| 190 |
+
padding: 10px 16px;
|
| 191 |
+
border-radius: var(--radius-md);
|
| 192 |
+
font-size: 14px;
|
| 193 |
+
font-weight: 500;
|
| 194 |
+
cursor: pointer;
|
| 195 |
+
border: none;
|
| 196 |
+
transition: background 0.15s, opacity 0.15s;
|
| 197 |
+
text-decoration: none;
|
| 198 |
+
white-space: nowrap;
|
| 199 |
+
flex: 1;
|
| 200 |
+
}
|
| 201 |
+
.btn-primary {
|
| 202 |
+
background: var(--accent-blue);
|
| 203 |
+
color: #fff;
|
| 204 |
+
}
|
| 205 |
+
.btn-primary:hover { background: #2563eb; }
|
| 206 |
+
|
| 207 |
+
.btn-secondary {
|
| 208 |
+
background: var(--bg-elevated);
|
| 209 |
+
color: var(--text-primary);
|
| 210 |
+
border: 1px solid var(--border);
|
| 211 |
+
}
|
| 212 |
+
.btn-secondary:hover { background: #252930; }
|
| 213 |
+
|
| 214 |
+
.btn-analyze {
|
| 215 |
+
width: 100%;
|
| 216 |
+
padding: 14px;
|
| 217 |
+
font-size: 15px;
|
| 218 |
+
font-weight: 600;
|
| 219 |
+
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
| 220 |
+
color: #fff;
|
| 221 |
+
border-radius: var(--radius-md);
|
| 222 |
+
margin-bottom: 0;
|
| 223 |
+
cursor: pointer;
|
| 224 |
+
border: none;
|
| 225 |
+
transition: opacity 0.15s, transform 0.1s;
|
| 226 |
+
}
|
| 227 |
+
.btn-analyze:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 228 |
+
.btn-analyze:not(:disabled):hover { opacity: 0.9; }
|
| 229 |
+
.btn-full { width: 100%; margin-top: 12px; }
|
| 230 |
+
|
| 231 |
+
/* ββ Loading section ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 232 |
+
.loading-section { margin-top: 24px; }
|
| 233 |
+
.loading-card {
|
| 234 |
+
background: var(--bg-card);
|
| 235 |
+
border: 1px solid var(--border);
|
| 236 |
+
border-radius: var(--radius-lg);
|
| 237 |
+
padding: 28px 20px;
|
| 238 |
+
text-align: center;
|
| 239 |
+
}
|
| 240 |
+
.loading-spinner {
|
| 241 |
+
width: 44px;
|
| 242 |
+
height: 44px;
|
| 243 |
+
border: 3px solid var(--border);
|
| 244 |
+
border-top-color: var(--accent-blue);
|
| 245 |
+
border-radius: 50%;
|
| 246 |
+
animation: spin 0.8s linear infinite;
|
| 247 |
+
margin: 0 auto 16px;
|
| 248 |
+
}
|
| 249 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 250 |
+
.loading-title { font-size: 15px; font-weight: 600; margin-bottom: 6px; }
|
| 251 |
+
.loading-sub { font-size: 12px; color: var(--text-mono); margin-bottom: 20px; }
|
| 252 |
+
|
| 253 |
+
.loading-steps {
|
| 254 |
+
display: flex;
|
| 255 |
+
flex-direction: column;
|
| 256 |
+
gap: 8px;
|
| 257 |
+
text-align: left;
|
| 258 |
+
}
|
| 259 |
+
.load-step {
|
| 260 |
+
padding: 10px 14px;
|
| 261 |
+
border-radius: var(--radius-sm);
|
| 262 |
+
font-size: 13px;
|
| 263 |
+
color: var(--text-muted);
|
| 264 |
+
background: var(--bg-elevated);
|
| 265 |
+
border-left: 3px solid transparent;
|
| 266 |
+
transition: all 0.3s;
|
| 267 |
+
}
|
| 268 |
+
.load-step.active {
|
| 269 |
+
color: var(--text-primary);
|
| 270 |
+
border-color: var(--accent-blue);
|
| 271 |
+
}
|
| 272 |
+
.load-step.done {
|
| 273 |
+
color: var(--accent-green);
|
| 274 |
+
border-color: var(--accent-green);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/* ββ Report section ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 278 |
+
.report-section { margin-top: 8px; }
|
| 279 |
+
|
| 280 |
+
.structural-banner {
|
| 281 |
+
display: flex;
|
| 282 |
+
align-items: flex-start;
|
| 283 |
+
gap: 12px;
|
| 284 |
+
border-radius: var(--radius-md);
|
| 285 |
+
padding: 14px 16px;
|
| 286 |
+
margin-bottom: 12px;
|
| 287 |
+
font-size: 14px;
|
| 288 |
+
}
|
| 289 |
+
.structural-banner.danger {
|
| 290 |
+
background: rgba(127,29,29,0.25);
|
| 291 |
+
border: 1px solid #7f1d1d;
|
| 292 |
+
color: #fca5a5;
|
| 293 |
+
}
|
| 294 |
+
.structural-banner.safe {
|
| 295 |
+
background: rgba(21,128,61,0.2);
|
| 296 |
+
border: 1px solid #166534;
|
| 297 |
+
color: #86efac;
|
| 298 |
+
}
|
| 299 |
+
.banner-icon { font-size: 20px; flex-shrink: 0; }
|
| 300 |
+
|
| 301 |
+
.report-card {
|
| 302 |
+
background: var(--bg-card);
|
| 303 |
+
border: 1px solid var(--border);
|
| 304 |
+
border-radius: var(--radius-lg);
|
| 305 |
+
padding: 20px;
|
| 306 |
+
margin-bottom: 12px;
|
| 307 |
+
box-shadow: var(--shadow-card);
|
| 308 |
+
}
|
| 309 |
+
.report-header {
|
| 310 |
+
display: flex;
|
| 311 |
+
align-items: flex-start;
|
| 312 |
+
justify-content: space-between;
|
| 313 |
+
margin-bottom: 16px;
|
| 314 |
+
gap: 12px;
|
| 315 |
+
}
|
| 316 |
+
.defect-label {
|
| 317 |
+
font-size: 18px;
|
| 318 |
+
font-weight: 700;
|
| 319 |
+
display: block;
|
| 320 |
+
line-height: 1.2;
|
| 321 |
+
}
|
| 322 |
+
.defect-type-sub {
|
| 323 |
+
font-size: 11px;
|
| 324 |
+
color: var(--text-secondary);
|
| 325 |
+
display: block;
|
| 326 |
+
margin-top: 3px;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.severity-chip {
|
| 330 |
+
display: flex;
|
| 331 |
+
flex-direction: column;
|
| 332 |
+
align-items: center;
|
| 333 |
+
background: var(--bg-elevated);
|
| 334 |
+
border-radius: var(--radius-md);
|
| 335 |
+
padding: 8px 14px;
|
| 336 |
+
min-width: 70px;
|
| 337 |
+
flex-shrink: 0;
|
| 338 |
+
border: 2px solid var(--border);
|
| 339 |
+
}
|
| 340 |
+
.severity-num { font-size: 22px; font-weight: 700; font-family: var(--font-mono); line-height: 1; }
|
| 341 |
+
.severity-word { font-size: 10px; color: var(--text-secondary); margin-top: 3px; text-transform: uppercase; letter-spacing: .04em; }
|
| 342 |
+
|
| 343 |
+
/* Severity meter */
|
| 344 |
+
.severity-meter-wrap { margin-bottom: 16px; }
|
| 345 |
+
.severity-track {
|
| 346 |
+
height: 8px;
|
| 347 |
+
background: var(--bg-elevated);
|
| 348 |
+
border-radius: 4px;
|
| 349 |
+
overflow: hidden;
|
| 350 |
+
margin-bottom: 4px;
|
| 351 |
+
}
|
| 352 |
+
.severity-fill {
|
| 353 |
+
height: 100%;
|
| 354 |
+
border-radius: 4px;
|
| 355 |
+
transition: width 0.6s cubic-bezier(.22,.68,0,1.2), background 0.4s;
|
| 356 |
+
}
|
| 357 |
+
.severity-ticks {
|
| 358 |
+
display: flex;
|
| 359 |
+
justify-content: space-between;
|
| 360 |
+
font-size: 10px;
|
| 361 |
+
color: var(--text-muted);
|
| 362 |
+
font-family: var(--font-mono);
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
.report-divider { height: 1px; background: var(--border-subtle); margin: 14px 0; }
|
| 366 |
+
|
| 367 |
+
.report-row {
|
| 368 |
+
display: flex;
|
| 369 |
+
flex-direction: column;
|
| 370 |
+
gap: 4px;
|
| 371 |
+
margin-bottom: 12px;
|
| 372 |
+
}
|
| 373 |
+
.row-key {
|
| 374 |
+
font-size: 10px;
|
| 375 |
+
color: var(--text-mono);
|
| 376 |
+
letter-spacing: .08em;
|
| 377 |
+
font-weight: 500;
|
| 378 |
+
}
|
| 379 |
+
.row-val { font-size: 14px; color: var(--text-primary); line-height: 1.5; }
|
| 380 |
+
|
| 381 |
+
.action-highlight {
|
| 382 |
+
background: rgba(59,130,246,0.08);
|
| 383 |
+
border: 1px solid rgba(59,130,246,0.2);
|
| 384 |
+
border-radius: var(--radius-md);
|
| 385 |
+
padding: 12px;
|
| 386 |
+
margin-bottom: 12px;
|
| 387 |
+
}
|
| 388 |
+
.action-text { font-weight: 500; color: #93c5fd; }
|
| 389 |
+
.urgency-text { font-weight: 500; }
|
| 390 |
+
|
| 391 |
+
/* Cost block */
|
| 392 |
+
.cost-block {
|
| 393 |
+
background: var(--bg-elevated);
|
| 394 |
+
border-radius: var(--radius-md);
|
| 395 |
+
padding: 14px;
|
| 396 |
+
margin-bottom: 12px;
|
| 397 |
+
}
|
| 398 |
+
.cost-main {
|
| 399 |
+
display: flex;
|
| 400 |
+
align-items: baseline;
|
| 401 |
+
gap: 6px;
|
| 402 |
+
margin-bottom: 8px;
|
| 403 |
+
}
|
| 404 |
+
.cost-rupee { font-size: 22px; font-weight: 700; color: var(--accent-green); font-family: var(--font-mono); }
|
| 405 |
+
.cost-range { font-size: 20px; font-weight: 700; color: var(--accent-green); font-family: var(--font-mono); }
|
| 406 |
+
.professional-line {
|
| 407 |
+
display: flex;
|
| 408 |
+
align-items: center;
|
| 409 |
+
gap: 8px;
|
| 410 |
+
font-size: 13px;
|
| 411 |
+
color: var(--text-secondary);
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
/* Monsoon warning */
|
| 415 |
+
.monsoon-warning {
|
| 416 |
+
background: rgba(234,179,8,0.1);
|
| 417 |
+
border: 1px solid rgba(234,179,8,0.3);
|
| 418 |
+
border-radius: var(--radius-sm);
|
| 419 |
+
padding: 10px 14px;
|
| 420 |
+
font-size: 13px;
|
| 421 |
+
color: #fde68a;
|
| 422 |
+
margin-top: 10px;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
/* Liability banner (Watch-Out 2) */
|
| 426 |
+
.liability-banner {
|
| 427 |
+
display: flex;
|
| 428 |
+
gap: 10px;
|
| 429 |
+
background: rgba(239,68,68,0.1);
|
| 430 |
+
border: 1px solid rgba(239,68,68,0.3);
|
| 431 |
+
border-radius: var(--radius-md);
|
| 432 |
+
padding: 14px;
|
| 433 |
+
font-size: 12px;
|
| 434 |
+
color: #fca5a5;
|
| 435 |
+
margin-bottom: 10px;
|
| 436 |
+
line-height: 1.5;
|
| 437 |
+
}
|
| 438 |
+
.disclaimer-card {
|
| 439 |
+
background: var(--bg-elevated);
|
| 440 |
+
border-left: 3px solid var(--accent-yellow);
|
| 441 |
+
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
| 442 |
+
padding: 10px 14px;
|
| 443 |
+
font-size: 12px;
|
| 444 |
+
color: var(--text-secondary);
|
| 445 |
+
margin-bottom: 10px;
|
| 446 |
+
line-height: 1.5;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
/* ββ Error state ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 450 |
+
.error-card {
|
| 451 |
+
background: var(--bg-card);
|
| 452 |
+
border: 1px solid var(--border);
|
| 453 |
+
border-radius: var(--radius-lg);
|
| 454 |
+
padding: 32px 20px;
|
| 455 |
+
text-align: center;
|
| 456 |
+
}
|
| 457 |
+
.error-icon { font-size: 40px; }
|
| 458 |
+
.error-title { font-size: 17px; font-weight: 600; margin: 12px 0 6px; }
|
| 459 |
+
.error-msg { font-size: 14px; color: var(--text-secondary); margin-bottom: 20px; }
|
| 460 |
+
|
| 461 |
+
/* ββ Footer βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 462 |
+
.site-footer {
|
| 463 |
+
text-align: center;
|
| 464 |
+
padding: 24px 16px;
|
| 465 |
+
font-size: 11px;
|
| 466 |
+
color: var(--text-muted);
|
| 467 |
+
border-top: 1px solid var(--border-subtle);
|
| 468 |
+
line-height: 1.8;
|
| 469 |
+
}
|
| 470 |
+
.site-footer a { color: var(--text-secondary); }
|
| 471 |
+
.site-footer .disclaimer-footer { color: var(--text-muted); margin-top: 4px; }
|
| 472 |
+
|
| 473 |
+
/* ββ Responsive βββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 474 |
+
@media (min-width: 480px) {
|
| 475 |
+
.capture-actions { flex-direction: row; }
|
| 476 |
+
}
|
| 477 |
+
@media (max-width: 360px) {
|
| 478 |
+
.brand-tagline { display: none; }
|
| 479 |
+
.model-badge { display: none; }
|
| 480 |
+
}
|