| """ |
| inference.py — GharScan Qwen2-VL-2B inference pipeline |
| """ |
| import re, json, time, torch |
| from PIL import Image |
| from loguru import logger |
| from transformers import Qwen2VLForConditionalGeneration, AutoProcessor |
| from qwen_vl_utils import process_vision_info |
| from cost_matrix import build_cost_response |
|
|
| BASE_MODEL_ID = "Qwen/Qwen2-VL-2B-Instruct" |
| LORA_MODEL_ID = "ritvik360/gharscan-qwen2vl-lora" |
| MAX_NEW_TOKENS = 256 |
| TEMPERATURE = 0.05 |
|
|
| _model = None |
| _processor = None |
|
|
| def _load_model_if_needed(): |
| global _model, _processor |
| if _model is not None: |
| return |
| logger.info(f"Loading {LORA_MODEL_ID} …") |
| t0 = time.monotonic() |
| try: |
| from peft import PeftModel |
| base = Qwen2VLForConditionalGeneration.from_pretrained( |
| BASE_MODEL_ID, torch_dtype=torch.bfloat16) |
| _model = PeftModel.from_pretrained(base, LORA_MODEL_ID) |
| _model = _model.merge_and_unload() |
| logger.info("LoRA loaded ✅") |
| except Exception as e: |
| logger.warning(f"LoRA failed ({e}) — using base model") |
| _model = Qwen2VLForConditionalGeneration.from_pretrained( |
| BASE_MODEL_ID, torch_dtype=torch.bfloat16) |
| _processor = AutoProcessor.from_pretrained(BASE_MODEL_ID) |
| _model.eval() |
| logger.info(f"Model ready in {time.monotonic()-t0:.1f}s") |
|
|
| def _call_vlm(image: Image.Image, prompt: str) -> dict: |
| messages = [{"role": "user", "content": [ |
| {"type": "image", "image": image}, |
| {"type": "text", "text": prompt} |
| ]}] |
| text = _processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) |
| image_inputs, _ = process_vision_info(messages) |
| inputs = _processor(text=[text], images=image_inputs, return_tensors="pt").to(_model.device) |
| with torch.no_grad(): |
| out = _model.generate(**inputs, max_new_tokens=MAX_NEW_TOKENS, |
| temperature=TEMPERATURE, do_sample=TEMPERATURE > 0) |
| gen = out[0][inputs["input_ids"].shape[1]:] |
| raw = _processor.decode(gen, skip_special_tokens=True).strip() |
| try: |
| return json.loads(raw) |
| except Exception: |
| m = re.search(r'\{.*\}', raw, re.DOTALL) |
| try: |
| return json.loads(m.group()) if m else {} |
| except Exception: |
| return {} |
|
|
| _CLASSIFY_PROMPT = """You are GharScan, an expert Indian building inspector. |
| Analyze this image and return ONLY valid JSON: |
| {"defect_type":"<hairline_crack|settlement_crack|structural_crack|water_seepage|efflorescence|spalling|rebar_rust|plaster_delamination|no_defect>","description":"<25-word description>","primary_cause":"<1 sentence>","monsoon_risk":<true|false>,"confidence":<0.0-1.0>}""" |
|
|
| _SEVERITY_PROMPT = """You are GharScan. The defect is: {defect_type}. |
| Return ONLY valid JSON: |
| {{"severity":<1|2|3|4|5>,"is_structural":<bool>,"structural_reasoning":"<1 sentence>","immediate_action":"<specific action>","urgency_timeline":"<next_renovation|within_6_months|within_1_month|this_week|immediately>"}}""" |
|
|
| _DEFECT_DISPLAY = { |
| "hairline_crack":"Hairline Plaster Crack","settlement_crack":"Settlement Crack", |
| "structural_crack":"Structural Crack","water_seepage":"Water Seepage / Damp Patch", |
| "efflorescence":"Efflorescence (Salt Deposits)","spalling":"Concrete Spalling", |
| "rebar_rust":"Rebar Rust Staining","plaster_delamination":"Plaster Delamination", |
| "no_defect":"No Defect Detected", |
| } |
|
|
| def run_gharscan_pipeline(image: Image.Image, language: str = "en", trace_session=None) -> dict: |
| _load_model_if_needed() |
| _model.to("cuda") |
| try: |
| image = image.convert("RGB").resize((448, 448)) |
|
|
| |
| cls = _call_vlm(image, _CLASSIFY_PROMPT) |
| defect_type = cls.get("defect_type", "no_defect") |
|
|
| if trace_session: |
| trace_session.log_step("classify", {}, cls) |
|
|
| |
| sev = _call_vlm(image, _SEVERITY_PROMPT.format(defect_type=defect_type)) |
| raw_sev = sev.get("severity", 2) |
| try: |
| severity = max(1, min(5, int(float(raw_sev)))) |
| except Exception: |
| severity = 2 |
|
|
| if trace_session: |
| trace_session.log_step("severity", {"defect_type": defect_type}, sev) |
|
|
| |
| cost = build_cost_response(defect_type, severity) |
|
|
| if trace_session: |
| trace_session.log_step("cost", {"defect_type": defect_type, "severity": severity}, cost) |
|
|
| STRUCTURAL_ALWAYS = {"structural_crack", "spalling", "rebar_rust"} |
| STRUCTURAL_BY_SEVERITY = {"settlement_crack": 4} |
|
|
| DEFAULT_ACTIONS = { |
| "structural_crack": "Stop using the affected area and call a licensed structural engineer.", |
| "spalling": "Keep clear of the area below and arrange a structural inspection.", |
| "rebar_rust": "Arrange urgent structural inspection before repairs.", |
| "settlement_crack": "Monitor movement and get a structural engineer’s opinion.", |
| } |
|
|
| is_structural = bool(sev.get("is_structural", False)) |
| if defect_type in STRUCTURAL_ALWAYS: |
| is_structural = True |
| if severity >= STRUCTURAL_BY_SEVERITY.get(defect_type, 999): |
| is_structural = True |
|
|
| structural_reasoning = sev.get("structural_reasoning", "").strip() |
| if not structural_reasoning and is_structural: |
| structural_reasoning = "This defect type can indicate structural risk." |
|
|
| immediate_action = sev.get("immediate_action", "").strip() or DEFAULT_ACTIONS.get( |
| defect_type, |
| "Consult a licensed civil / structural engineer." |
| ) |
| report = { |
| "analysis_ok": defect_type != "no_defect", |
| "defect_type": defect_type, |
| "defect_display": _DEFECT_DISPLAY.get(defect_type, defect_type.replace("_"," ").title()), |
| "description": cls.get("description", ""), |
| "primary_cause": cls.get("primary_cause", ""), |
| "monsoon_risk": cls.get("monsoon_risk", False), |
| "severity": severity, |
| "severity_label": cost["severity_label"], |
| "severity_color": cost["severity_color"], |
| "is_structural": is_structural, |
| "structural_reasoning": structural_reasoning, |
| "immediate_action": immediate_action, |
| "urgency_display": cost["urgency_display"], |
| "cost_range_inr": cost["cost_range_inr"], |
| "professional_display": cost["professional_display"], |
| "requires_engineer": cost["requires_engineer"], |
| "disclaimer": cost["disclaimer"], |
| "show_liability_banner":cost["show_liability_banner"], |
| "liability_text": cost["liability_text"], |
| } |
| if trace_session: |
| trace_session.finalize(report) |
| return report |
|
|
| finally: |
| _model.to("cpu") |
| torch.cuda.empty_cache() |