Spaces:
				
			
			
	
			
			
		Sleeping
		
	
	
	
			
			
	
	
	
	
		
		
		Sleeping
		
	Update app.py
Browse files
    	
        app.py
    CHANGED
    
    | @@ -1,1072 +1,56 @@ | |
| 1 | 
            -
             | 
| 2 | 
            -
            # Verbose, instrumented version — preserves public class/function names
         | 
| 3 | 
            -
            # Turn on deep logging: export LOGLEVEL=DEBUG SMARTHEAL_DEBUG=1
         | 
| 4 |  | 
| 5 | 
             
            import os
         | 
| 6 | 
             
            import logging
         | 
| 7 | 
            -
             | 
| 8 | 
            -
             | 
|  | |
| 9 |  | 
| 10 | 
            -
            #  | 
| 11 | 
            -
             | 
| 12 | 
            -
             | 
| 13 | 
            -
             | 
|  | |
|  | |
| 14 |  | 
| 15 | 
            -
             | 
| 16 | 
            -
             | 
| 17 | 
            -
            from PIL import Image
         | 
| 18 | 
            -
            from PIL.ExifTags import TAGS
         | 
| 19 |  | 
| 20 | 
            -
             | 
| 21 | 
            -
            logging.basicConfig(
         | 
| 22 | 
            -
                level=getattr(logging, LOGLEVEL, logging.INFO),
         | 
| 23 | 
            -
                format="%(asctime)s - %(levelname)s - %(message)s",
         | 
| 24 | 
            -
            )
         | 
| 25 | 
            -
             | 
| 26 | 
            -
            def _log_kv(prefix: str, kv: Dict):
         | 
| 27 | 
            -
                logging.debug(prefix + " | " + " | ".join(f"{k}={v}" for k, v in kv.items()))
         | 
| 28 | 
            -
             | 
| 29 | 
            -
            # --- Spaces GPU decorator (REQUIRED) ---
         | 
| 30 | 
            -
            from spaces import GPU as _SPACES_GPU
         | 
| 31 | 
            -
             | 
| 32 | 
            -
            @_SPACES_GPU(enable_queue=True)
         | 
| 33 | 
            -
            def smartheal_gpu_stub(ping: int = 0) -> str:
         | 
| 34 | 
            -
                return "ready"
         | 
| 35 | 
            -
             | 
| 36 | 
            -
            # ---- Paths / constants ----
         | 
| 37 | 
            -
            UPLOADS_DIR = "uploads"
         | 
| 38 | 
            -
            os.makedirs(UPLOADS_DIR, exist_ok=True)
         | 
| 39 | 
            -
             | 
| 40 | 
            -
            HF_TOKEN = os.getenv("HF_TOKEN", None)
         | 
| 41 | 
            -
            YOLO_MODEL_PATH = "src/best.pt"
         | 
| 42 | 
            -
            SEG_MODEL_PATH = "src/segmentation_model.h5"   # optional; legacy .h5 supported
         | 
| 43 | 
            -
            GUIDELINE_PDFS = ["src/eHealth in Wound Care.pdf", "src/IWGDF Guideline.pdf", "src/evaluation.pdf"]
         | 
| 44 | 
            -
            DATASET_ID = "SmartHeal/wound-image-uploads"
         | 
| 45 | 
            -
            DEFAULT_PX_PER_CM = 38.0
         | 
| 46 | 
            -
            PX_PER_CM_MIN, PX_PER_CM_MAX = 5.0, 1200.0
         | 
| 47 | 
            -
             | 
| 48 | 
            -
            # Segmentation preprocessing knobs
         | 
| 49 | 
            -
            SEG_EXPECTS_RGB = os.getenv("SEG_EXPECTS_RGB", "1") == "1"  # most TF models trained on RGB
         | 
| 50 | 
            -
            SEG_NORM = os.getenv("SEG_NORM", "0to1")                    # "0to1" | "imagenet"
         | 
| 51 | 
            -
            SEG_THRESH = float(os.getenv("SEG_THRESH", "0.5"))
         | 
| 52 | 
            -
             | 
| 53 | 
            -
            models_cache: Dict[str, object] = {}
         | 
| 54 | 
            -
            knowledge_base_cache: Dict[str, object] = {}
         | 
| 55 | 
            -
             | 
| 56 | 
            -
            # ---------- Utilities to prevent CUDA in main process ----------
         | 
| 57 | 
            -
            from contextlib import contextmanager
         | 
| 58 | 
            -
             | 
| 59 | 
            -
            @contextmanager
         | 
| 60 | 
            -
            def _no_cuda_env():
         | 
| 61 | 
            -
                """
         | 
| 62 | 
            -
                Mask GPUs so any library imported/constructed in the main process
         | 
| 63 | 
            -
                cannot see CUDA (required for Spaces Stateless GPU).
         | 
| 64 | 
            -
                """
         | 
| 65 | 
            -
                prev = os.environ.get("CUDA_VISIBLE_DEVICES")
         | 
| 66 | 
            -
                os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
         | 
| 67 | 
            -
                try:
         | 
| 68 | 
            -
                    yield
         | 
| 69 | 
            -
                finally:
         | 
| 70 | 
            -
                    if prev is None:
         | 
| 71 | 
            -
                        os.environ.pop("CUDA_VISIBLE_DEVICES", None)
         | 
| 72 | 
            -
                    else:
         | 
| 73 | 
            -
                        os.environ["CUDA_VISIBLE_DEVICES"] = prev
         | 
| 74 | 
            -
             | 
| 75 | 
            -
            # ---------- Lazy imports (wrapped where needed) ----------
         | 
| 76 | 
            -
            def _import_ultralytics():
         | 
| 77 | 
            -
                # Prevent Ultralytics from probing CUDA on import
         | 
| 78 | 
            -
                with _no_cuda_env():
         | 
| 79 | 
            -
                    from ultralytics import YOLO
         | 
| 80 | 
            -
                return YOLO
         | 
| 81 | 
            -
             | 
| 82 | 
            -
            def _import_tf_loader():
         | 
| 83 | 
            -
                import tensorflow as tf
         | 
| 84 | 
            -
                tf.config.set_visible_devices([], "GPU")
         | 
| 85 | 
            -
                from tensorflow.keras.models import load_model
         | 
| 86 | 
            -
                return load_model
         | 
| 87 | 
            -
             | 
| 88 | 
            -
            def _import_hf_cls():
         | 
| 89 | 
            -
                from transformers import pipeline
         | 
| 90 | 
            -
                return pipeline
         | 
| 91 | 
            -
             | 
| 92 | 
            -
            def _import_embeddings():
         | 
| 93 | 
            -
                from langchain_community.embeddings import HuggingFaceEmbeddings
         | 
| 94 | 
            -
                return HuggingFaceEmbeddings
         | 
| 95 | 
            -
             | 
| 96 | 
            -
            def _import_langchain_pdf():
         | 
| 97 | 
            -
                from langchain_community.document_loaders import PyPDFLoader
         | 
| 98 | 
            -
                return PyPDFLoader
         | 
| 99 | 
            -
             | 
| 100 | 
            -
            def _import_langchain_faiss():
         | 
| 101 | 
            -
                from langchain_community.vectorstores import FAISS
         | 
| 102 | 
            -
                return FAISS
         | 
| 103 | 
            -
             | 
| 104 | 
            -
            def _import_hf_hub():
         | 
| 105 | 
            -
                from huggingface_hub import HfApi, HfFolder
         | 
| 106 | 
            -
                return HfApi, HfFolder
         | 
| 107 | 
            -
             | 
| 108 | 
            -
            # ---------- SmartHeal prompts (system + user prefix) ----------
         | 
| 109 | 
            -
            SMARTHEAL_SYSTEM_PROMPT = """\
         | 
| 110 | 
            -
            You are SmartHeal Clinical Assistant, a wound-care decision-support system.
         | 
| 111 | 
            -
            You analyze wound photographs and brief patient context to produce careful,
         | 
| 112 | 
            -
            specific, guideline-informed recommendations WITHOUT diagnosing. You always:
         | 
| 113 | 
            -
            - Use the measurements calculated by the vision pipeline as ground truth.
         | 
| 114 | 
            -
            - Prefer concise, actionable steps tailored to exudate level, infection risk, and pain.
         | 
| 115 | 
            -
            - Flag uncertainties and red flags that need escalation to a clinician.
         | 
| 116 | 
            -
            - Avoid contraindicated advice; do not infer unseen comorbidities.
         | 
| 117 | 
            -
            - Keep under 300 words and use the requested headings exactly.
         | 
| 118 | 
            -
            - Tone: professional, clear, and conservative; no definitive medical claims.
         | 
| 119 | 
            -
            - Safety: remind the user to seek clinician review for changes or red flags.
         | 
| 120 | 
            -
            """
         | 
| 121 | 
            -
             | 
| 122 | 
            -
            SMARTHEAL_USER_PREFIX = """\
         | 
| 123 | 
            -
            Patient: {patient_info}
         | 
| 124 | 
            -
            Visual findings: type={wound_type}, size={length_cm}x{breadth_cm} cm, area={area_cm2} cm^2,
         | 
| 125 | 
            -
            detection_conf={det_conf:.2f}, calibration={px_per_cm} px/cm.
         | 
| 126 | 
            -
            Guideline context (snippets you can draw principles from; do not quote at length):
         | 
| 127 | 
            -
            {guideline_context}
         | 
| 128 | 
            -
            Write a structured answer with these headings exactly:
         | 
| 129 | 
            -
            1. Clinical Summary (max 4 bullet points)
         | 
| 130 | 
            -
            2. Likely Stage/Type (if uncertain, say 'uncertain')
         | 
| 131 | 
            -
            3. Treatment Plan (specific dressing choices and frequency based on exudate/infection risk)
         | 
| 132 | 
            -
            4. Red Flags (what to escalate and when)
         | 
| 133 | 
            -
            5. Follow-up Cadence (days)
         | 
| 134 | 
            -
            6. Notes (assumptions/uncertainties)
         | 
| 135 | 
            -
            Keep to 220–300 words. Do NOT provide diagnosis. Avoid contraindicated advice.
         | 
| 136 | 
            -
            """
         | 
| 137 | 
            -
             | 
| 138 | 
            -
            # ---------- MedGemma-only text generator ----------
         | 
| 139 | 
            -
            @_SPACES_GPU(enable_queue=True)
         | 
| 140 | 
            -
            def _medgemma_generate_gpu(prompt: str, model_id: str, max_new_tokens: int, token: Optional[str]):
         | 
| 141 | 
            -
                """
         | 
| 142 | 
            -
                Runs entirely inside a Spaces GPU worker. Uses Med-Gemma (text-only) to draft the report.
         | 
| 143 | 
            -
                """
         | 
| 144 | 
            -
                import torch
         | 
| 145 | 
            -
                from transformers import pipeline
         | 
| 146 | 
            -
             | 
| 147 | 
            -
                pipe = pipeline(
         | 
| 148 | 
            -
                    task="text-generation",
         | 
| 149 | 
            -
                    model=model_id,
         | 
| 150 | 
            -
                    torch_dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
         | 
| 151 | 
            -
                    device_map="auto" if torch.cuda.is_available() else None,
         | 
| 152 | 
            -
                    token=token,
         | 
| 153 | 
            -
                    trust_remote_code=True,
         | 
| 154 | 
            -
                    model_kwargs={"low_cpu_mem_usage": True},
         | 
| 155 | 
            -
                )
         | 
| 156 | 
            -
                out = pipe(
         | 
| 157 | 
            -
                    prompt,
         | 
| 158 | 
            -
                    max_new_tokens=max_new_tokens,
         | 
| 159 | 
            -
                    do_sample=False,
         | 
| 160 | 
            -
                    temperature=0.2,
         | 
| 161 | 
            -
                    return_full_text=True,
         | 
| 162 | 
            -
                )
         | 
| 163 | 
            -
                text = (out[0].get("generated_text") if isinstance(out, list) else out).strip()
         | 
| 164 | 
            -
                # Remove the prompt echo if present
         | 
| 165 | 
            -
                if text.startswith(prompt):
         | 
| 166 | 
            -
                    text = text[len(prompt):].lstrip()
         | 
| 167 | 
            -
                return text or "⚠️ Empty response"
         | 
| 168 | 
            -
             | 
| 169 | 
            -
            def generate_medgemma_report(  # kept name so callers don't change
         | 
| 170 | 
            -
                patient_info: str,
         | 
| 171 | 
            -
                visual_results: Dict,
         | 
| 172 | 
            -
                guideline_context: str,
         | 
| 173 | 
            -
                image_pil: Image.Image,  # kept for signature compatibility; not used by MedGemma
         | 
| 174 | 
            -
                max_new_tokens: Optional[int] = None,
         | 
| 175 | 
            -
            ) -> str:
         | 
| 176 | 
            -
                """
         | 
| 177 | 
            -
                MedGemma (text-only) report generation.
         | 
| 178 | 
            -
                The image is analyzed by the vision pipeline; MedGemma formats clinical guidance text.
         | 
| 179 | 
            -
                """
         | 
| 180 | 
            -
                if os.getenv("SMARTHEAL_ENABLE_VLM", "1") != "1":
         | 
| 181 | 
            -
                    return "⚠️ VLM disabled"
         | 
| 182 | 
            -
             | 
| 183 | 
            -
                # Default to a public Med-Gemma instruction-tuned model (update via env if you have access to another).
         | 
| 184 | 
            -
                model_id = os.getenv("SMARTHEAL_MEDGEMMA_MODEL", "google/med-gemma-2-2b-it")
         | 
| 185 | 
            -
                max_new_tokens = max_new_tokens or int(os.getenv("SMARTHEAL_VLM_MAX_TOKENS", "600"))
         | 
| 186 | 
            -
             | 
| 187 | 
            -
                uprompt = SMARTHEAL_USER_PREFIX.format(
         | 
| 188 | 
            -
                    patient_info=patient_info,
         | 
| 189 | 
            -
                    wound_type=visual_results.get("wound_type", "Unknown"),
         | 
| 190 | 
            -
                    length_cm=visual_results.get("length_cm", 0),
         | 
| 191 | 
            -
                    breadth_cm=visual_results.get("breadth_cm", 0),
         | 
| 192 | 
            -
                    area_cm2=visual_results.get("surface_area_cm2", 0),
         | 
| 193 | 
            -
                    det_conf=float(visual_results.get("detection_confidence", 0.0)),
         | 
| 194 | 
            -
                    px_per_cm=visual_results.get("px_per_cm", "?"),
         | 
| 195 | 
            -
                    guideline_context=(guideline_context or "")[:900],
         | 
| 196 | 
            -
                )
         | 
| 197 | 
            -
             | 
| 198 | 
            -
                # Compose a single text prompt
         | 
| 199 | 
            -
                prompt = f"{SMARTHEAL_SYSTEM_PROMPT}\n\n{uprompt}\n\nAnswer:"
         | 
| 200 | 
            -
             | 
| 201 | 
            -
                try:
         | 
| 202 | 
            -
                    return _medgemma_generate_gpu(prompt, model_id, max_new_tokens, HF_TOKEN)
         | 
| 203 | 
            -
                except Exception as e:
         | 
| 204 | 
            -
                    logging.error(f"MedGemma call failed: {e}")
         | 
| 205 | 
            -
                    return "⚠️ VLM error"
         | 
| 206 | 
            -
             | 
| 207 | 
            -
            # ---------- Input-shape helpers (avoid `.as_list()` on strings) ----------
         | 
| 208 | 
            -
            def _shape_to_hw(shape) -> Tuple[Optional[int], Optional[int]]:
         | 
| 209 | 
            -
                try:
         | 
| 210 | 
            -
                    if hasattr(shape, "as_list"):
         | 
| 211 | 
            -
                        shape = shape.as_list()
         | 
| 212 | 
            -
                except Exception:
         | 
| 213 | 
            -
                    pass
         | 
| 214 | 
            -
                if isinstance(shape, (tuple, list)):
         | 
| 215 | 
            -
                    if len(shape) == 4:   # (None, H, W, C)
         | 
| 216 | 
            -
                        H, W = shape[1], shape[2]
         | 
| 217 | 
            -
                    elif len(shape) == 3: # (H, W, C)
         | 
| 218 | 
            -
                        H, W = shape[0], shape[1]
         | 
| 219 | 
            -
                    else:
         | 
| 220 | 
            -
                        return (None, None)
         | 
| 221 | 
            -
                    try: H = int(H) if (H is not None and str(H).lower() != "none") else None
         | 
| 222 | 
            -
                    except Exception: H = None
         | 
| 223 | 
            -
                    try: W = int(W) if (W is not None and str(W).lower() != "none") else None
         | 
| 224 | 
            -
                    except Exception: W = None
         | 
| 225 | 
            -
                    return (H, W)
         | 
| 226 | 
            -
                return (None, None)
         | 
| 227 | 
            -
             | 
| 228 | 
            -
            def _get_model_input_hw(model, default_hw: Tuple[int, int] = (224, 224)) -> Tuple[int, int]:
         | 
| 229 | 
            -
                H, W = _shape_to_hw(getattr(model, "input_shape", None))
         | 
| 230 | 
            -
                if H and W:
         | 
| 231 | 
            -
                    return H, W
         | 
| 232 | 
            -
                try:
         | 
| 233 | 
            -
                    inputs = getattr(model, "inputs", None)
         | 
| 234 | 
            -
                    if inputs:
         | 
| 235 | 
            -
                        H, W = _shape_to_hw(inputs[0].shape)
         | 
| 236 | 
            -
                        if H and W:
         | 
| 237 | 
            -
                            return H, W
         | 
| 238 | 
            -
                except Exception:
         | 
| 239 | 
            -
                    pass
         | 
| 240 | 
            -
                try:
         | 
| 241 | 
            -
                    cfg = model.get_config() if hasattr(model, "get_config") else None
         | 
| 242 | 
            -
                    if isinstance(cfg, dict):
         | 
| 243 | 
            -
                        for layer in cfg.get("layers", []):
         | 
| 244 | 
            -
                            conf = (layer or {}).get("config", {})
         | 
| 245 | 
            -
                            cand = conf.get("batch_input_shape") or conf.get("batch_shape")
         | 
| 246 | 
            -
                            H, W = _shape_to_hw(cand)
         | 
| 247 | 
            -
                            if H and W:
         | 
| 248 | 
            -
                                return H, W
         | 
| 249 | 
            -
                except Exception:
         | 
| 250 | 
            -
                    pass
         | 
| 251 | 
            -
                logging.warning(f"Could not resolve model input shape; using default {default_hw}.")
         | 
| 252 | 
            -
                return default_hw
         | 
| 253 | 
            -
             | 
| 254 | 
            -
            # ---------- Initialize CPU models ----------
         | 
| 255 | 
            -
            def load_yolo_model():
         | 
| 256 | 
            -
                YOLO = _import_ultralytics()
         | 
| 257 | 
            -
                with _no_cuda_env():
         | 
| 258 | 
            -
                    model = YOLO(YOLO_MODEL_PATH)
         | 
| 259 | 
            -
                return model
         | 
| 260 | 
            -
             | 
| 261 | 
            -
            def load_segmentation_model(path: Optional[str] = None):
         | 
| 262 | 
            -
                """
         | 
| 263 | 
            -
                Robust loader for legacy .h5 models across TF/Keras versions.
         | 
| 264 | 
            -
                Uses global SEG_MODEL_PATH by default.
         | 
| 265 | 
            -
                """
         | 
| 266 | 
            -
                import ast
         | 
| 267 | 
            -
                import tensorflow as tf
         | 
| 268 | 
            -
                tf.config.set_visible_devices([], "GPU")
         | 
| 269 | 
            -
                model_path = path or SEG_MODEL_PATH
         | 
| 270 | 
            -
             | 
| 271 | 
            -
                # Attempt 1: tf.keras with safe_mode=False
         | 
| 272 | 
            -
                try:
         | 
| 273 | 
            -
                    m = tf.keras.models.load_model(model_path, compile=False, safe_mode=False)
         | 
| 274 | 
            -
                    logging.info("✅ Segmentation model loaded (tf.keras, safe_mode=False).")
         | 
| 275 | 
            -
                    return m
         | 
| 276 | 
            -
                except Exception as e1:
         | 
| 277 | 
            -
                    logging.warning(f"tf.keras load (safe_mode=False) failed: {e1}")
         | 
| 278 | 
            -
             | 
| 279 | 
            -
                # Attempt 2: patched InputLayer (drop legacy args; coerce string shapes)
         | 
| 280 | 
            -
                try:
         | 
| 281 | 
            -
                    from tensorflow.keras.layers import InputLayer as _KInputLayer
         | 
| 282 | 
            -
                    def _InputLayerPatched(*args, **kwargs):
         | 
| 283 | 
            -
                        kwargs.pop("batch_shape", None)
         | 
| 284 | 
            -
                        kwargs.pop("batch_input_shape", None)
         | 
| 285 | 
            -
                        if "shape" in kwargs and isinstance(kwargs["shape"], str):
         | 
| 286 | 
            -
                            try:
         | 
| 287 | 
            -
                                kwargs["shape"] = tuple(ast.literal_eval(kwargs["shape"]))
         | 
| 288 | 
            -
                            except Exception:
         | 
| 289 | 
            -
                                kwargs.pop("shape", None)
         | 
| 290 | 
            -
                        return _KInputLayer(**kwargs)
         | 
| 291 | 
            -
                    m = tf.keras.models.load_model(
         | 
| 292 | 
            -
                        model_path,
         | 
| 293 | 
            -
                        compile=False,
         | 
| 294 | 
            -
                        custom_objects={"InputLayer": _InputLayerPatched},
         | 
| 295 | 
            -
                        safe_mode=False,
         | 
| 296 | 
            -
                    )
         | 
| 297 | 
            -
                    logging.info("✅ Segmentation model loaded (patched InputLayer).")
         | 
| 298 | 
            -
                    return m
         | 
| 299 | 
            -
                except Exception as e2:
         | 
| 300 | 
            -
                    logging.warning(f"Patched InputLayer load failed: {e2}")
         | 
| 301 | 
            -
             | 
| 302 | 
            -
                # Attempt 3: keras 2 shim (tf_keras) if present
         | 
| 303 | 
            -
                try:
         | 
| 304 | 
            -
                    import tf_keras
         | 
| 305 | 
            -
                    m = tf_keras.models.load_model(model_path, compile=False)
         | 
| 306 | 
            -
                    logging.info("✅ Segmentation model loaded (tf_keras compat).")
         | 
| 307 | 
            -
                    return m
         | 
| 308 | 
            -
                except Exception as e3:
         | 
| 309 | 
            -
                    logging.warning(f"tf_keras load failed or not installed: {e3}")
         | 
| 310 | 
            -
             | 
| 311 | 
            -
                raise RuntimeError("Segmentation model could not be loaded; please convert/resave the model.")
         | 
| 312 | 
            -
             | 
| 313 | 
            -
            def load_classification_pipeline():
         | 
| 314 | 
            -
                pipe = _import_hf_cls()
         | 
| 315 | 
            -
                return pipe("image-classification", model="Hemg/Wound-classification", token=HF_TOKEN, device="cpu")
         | 
| 316 | 
            -
             | 
| 317 | 
            -
            def load_embedding_model():
         | 
| 318 | 
            -
                Emb = _import_embeddings()
         | 
| 319 | 
            -
                return Emb(model_name="sentence-transformers/all-MiniLM-L6-v2", model_kwargs={"device": "cpu"})
         | 
| 320 | 
            -
             | 
| 321 | 
            -
            def initialize_cpu_models() -> None:
         | 
| 322 | 
            -
                if HF_TOKEN:
         | 
| 323 | 
            -
                    try:
         | 
| 324 | 
            -
                        HfApi, HfFolder = _import_hf_hub()
         | 
| 325 | 
            -
                        HfFolder.save_token(HF_TOKEN)
         | 
| 326 | 
            -
                        logging.info("✅ HF token set")
         | 
| 327 | 
            -
                    except Exception as e:
         | 
| 328 | 
            -
                        logging.warning(f"HF token save failed: {e}")
         | 
| 329 | 
            -
             | 
| 330 | 
            -
                if "det" not in models_cache:
         | 
| 331 | 
            -
                    try:
         | 
| 332 | 
            -
                        models_cache["det"] = load_yolo_model()
         | 
| 333 | 
            -
                        logging.info("✅ YOLO loaded (CPU; CUDA masked in main)")
         | 
| 334 | 
            -
                    except Exception as e:
         | 
| 335 | 
            -
                        logging.error(f"YOLO load failed: {e}")
         | 
| 336 | 
            -
             | 
| 337 | 
            -
                if "seg" not in models_cache:
         | 
| 338 | 
            -
                    try:
         | 
| 339 | 
            -
                        if os.path.exists(SEG_MODEL_PATH):
         | 
| 340 | 
            -
                            m = load_segmentation_model()  # uses global path by default
         | 
| 341 | 
            -
                            models_cache["seg"] = m
         | 
| 342 | 
            -
                            th, tw = _get_model_input_hw(m, default_hw=(224, 224))
         | 
| 343 | 
            -
                            oshape = getattr(m, "output_shape", None)
         | 
| 344 | 
            -
                            logging.info(f"✅ Segmentation model loaded (CPU) | input_hw=({th},{tw}) output_shape={oshape}")
         | 
| 345 | 
            -
                        else:
         | 
| 346 | 
            -
                            models_cache["seg"] = None
         | 
| 347 | 
            -
                            logging.warning("Segmentation model file missing; skipping.")
         | 
| 348 | 
            -
                    except Exception as e:
         | 
| 349 | 
            -
                        models_cache["seg"] = None
         | 
| 350 | 
            -
                        logging.warning(f"Segmentation unavailable: {e}")
         | 
| 351 | 
            -
             | 
| 352 | 
            -
                if "cls" not in models_cache:
         | 
| 353 | 
            -
                    try:
         | 
| 354 | 
            -
                        models_cache["cls"] = load_classification_pipeline()
         | 
| 355 | 
            -
                        logging.info("✅ Classifier loaded (CPU)")
         | 
| 356 | 
            -
                    except Exception as e:
         | 
| 357 | 
            -
                        models_cache["cls"] = None
         | 
| 358 | 
            -
                        logging.warning(f"Classifier unavailable: {e}")
         | 
| 359 | 
            -
             | 
| 360 | 
            -
                if "embedding_model" not in models_cache:
         | 
| 361 | 
            -
                    try:
         | 
| 362 | 
            -
                        models_cache["embedding_model"] = load_embedding_model()
         | 
| 363 | 
            -
                        logging.info("✅ Embeddings loaded (CPU)")
         | 
| 364 | 
            -
                    except Exception as e:
         | 
| 365 | 
            -
                        models_cache["embedding_model"] = None
         | 
| 366 | 
            -
                        logging.warning(f"Embeddings unavailable: {e}")
         | 
| 367 | 
            -
             | 
| 368 | 
            -
            def setup_knowledge_base() -> None:
         | 
| 369 | 
            -
                if "vector_store" in knowledge_base_cache:
         | 
| 370 | 
            -
                    return
         | 
| 371 | 
            -
                docs: List = []
         | 
| 372 | 
            -
                try:
         | 
| 373 | 
            -
                    PyPDFLoader = _import_langchain_pdf()
         | 
| 374 | 
            -
                    for pdf in GUIDELINE_PDFS:
         | 
| 375 | 
            -
                        if os.path.exists(pdf):
         | 
| 376 | 
            -
                            try:
         | 
| 377 | 
            -
                                docs.extend(PyPDFLoader(pdf).load())
         | 
| 378 | 
            -
                                logging.info(f"Loaded PDF: {pdf}")
         | 
| 379 | 
            -
                            except Exception as e:
         | 
| 380 | 
            -
                                logging.warning(f"PDF load failed ({pdf}): {e}")
         | 
| 381 | 
            -
                except Exception as e:
         | 
| 382 | 
            -
                    logging.warning(f"LangChain PDF loader unavailable: {e}")
         | 
| 383 | 
            -
             | 
| 384 | 
            -
                if docs and models_cache.get("embedding_model"):
         | 
| 385 | 
            -
                    try:
         | 
| 386 | 
            -
                        from langchain.text_splitter import RecursiveCharacterTextSplitter
         | 
| 387 | 
            -
                        FAISS = _import_langchain_faiss()
         | 
| 388 | 
            -
                        chunks = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100).split_documents(docs)
         | 
| 389 | 
            -
                        knowledge_base_cache["vector_store"] = FAISS.from_documents(chunks, models_cache["embedding_model"])
         | 
| 390 | 
            -
                        logging.info(f"✅ Knowledge base ready ({len(chunks)} chunks)")
         | 
| 391 | 
            -
                    except Exception as e:
         | 
| 392 | 
            -
                        knowledge_base_cache["vector_store"] = None
         | 
| 393 | 
            -
                        logging.warning(f"KB build failed: {e}")
         | 
| 394 | 
            -
                else:
         | 
| 395 | 
            -
                    knowledge_base_cache["vector_store"] = None
         | 
| 396 | 
            -
                    logging.warning("KB disabled (no docs or embeddings).")
         | 
| 397 | 
            -
             | 
| 398 | 
            -
            initialize_cpu_models()
         | 
| 399 | 
            -
            setup_knowledge_base()
         | 
| 400 | 
            -
             | 
| 401 | 
            -
            # ---------- Calibration helpers ----------
         | 
| 402 | 
            -
            def _exif_to_dict(pil_img: Image.Image) -> Dict[str, object]:
         | 
| 403 | 
            -
                out = {}
         | 
| 404 | 
            -
                try:
         | 
| 405 | 
            -
                    exif = pil_img.getexif()
         | 
| 406 | 
            -
                    if not exif:
         | 
| 407 | 
            -
                        return out
         | 
| 408 | 
            -
                    for k, v in exif.items():
         | 
| 409 | 
            -
                        tag = TAGS.get(k, k)
         | 
| 410 | 
            -
                        out[tag] = v
         | 
| 411 | 
            -
                except Exception:
         | 
| 412 | 
            -
                    pass
         | 
| 413 | 
            -
                return out
         | 
| 414 | 
            -
             | 
| 415 | 
            -
            def _to_float(val) -> Optional[float]:
         | 
| 416 | 
            -
                try:
         | 
| 417 | 
            -
                    if val is None:
         | 
| 418 | 
            -
                        return None
         | 
| 419 | 
            -
                    if isinstance(val, tuple) and len(val) == 2:
         | 
| 420 | 
            -
                        num, den = float(val[0]), float(val[1]) if float(val[1]) != 0 else 1.0
         | 
| 421 | 
            -
                        return num / den
         | 
| 422 | 
            -
                    return float(val)
         | 
| 423 | 
            -
                except Exception:
         | 
| 424 | 
            -
                    return None
         | 
| 425 | 
            -
             | 
| 426 | 
            -
            def _estimate_sensor_width_mm(f_mm: Optional[float], f35: Optional[float]) -> Optional[float]:
         | 
| 427 | 
            -
                if f_mm and f35 and f35 > 0:
         | 
| 428 | 
            -
                    return 36.0 * f_mm / f35
         | 
| 429 | 
            -
                return None
         | 
| 430 | 
            -
             | 
| 431 | 
            -
            def estimate_px_per_cm_from_exif(pil_img: Image.Image, default_px_per_cm: float = DEFAULT_PX_PER_CM) -> Tuple[float, Dict]:
         | 
| 432 | 
            -
                meta = {"used": "default", "f_mm": None, "f35": None, "sensor_w_mm": None, "distance_m": None}
         | 
| 433 | 
            -
                try:
         | 
| 434 | 
            -
                    exif = _exif_to_dict(pil_img)
         | 
| 435 | 
            -
                    f_mm = _to_float(exif.get("FocalLength"))
         | 
| 436 | 
            -
                    f35 = _to_float(exif.get("FocalLengthIn35mmFilm") or exif.get("FocalLengthIn35mm"))
         | 
| 437 | 
            -
                    subj_dist_m = _to_float(exif.get("SubjectDistance"))
         | 
| 438 | 
            -
                    sensor_w_mm = _estimate_sensor_width_mm(f_mm, f35)
         | 
| 439 | 
            -
                    meta.update({"f_mm": f_mm, "f35": f35, "sensor_w_mm": sensor_w_mm, "distance_m": subj_dist_m})
         | 
| 440 | 
            -
             | 
| 441 | 
            -
                    if f_mm and sensor_w_mm and subj_dist_m and subj_dist_m > 0:
         | 
| 442 | 
            -
                        w_px = pil_img.width
         | 
| 443 | 
            -
                        field_w_mm = sensor_w_mm * (subj_dist_m * 1000.0) / f_mm
         | 
| 444 | 
            -
                        field_w_cm = field_w_mm / 10.0
         | 
| 445 | 
            -
                        px_per_cm = w_px / max(field_w_cm, 1e-6)
         | 
| 446 | 
            -
                        px_per_cm = float(np.clip(px_per_cm, PX_PER_CM_MIN, PX_PER_CM_MAX))
         | 
| 447 | 
            -
                        meta["used"] = "exif"
         | 
| 448 | 
            -
                        return px_per_cm, meta
         | 
| 449 | 
            -
                    return float(default_px_per_cm), meta
         | 
| 450 | 
            -
                except Exception:
         | 
| 451 | 
            -
                    return float(default_px_per_cm), meta
         | 
| 452 | 
            -
             | 
| 453 | 
            -
            # ---------- Segmentation helpers ----------
         | 
| 454 | 
            -
            def _imagenet_norm(arr: np.ndarray) -> np.ndarray:
         | 
| 455 | 
            -
                mean = np.array([123.675, 116.28, 103.53], dtype=np.float32)
         | 
| 456 | 
            -
                std  = np.array([58.395, 57.12, 57.375], dtype=np.float32)
         | 
| 457 | 
            -
                return (arr.astype(np.float32) - mean) / std
         | 
| 458 | 
            -
             | 
| 459 | 
            -
            def _preprocess_for_seg(bgr_roi: np.ndarray, target_hw: Tuple[int, int]) -> np.ndarray:
         | 
| 460 | 
            -
                H, W = target_hw
         | 
| 461 | 
            -
                resized = cv2.resize(bgr_roi, (W, H), interpolation=cv2.INTER_LINEAR)
         | 
| 462 | 
            -
                if SEG_EXPECTS_RGB:
         | 
| 463 | 
            -
                    resized = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
         | 
| 464 | 
            -
                if SEG_NORM.lower() == "imagenet":
         | 
| 465 | 
            -
                    x = _imagenet_norm(resized)
         | 
| 466 | 
            -
                else:
         | 
| 467 | 
            -
                    x = resized.astype(np.float32) / 255.0
         | 
| 468 | 
            -
                x = np.expand_dims(x, axis=0)  # (1,H,W,3)
         | 
| 469 | 
            -
                return x
         | 
| 470 | 
            -
             | 
| 471 | 
            -
            def _to_prob(pred: np.ndarray) -> np.ndarray:
         | 
| 472 | 
            -
                p = np.squeeze(pred)
         | 
| 473 | 
            -
                pmin, pmax = float(p.min()), float(p.max())
         | 
| 474 | 
            -
                if pmax > 1.0 or pmin < 0.0:
         | 
| 475 | 
            -
                    p = 1.0 / (1.0 + np.exp(-p))
         | 
| 476 | 
            -
                return p.astype(np.float32)
         | 
| 477 | 
            -
             | 
| 478 | 
            -
            # ---- Adaptive threshold + GrabCut grow ----
         | 
| 479 | 
            -
            def _adaptive_prob_threshold(p: np.ndarray) -> float:
         | 
| 480 | 
            -
                """
         | 
| 481 | 
            -
                Choose a threshold that avoids tiny blobs while not swallowing skin.
         | 
| 482 | 
            -
                Try Otsu and the 90th percentile, clamp to [0.25, 0.65], pick by area heuristic.
         | 
| 483 | 
            -
                """
         | 
| 484 | 
            -
                p01 = np.clip(p.astype(np.float32), 0, 1)
         | 
| 485 | 
            -
                p255 = (p01 * 255).astype(np.uint8)
         | 
| 486 | 
            -
             | 
| 487 | 
            -
                ret_otsu, _ = cv2.threshold(p255, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
         | 
| 488 | 
            -
                thr_otsu = float(np.clip(ret_otsu / 255.0, 0.25, 0.65))
         | 
| 489 | 
            -
                thr_pctl = float(np.clip(np.percentile(p01, 90), 0.25, 0.65))
         | 
| 490 | 
            -
             | 
| 491 | 
            -
                def area_frac(thr: float) -> float:
         | 
| 492 | 
            -
                    return float((p01 >= thr).sum()) / float(p01.size)
         | 
| 493 | 
            -
             | 
| 494 | 
            -
                af_otsu = area_frac(thr_otsu)
         | 
| 495 | 
            -
                af_pctl = area_frac(thr_pctl)
         | 
| 496 | 
            -
             | 
| 497 | 
            -
                def score(af: float) -> float:
         | 
| 498 | 
            -
                    target_low, target_high = 0.03, 0.10
         | 
| 499 | 
            -
                    if af < target_low: return abs(af - target_low) * 3.0
         | 
| 500 | 
            -
                    if af > target_high: return abs(af - target_high) * 1.5
         | 
| 501 | 
            -
                    return 0.0
         | 
| 502 | 
            -
             | 
| 503 | 
            -
                return thr_otsu if score(af_otsu) <= score(af_pctl) else thr_pctl
         | 
| 504 | 
            -
             | 
| 505 | 
            -
            def _grabcut_refine(bgr: np.ndarray, seed01: np.ndarray, iters: int = 3) -> np.ndarray:
         | 
| 506 | 
            -
                """Grow from a confident core into low-contrast margins."""
         | 
| 507 | 
            -
                h, w = bgr.shape[:2]
         | 
| 508 | 
            -
                gc = np.full((h, w), cv2.GC_PR_BGD, np.uint8)
         | 
| 509 | 
            -
                k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
         | 
| 510 | 
            -
                seed_dil = cv2.dilate(seed01, k, iterations=1)
         | 
| 511 | 
            -
                gc[seed01.astype(bool)] = cv2.GC_PR_FGD
         | 
| 512 | 
            -
                gc[seed_dil.astype(bool)] = cv2.GC_FGD
         | 
| 513 | 
            -
                gc[0, :], gc[-1, :], gc[:, 0], gc[:, 1] = cv2.GC_BGD, cv2.GC_BGD, cv2.GC_BGD, cv2.GC_BGD
         | 
| 514 | 
            -
                bgdModel = np.zeros((1, 65), np.float64)
         | 
| 515 | 
            -
                fgdModel = np.zeros((1, 65), np.float64)
         | 
| 516 | 
            -
                cv2.grabCut(bgr, gc, None, bgdModel, fgdModel, iters, cv2.GC_INIT_WITH_MASK)
         | 
| 517 | 
            -
                return np.where((gc == cv2.GC_FGD) | (gc == cv2.GC_PR_FGD), 1, 0).astype(np.uint8)
         | 
| 518 | 
            -
             | 
| 519 | 
            -
            def _fill_holes(mask01: np.ndarray) -> np.ndarray:
         | 
| 520 | 
            -
                h, w = mask01.shape[:2]
         | 
| 521 | 
            -
                ff = np.zeros((h + 2, w + 2), np.uint8)
         | 
| 522 | 
            -
                m = (mask01 * 255).astype(np.uint8).copy()
         | 
| 523 | 
            -
                cv2.floodFill(m, ff, (0, 0), 255)
         | 
| 524 | 
            -
                m_inv = cv2.bitwise_not(m)
         | 
| 525 | 
            -
                out = ((mask01 * 255) | m_inv) // 255
         | 
| 526 | 
            -
                return out.astype(np.uint8)
         | 
| 527 | 
            -
             | 
| 528 | 
            -
            def _clean_mask(mask01: np.ndarray) -> np.ndarray:
         | 
| 529 | 
            -
                """Open → Close → Fill holes → Largest component (no dilation)."""
         | 
| 530 | 
            -
                mask01 = (mask01 > 0).astype(np.uint8)
         | 
| 531 | 
            -
                k3 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
         | 
| 532 | 
            -
                k5 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
         | 
| 533 | 
            -
                mask01 = cv2.morphologyEx(mask01, cv2.MORPH_OPEN, k3, iterations=1)
         | 
| 534 | 
            -
                mask01 = cv2.morphologyEx(mask01, cv2.MORPH_CLOSE, k5, iterations=1)
         | 
| 535 | 
            -
                mask01 = _fill_holes(mask01)
         | 
| 536 | 
            -
                # Keep largest component only
         | 
| 537 | 
            -
                num, labels, stats, _ = cv2.connectedComponentsWithStats(mask01, 8)
         | 
| 538 | 
            -
                if num > 1:
         | 
| 539 | 
            -
                    areas = stats[1:, cv2.CC_STAT_AREA]
         | 
| 540 | 
            -
                    if areas.size:
         | 
| 541 | 
            -
                        largest_idx = 1 + int(np.argmax(areas))
         | 
| 542 | 
            -
                        mask01 = (labels == largest_idx).astype(np.uint8)
         | 
| 543 | 
            -
                return (mask01 > 0).astype(np.uint8)
         | 
| 544 | 
            -
             | 
| 545 | 
            -
            # Global last debug dict (per-process)
         | 
| 546 | 
            -
            _last_seg_debug: Dict[str, object] = {}
         | 
| 547 | 
            -
             | 
| 548 | 
            -
            def segment_wound(image_bgr: np.ndarray, ts: str, out_dir: str) -> Tuple[np.ndarray, Dict[str, object]]:
         | 
| 549 | 
            -
                """
         | 
| 550 | 
            -
                TF model → adaptive threshold on prob → GrabCut grow → cleanup.
         | 
| 551 | 
            -
                Fallback: KMeans-Lab.
         | 
| 552 | 
            -
                Returns (mask_uint8_0_255, debug_dict)
         | 
| 553 | 
            -
                """
         | 
| 554 | 
            -
                debug = {"used": None, "reason": None, "positive_fraction": 0.0,
         | 
| 555 | 
            -
                         "thr": None, "heatmap_path": None, "roi_seen_by_model": None}
         | 
| 556 | 
            -
             | 
| 557 | 
            -
                seg_model = models_cache.get("seg", None)
         | 
| 558 | 
            -
             | 
| 559 | 
            -
                # --- Model path ---
         | 
| 560 | 
            -
                if seg_model is not None:
         | 
| 561 | 
            -
                    try:
         | 
| 562 | 
            -
                        th, tw = _get_model_input_hw(seg_model, default_hw=(224, 224))
         | 
| 563 | 
            -
                        x = _preprocess_for_seg(image_bgr, (th, tw))
         | 
| 564 | 
            -
                        roi_seen_path = None
         | 
| 565 | 
            -
                        if SMARTHEAL_DEBUG:
         | 
| 566 | 
            -
                            roi_seen_path = os.path.join(out_dir, f"roi_for_seg_{ts}.png")
         | 
| 567 | 
            -
                            cv2.imwrite(roi_seen_path, image_bgr)
         | 
| 568 | 
            -
             | 
| 569 | 
            -
                        pred = seg_model.predict(x, verbose=0)
         | 
| 570 | 
            -
                        if isinstance(pred, (list, tuple)): pred = pred[0]
         | 
| 571 | 
            -
                        p = _to_prob(pred)
         | 
| 572 | 
            -
                        p = cv2.resize(p, (image_bgr.shape[1], image_bgr.shape[0]), interpolation=cv2.INTER_LINEAR)
         | 
| 573 | 
            -
             | 
| 574 | 
            -
                        heatmap_path = None
         | 
| 575 | 
            -
                        if SMARTHEAL_DEBUG:
         | 
| 576 | 
            -
                            hm = (np.clip(p, 0, 1) * 255).astype(np.uint8)
         | 
| 577 | 
            -
                            heat = cv2.applyColorMap(hm, cv2.COLORMAP_JET)
         | 
| 578 | 
            -
                            heatmap_path = os.path.join(out_dir, f"seg_pred_heatmap_{ts}.png")
         | 
| 579 | 
            -
                            cv2.imwrite(heatmap_path, heat)
         | 
| 580 | 
            -
             | 
| 581 | 
            -
                        thr = _adaptive_prob_threshold(p)
         | 
| 582 | 
            -
                        core01 = (p >= thr).astype(np.uint8)
         | 
| 583 | 
            -
                        core_frac = float(core01.sum()) / float(core01.size)
         | 
| 584 | 
            -
             | 
| 585 | 
            -
                        if core_frac < 0.005:
         | 
| 586 | 
            -
                            thr2 = max(thr - 0.10, 0.15)
         | 
| 587 | 
            -
                            core01 = (p >= thr2).astype(np.uint8)
         | 
| 588 | 
            -
                            thr = thr2
         | 
| 589 | 
            -
                            core_frac = float(core01.sum()) / float(core01.size)
         | 
| 590 | 
            -
             | 
| 591 | 
            -
                        if core01.any():
         | 
| 592 | 
            -
                            gc01 = _grabcut_refine(image_bgr, core01, iters=3)
         | 
| 593 | 
            -
                            mask01 = _clean_mask(gc01)
         | 
| 594 | 
            -
                        else:
         | 
| 595 | 
            -
                            mask01 = np.zeros(core01.shape, np.uint8)
         | 
| 596 | 
            -
             | 
| 597 | 
            -
                        pos_frac = float(mask01.sum()) / float(mask01.size)
         | 
| 598 | 
            -
                        logging.info(f"SegModel USED | thr={float(thr):.2f} core_frac={core_frac:.4f} final_frac={pos_frac:.4f}")
         | 
| 599 | 
            -
             | 
| 600 | 
            -
                        debug.update({
         | 
| 601 | 
            -
                            "used": "tf_model",
         | 
| 602 | 
            -
                            "reason": "ok",
         | 
| 603 | 
            -
                            "positive_fraction": pos_frac,
         | 
| 604 | 
            -
                            "thr": float(thr),
         | 
| 605 | 
            -
                            "heatmap_path": heatmap_path,
         | 
| 606 | 
            -
                            "roi_seen_by_model": roi_seen_path
         | 
| 607 | 
            -
                        })
         | 
| 608 | 
            -
                        return (mask01 * 255).astype(np.uint8), debug
         | 
| 609 | 
            -
             | 
| 610 | 
            -
                    except Exception as e:
         | 
| 611 | 
            -
                        logging.warning(f"⚠️ Segmentation model failed → fallback. Reason: {e}")
         | 
| 612 | 
            -
                        debug.update({"used": "fallback_kmeans", "reason": f"model_failed: {e}"})
         | 
| 613 | 
            -
             | 
| 614 | 
            -
                # --- Fallback: KMeans in Lab (reddest cluster as wound) ---
         | 
| 615 | 
            -
                Z = image_bgr.reshape((-1, 3)).astype(np.float32)
         | 
| 616 | 
            -
                criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)
         | 
| 617 | 
            -
                _, labels, centers = cv2.kmeans(Z, 2, None, criteria, 5, cv2.KMEANS_PP_CENTERS)
         | 
| 618 | 
            -
                centers_u8 = centers.astype(np.uint8).reshape(1, 2, 3)
         | 
| 619 | 
            -
                centers_lab = cv2.cvtColor(centers_u8, cv2.COLOR_BGR2LAB)[0]
         | 
| 620 | 
            -
                wound_idx = int(np.argmax(centers_lab[:, 1]))  # maximize a* (red)
         | 
| 621 | 
            -
                mask01 = (labels.reshape(image_bgr.shape[:2]) == wound_idx).astype(np.uint8)
         | 
| 622 | 
            -
                mask01 = _clean_mask(mask01)
         | 
| 623 | 
            -
             | 
| 624 | 
            -
                pos_frac = float(mask01.sum()) / float(mask01.size)
         | 
| 625 | 
            -
                logging.info(f"KMeans USED | final_frac={pos_frac:.4f}")
         | 
| 626 | 
            -
             | 
| 627 | 
            -
                debug.update({
         | 
| 628 | 
            -
                    "used": "fallback_kmeans",
         | 
| 629 | 
            -
                    "reason": debug.get("reason") or "no_model",
         | 
| 630 | 
            -
                    "positive_fraction": pos_frac,
         | 
| 631 | 
            -
                    "thr": None
         | 
| 632 | 
            -
                })
         | 
| 633 | 
            -
                return (mask01 * 255).astype(np.uint8), debug
         | 
| 634 | 
            -
             | 
| 635 | 
            -
            # ---------- Measurement + overlay helpers ----------
         | 
| 636 | 
            -
            def largest_component_mask(binary01: np.ndarray, min_area_px: int = 50) -> np.ndarray:
         | 
| 637 | 
            -
                num, labels, stats, _ = cv2.connectedComponentsWithStats(binary01.astype(np.uint8), connectivity=8)
         | 
| 638 | 
            -
                if num <= 1:
         | 
| 639 | 
            -
                    return binary01.astype(np.uint8)
         | 
| 640 | 
            -
                areas = stats[1:, cv2.CC_STAT_AREA]
         | 
| 641 | 
            -
                if areas.size == 0 or areas.max() < min_area_px:
         | 
| 642 | 
            -
                    return binary01.astype(np.uint8)
         | 
| 643 | 
            -
                largest_idx = 1 + int(np.argmax(areas))
         | 
| 644 | 
            -
                return (labels == largest_idx).astype(np.uint8)
         | 
| 645 | 
            -
             | 
| 646 | 
            -
            def measure_min_area_rect(mask01: np.ndarray, px_per_cm: float) -> Tuple[float, float, Tuple]:
         | 
| 647 | 
            -
                contours, _ = cv2.findContours(mask01.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
         | 
| 648 | 
            -
                if not contours:
         | 
| 649 | 
            -
                    return 0.0, 0.0, (None, None)
         | 
| 650 | 
            -
                cnt = max(contours, key=cv2.contourArea)
         | 
| 651 | 
            -
                rect = cv2.minAreaRect(cnt)
         | 
| 652 | 
            -
                (w_px, h_px) = rect[1]
         | 
| 653 | 
            -
                length_px, breadth_px = (max(w_px, h_px), min(h_px, w_px))
         | 
| 654 | 
            -
                length_cm = round(length_px / max(px_per_cm, 1e-6), 2)
         | 
| 655 | 
            -
                breadth_cm = round(breadth_px / max(px_per_cm, 1e-6), 2)
         | 
| 656 | 
            -
                box = cv2.boxPoints(rect).astype(int)
         | 
| 657 | 
            -
                return length_cm, breadth_cm, (box, rect[0])
         | 
| 658 | 
            -
             | 
| 659 | 
            -
            def area_cm2_from_contour(mask01: np.ndarray, px_per_cm: float) -> Tuple[float, Optional[np.ndarray]]:
         | 
| 660 | 
            -
                """Area from largest polygon (sub-pixel); returns (area_cm2, contour)."""
         | 
| 661 | 
            -
                m = (mask01 > 0).astype(np.uint8)
         | 
| 662 | 
            -
                contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
         | 
| 663 | 
            -
                if not contours:
         | 
| 664 | 
            -
                    return 0.0, None
         | 
| 665 | 
            -
                cnt = max(contours, key=cv2.contourArea)
         | 
| 666 | 
            -
                poly_area_px2 = float(cv2.contourArea(cnt))
         | 
| 667 | 
            -
                area_cm2 = round(poly_area_px2 / (max(px_per_cm, 1e-6) ** 2), 2)
         | 
| 668 | 
            -
                return area_cm2, cnt
         | 
| 669 | 
            -
             | 
| 670 | 
            -
            def clamp_area_with_minrect(cnt: np.ndarray, px_per_cm: float, area_cm2_poly: float) -> float:
         | 
| 671 | 
            -
                rect = cv2.minAreaRect(cnt)
         | 
| 672 | 
            -
                (w_px, h_px) = rect[1]
         | 
| 673 | 
            -
                rect_area_px2 = float(max(w_px, 0.0) * max(h_px, 0.0))
         | 
| 674 | 
            -
                rect_area_cm2 = rect_area_px2 / (max(px_per_cm, 1e-6) ** 2)
         | 
| 675 | 
            -
                return round(min(area_cm2_poly, rect_area_cm2 * 1.05), 2)
         | 
| 676 | 
            -
             | 
| 677 | 
            -
            def draw_measurement_overlay(
         | 
| 678 | 
            -
                base_bgr: np.ndarray,
         | 
| 679 | 
            -
                mask01: np.ndarray,
         | 
| 680 | 
            -
                rect_box: np.ndarray,
         | 
| 681 | 
            -
                length_cm: float,
         | 
| 682 | 
            -
                breadth_cm: float,
         | 
| 683 | 
            -
                thickness: int = 2
         | 
| 684 | 
            -
            ) -> np.ndarray:
         | 
| 685 | 
            -
                """
         | 
| 686 | 
            -
                1) Strong red mask overlay + white contour
         | 
| 687 | 
            -
                2) Min-area rectangle
         | 
| 688 | 
            -
                3) Double-headed arrows labeled Length/Width
         | 
| 689 | 
            -
                """
         | 
| 690 | 
            -
                overlay = base_bgr.copy()
         | 
| 691 | 
            -
             | 
| 692 | 
            -
                # Mask tint
         | 
| 693 | 
            -
                mask255 = (mask01 * 255).astype(np.uint8)
         | 
| 694 | 
            -
                mask3 = cv2.merge([mask255, mask255, mask255])
         | 
| 695 | 
            -
                red = np.zeros_like(overlay); red[:] = (0, 0, 255)
         | 
| 696 | 
            -
                alpha = 0.55
         | 
| 697 | 
            -
                tinted = cv2.addWeighted(overlay, 1 - alpha, red, alpha, 0)
         | 
| 698 | 
            -
                overlay = np.where(mask3 > 0, tinted, overlay)
         | 
| 699 | 
            -
             | 
| 700 | 
            -
                # Contour
         | 
| 701 | 
            -
                cnts, _ = cv2.findContours(mask255, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
         | 
| 702 | 
            -
                if cnts:
         | 
| 703 | 
            -
                    cv2.drawContours(overlay, cnts, -1, (255, 255, 255), 2)
         | 
| 704 | 
            -
             | 
| 705 | 
            -
                if rect_box is not None:
         | 
| 706 | 
            -
                    cv2.polylines(overlay, [rect_box], True, (255, 255, 255), thickness)
         | 
| 707 | 
            -
                    pts = rect_box.reshape(-1, 2)
         | 
| 708 | 
            -
             | 
| 709 | 
            -
                    def midpoint(a, b): return (int((a[0] + b[0]) / 2), int((a[1] + b[1]) / 2))
         | 
| 710 | 
            -
                    e = [np.linalg.norm(pts[i] - pts[(i + 1) % 4]) for i in range(4)]
         | 
| 711 | 
            -
                    long_edge_idx = int(np.argmax(e))
         | 
| 712 | 
            -
                    mids = [midpoint(pts[i], pts[(i + 1) % 4]) for i in range(4)]
         | 
| 713 | 
            -
                    long_pair = (long_edge_idx, (long_edge_idx + 2) % 4)
         | 
| 714 | 
            -
                    short_pair = ((long_edge_idx + 1) % 4, (long_edge_idx + 3) % 4)
         | 
| 715 | 
            -
             | 
| 716 | 
            -
                    def draw_double_arrow(img, p1, p2):
         | 
| 717 | 
            -
                        cv2.arrowedLine(img, p1, p2, (0, 0, 0), thickness + 2, tipLength=0.05)
         | 
| 718 | 
            -
                        cv2.arrowedLine(img, p2, p1, (0, 0, 0), thickness + 2, tipLength=0.05)
         | 
| 719 | 
            -
                        cv2.arrowedLine(img, p1, p2, (255, 255, 255), thickness, tipLength=0.05)
         | 
| 720 | 
            -
                        cv2.arrowedLine(img, p2, p1, (255, 255, 255), thickness, tipLength=0.05)
         | 
| 721 | 
            -
             | 
| 722 | 
            -
                    def put_label(text, anchor):
         | 
| 723 | 
            -
                        org = (anchor[0] + 6, anchor[1] - 6)
         | 
| 724 | 
            -
                        cv2.putText(overlay, text, org, cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 4, cv2.LINE_AA)
         | 
| 725 | 
            -
                        cv2.putText(overlay, text, org, cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINE_AA)
         | 
| 726 | 
            -
             | 
| 727 | 
            -
                    draw_double_arrow(overlay, mids[long_pair[0]], mids[long_pair[1]])
         | 
| 728 | 
            -
                    draw_double_arrow(overlay, mids[short_pair[0]], mids[short_pair[1]])
         | 
| 729 | 
            -
                    put_label(f"Length: {length_cm:.2f} cm", mids[long_pair[0]])
         | 
| 730 | 
            -
                    put_label(f"Width:  {breadth_cm:.2f} cm", mids[short_pair[0]])
         | 
| 731 | 
            -
             | 
| 732 | 
            -
                return overlay
         | 
| 733 | 
            -
             | 
| 734 | 
            -
            # ---------- AI PROCESSOR ----------
         | 
| 735 | 
            -
            class AIProcessor:
         | 
| 736 | 
             
                def __init__(self):
         | 
| 737 | 
            -
                    self. | 
| 738 | 
            -
                     | 
| 739 | 
            -
             | 
| 740 | 
            -
             | 
| 741 | 
            -
             | 
| 742 | 
            -
             | 
| 743 | 
            -
             | 
| 744 | 
            -
             | 
| 745 | 
            -
             | 
| 746 | 
            -
             | 
| 747 | 
            -
             | 
| 748 | 
            -
                def perform_visual_analysis(self, image_pil: Image.Image) -> Dict:
         | 
| 749 | 
            -
                    """
         | 
| 750 | 
            -
                    YOLO detect → crop ROI → segment_wound(ROI) → clean mask →
         | 
| 751 | 
            -
                    minAreaRect measurement (cm) using EXIF px/cm → save outputs.
         | 
| 752 | 
            -
                    """
         | 
| 753 | 
            -
                    try:
         | 
| 754 | 
            -
                        px_per_cm, exif_meta = estimate_px_per_cm_from_exif(image_pil, DEFAULT_PX_PER_CM)
         | 
| 755 | 
            -
                        # Guardrails for calibration to avoid huge area blow-ups
         | 
| 756 | 
            -
                        px_per_cm = float(np.clip(px_per_cm, 20.0, 350.0))
         | 
| 757 | 
            -
                        if (exif_meta or {}).get("used") != "exif":
         | 
| 758 | 
            -
                            logging.warning(f"Calibration fallback used: px_per_cm={px_per_cm:.2f} (default). Prefer ruler/Aruco for accuracy.")
         | 
| 759 | 
            -
             | 
| 760 | 
            -
                        image_cv = cv2.cvtColor(np.array(image_pil.convert("RGB")), cv2.COLOR_RGB2BGR)
         | 
| 761 | 
            -
             | 
| 762 | 
            -
                        # --- Detection ---
         | 
| 763 | 
            -
                        det_model = self.models_cache.get("det")
         | 
| 764 | 
            -
                        if det_model is None:
         | 
| 765 | 
            -
                            raise RuntimeError("YOLO model not loaded")
         | 
| 766 | 
            -
                        # Force CPU inference and avoid CUDA touch
         | 
| 767 | 
            -
                        results = det_model.predict(image_cv, verbose=False, device="cpu")
         | 
| 768 | 
            -
                        if (not results) or (not getattr(results[0], "boxes", None)) or (len(results[0].boxes) == 0):
         | 
| 769 | 
            -
                            try:
         | 
| 770 | 
            -
                                import gradio as gr
         | 
| 771 | 
            -
                                raise gr.Error("No wound could be detected.")
         | 
| 772 | 
            -
                            except Exception:
         | 
| 773 | 
            -
                                raise RuntimeError("No wound could be detected.")
         | 
| 774 | 
            -
             | 
| 775 | 
            -
                        box = results[0].boxes[0].xyxy[0].cpu().numpy().astype(int)
         | 
| 776 | 
            -
                        x1, y1, x2, y2 = [int(v) for v in box]
         | 
| 777 | 
            -
                        x1, y1 = max(0, x1), max(0, y1)
         | 
| 778 | 
            -
                        x2, y2 = min(image_cv.shape[1], x2), min(image_cv.shape[0], y2)
         | 
| 779 | 
            -
                        roi = image_cv[y1:y2, x1:x2].copy()
         | 
| 780 | 
            -
                        if roi.size == 0:
         | 
| 781 | 
            -
                            try:
         | 
| 782 | 
            -
                                import gradio as gr
         | 
| 783 | 
            -
                                raise gr.Error("Detected ROI is empty.")
         | 
| 784 | 
            -
                            except Exception:
         | 
| 785 | 
            -
                                raise RuntimeError("Detected ROI is empty.")
         | 
| 786 | 
            -
             | 
| 787 | 
            -
                        out_dir = self._ensure_analysis_dir()
         | 
| 788 | 
            -
                        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
         | 
| 789 | 
            -
             | 
| 790 | 
            -
                        # --- Segmentation (model-first + KMeans fallback) ---
         | 
| 791 | 
            -
                        mask_u8_255, seg_debug = segment_wound(roi, ts, out_dir)
         | 
| 792 | 
            -
                        mask01 = (mask_u8_255 > 127).astype(np.uint8)
         | 
| 793 | 
            -
             | 
| 794 | 
            -
                        if mask01.any():
         | 
| 795 | 
            -
                            mask01 = _clean_mask(mask01)
         | 
| 796 | 
            -
                            logging.debug(f"Mask postproc: px_after={int(mask01.sum())}")
         | 
| 797 | 
            -
             | 
| 798 | 
            -
                        # --- Measurement (accurate & conservative) ---
         | 
| 799 | 
            -
                        if mask01.any():
         | 
| 800 | 
            -
                            length_cm, breadth_cm, (box_pts, _) = measure_min_area_rect(mask01, px_per_cm)
         | 
| 801 | 
            -
                            area_poly_cm2, largest_cnt = area_cm2_from_contour(mask01, px_per_cm)
         | 
| 802 | 
            -
                            if largest_cnt is not None:
         | 
| 803 | 
            -
                                surface_area_cm2 = clamp_area_with_minrect(largest_cnt, px_per_cm, area_poly_cm2)
         | 
| 804 | 
            -
                            else:
         | 
| 805 | 
            -
                                surface_area_cm2 = area_poly_cm2
         | 
| 806 | 
            -
             | 
| 807 | 
            -
                            anno_roi = draw_measurement_overlay(roi, mask01, box_pts, length_cm, breadth_cm)
         | 
| 808 | 
            -
                            segmentation_empty = False
         | 
| 809 | 
            -
                        else:
         | 
| 810 | 
            -
                            # Fallback if seg failed: use ROI dimensions
         | 
| 811 | 
            -
                            h_px = max(0, y2 - y1); w_px = max(0, x2 - x1)
         | 
| 812 | 
            -
                            length_cm = round(max(h_px, w_px) / px_per_cm, 2)
         | 
| 813 | 
            -
                            breadth_cm = round(min(h_px, w_px) / px_per_cm, 2)
         | 
| 814 | 
            -
                            surface_area_cm2 = round((h_px * w_px) / (px_per_cm ** 2), 2)
         | 
| 815 | 
            -
                            anno_roi = roi.copy()
         | 
| 816 | 
            -
                            cv2.rectangle(anno_roi, (2, 2), (anno_roi.shape[1]-3, anno_roi.shape[0]-3), (0, 0, 255), 3)
         | 
| 817 | 
            -
                            cv2.line(anno_roi, (0, 0), (anno_roi.shape[1]-1, anno_roi.shape[0]-1), (0, 0, 255), 2)
         | 
| 818 | 
            -
                            cv2.line(anno_roi, (anno_roi.shape[1]-1, 0), (0, anno_roi.shape[0]-1), (0, 0, 255), 2)
         | 
| 819 | 
            -
                            box_pts = None
         | 
| 820 | 
            -
                            segmentation_empty = True
         | 
| 821 | 
            -
             | 
| 822 | 
            -
                        # --- Save visualizations ---
         | 
| 823 | 
            -
                        original_path = os.path.join(out_dir, f"original_{ts}.png")
         | 
| 824 | 
            -
                        cv2.imwrite(original_path, image_cv)
         | 
| 825 | 
            -
             | 
| 826 | 
            -
                        det_vis = image_cv.copy()
         | 
| 827 | 
            -
                        cv2.rectangle(det_vis, (x1, y1), (x2, y2), (0, 255, 0), 2)
         | 
| 828 | 
            -
                        detection_path = os.path.join(out_dir, f"detection_{ts}.png")
         | 
| 829 | 
            -
                        cv2.imwrite(detection_path, det_vis)
         | 
| 830 | 
            -
             | 
| 831 | 
            -
                        roi_mask_path = os.path.join(out_dir, f"roi_mask_{ts}.png")
         | 
| 832 | 
            -
                        cv2.imwrite(roi_mask_path, (mask01 * 255).astype(np.uint8))
         | 
| 833 | 
            -
             | 
| 834 | 
            -
                        # ROI overlay (mask tint + contour, without arrows)
         | 
| 835 | 
            -
                        mask255 = (mask01 * 255).astype(np.uint8)
         | 
| 836 | 
            -
                        mask3   = cv2.merge([mask255, mask255, mask255])
         | 
| 837 | 
            -
                        red     = np.zeros_like(roi); red[:] = (0, 0, 255)
         | 
| 838 | 
            -
                        alpha   = 0.55
         | 
| 839 | 
            -
                        tinted  = cv2.addWeighted(roi, 1 - alpha, red, alpha, 0)
         | 
| 840 | 
            -
                        if mask255.any():
         | 
| 841 | 
            -
                            roi_overlay = np.where(mask3 > 0, tinted, roi)
         | 
| 842 | 
            -
                            cnts, _ = cv2.findContours(mask255, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
         | 
| 843 | 
            -
                            cv2.drawContours(roi_overlay, cnts, -1, (255, 255, 255), 2)
         | 
| 844 | 
            -
                        else:
         | 
| 845 | 
            -
                            roi_overlay = anno_roi
         | 
| 846 | 
            -
             | 
| 847 | 
            -
                        seg_full = image_cv.copy()
         | 
| 848 | 
            -
                        seg_full[y1:y2, x1:x2] = roi_overlay
         | 
| 849 | 
            -
                        segmentation_path = os.path.join(out_dir, f"segmentation_{ts}.png")
         | 
| 850 | 
            -
                        cv2.imwrite(segmentation_path, seg_full)
         | 
| 851 | 
            -
             | 
| 852 | 
            -
                        segmentation_roi_path = os.path.join(out_dir, f"segmentation_roi_{ts}.png")
         | 
| 853 | 
            -
                        cv2.imwrite(segmentation_roi_path, roi_overlay)
         | 
| 854 | 
            -
             | 
| 855 | 
            -
                        # Annotated (mask + arrows + labels) in full-frame
         | 
| 856 | 
            -
                        anno_full = image_cv.copy()
         | 
| 857 | 
            -
                        anno_full[y1:y2, x1:x2] = anno_roi
         | 
| 858 | 
            -
                        annotated_seg_path = os.path.join(out_dir, f"segmentation_annotated_{ts}.png")
         | 
| 859 | 
            -
                        cv2.imwrite(annotated_seg_path, anno_full)
         | 
| 860 | 
            -
             | 
| 861 | 
            -
                        # --- Optional classification ---
         | 
| 862 | 
            -
                        wound_type = "Unknown"
         | 
| 863 | 
            -
                        cls_pipe = self.models_cache.get("cls")
         | 
| 864 | 
            -
                        if cls_pipe is not None:
         | 
| 865 | 
            -
                            try:
         | 
| 866 | 
            -
                                preds = cls_pipe(Image.fromarray(cv2.cvtColor(roi, cv2.COLOR_BGR2RGB)))
         | 
| 867 | 
            -
                                if preds:
         | 
| 868 | 
            -
                                    wound_type = max(preds, key=lambda x: x.get("score", 0)).get("label", "Unknown")
         | 
| 869 | 
            -
                            except Exception as e:
         | 
| 870 | 
            -
                                logging.warning(f"Classification failed: {e}")
         | 
| 871 | 
            -
             | 
| 872 | 
            -
                        # Log end-of-seg summary
         | 
| 873 | 
            -
                        seg_summary = {
         | 
| 874 | 
            -
                            "seg_used": seg_debug.get("used"),
         | 
| 875 | 
            -
                            "seg_reason": seg_debug.get("reason"),
         | 
| 876 | 
            -
                            "positive_fraction": round(float(seg_debug.get("positive_fraction", 0.0)), 6),
         | 
| 877 | 
            -
                            "threshold": seg_debug.get("thr"),
         | 
| 878 | 
            -
                            "segmentation_empty": segmentation_empty,
         | 
| 879 | 
            -
                            "exif_px_per_cm": round(px_per_cm, 3),
         | 
| 880 | 
            -
                        }
         | 
| 881 | 
            -
                        _log_kv("SEG_SUMMARY", seg_summary)
         | 
| 882 | 
            -
             | 
| 883 | 
            -
                        return {
         | 
| 884 | 
            -
                            "wound_type": wound_type,
         | 
| 885 | 
            -
                            "length_cm": length_cm,
         | 
| 886 | 
            -
                            "breadth_cm": breadth_cm,
         | 
| 887 | 
            -
                            "surface_area_cm2": surface_area_cm2,
         | 
| 888 | 
            -
                            "px_per_cm": round(px_per_cm, 2),
         | 
| 889 | 
            -
                            "calibration_meta": exif_meta,
         | 
| 890 | 
            -
                            "detection_confidence": float(results[0].boxes.conf[0].cpu().item())
         | 
| 891 | 
            -
                                if getattr(results[0].boxes, "conf", None) is not None else 0.0,
         | 
| 892 | 
            -
                            "detection_image_path": detection_path,
         | 
| 893 | 
            -
                            "segmentation_image_path": annotated_seg_path,
         | 
| 894 | 
            -
                            "segmentation_annotated_path": annotated_seg_path,
         | 
| 895 | 
            -
                            "segmentation_roi_path": segmentation_roi_path,
         | 
| 896 | 
            -
                            "roi_mask_path": roi_mask_path,
         | 
| 897 | 
            -
                            "segmentation_empty": segmentation_empty,
         | 
| 898 | 
            -
                            "segmentation_debug": seg_debug,
         | 
| 899 | 
            -
                            "original_image_path": original_path,
         | 
| 900 | 
            -
                        }
         | 
| 901 | 
            -
                    except Exception as e:
         | 
| 902 | 
            -
                        logging.error(f"Visual analysis failed: {e}", exc_info=True)
         | 
| 903 | 
            -
                        raise
         | 
| 904 | 
            -
             | 
| 905 | 
            -
                # ---------- Knowledge base + reporting ----------
         | 
| 906 | 
            -
                def query_guidelines(self, query: str) -> str:
         | 
| 907 | 
            -
                    try:
         | 
| 908 | 
            -
                        vs = self.knowledge_base_cache.get("vector_store")
         | 
| 909 | 
            -
                        if not vs:
         | 
| 910 | 
            -
                            return "Knowledge base is not available."
         | 
| 911 | 
            -
                        retriever = vs.as_retriever(search_kwargs={"k": 5})
         | 
| 912 | 
            -
                        docs = retriever.invoke(query)
         | 
| 913 | 
            -
                        lines: List[str] = []
         | 
| 914 | 
            -
                        for d in docs:
         | 
| 915 | 
            -
                            src = (d.metadata or {}).get("source", "N/A")
         | 
| 916 | 
            -
                            txt = (d.page_content or "")[:300]
         | 
| 917 | 
            -
                            lines.append(f"Source: {src}\nContent: {txt}...")
         | 
| 918 | 
            -
                        return "\n\n".join(lines) if lines else "No relevant guideline snippets found."
         | 
| 919 | 
            -
                    except Exception as e:
         | 
| 920 | 
            -
                        logging.warning(f"Guidelines query failed: {e}")
         | 
| 921 | 
            -
                        return f"Guidelines query failed: {str(e)}"
         | 
| 922 | 
            -
             | 
| 923 | 
            -
                def _generate_fallback_report(self, patient_info: str, visual_results: Dict, guideline_context: str) -> str:
         | 
| 924 | 
            -
                    return f"""# 🩺 SmartHeal AI - Comprehensive Wound Analysis Report
         | 
| 925 | 
            -
            ## 📋 Patient Information
         | 
| 926 | 
            -
            {patient_info}
         | 
| 927 | 
            -
            ## 🔍 Visual Analysis Results
         | 
| 928 | 
            -
            - **Wound Type**: {visual_results.get('wound_type', 'Unknown')}
         | 
| 929 | 
            -
            - **Dimensions**: {visual_results.get('length_cm', 0)} cm × {visual_results.get('breadth_cm', 0)} cm
         | 
| 930 | 
            -
            - **Surface Area**: {visual_results.get('surface_area_cm2', 0)} cm²
         | 
| 931 | 
            -
            - **Detection Confidence**: {visual_results.get('detection_confidence', 0):.1%}
         | 
| 932 | 
            -
            - **Calibration**: {visual_results.get('px_per_cm','?')} px/cm ({(visual_results.get('calibration_meta') or {}).get('used','default')})
         | 
| 933 | 
            -
            ## 📊 Analysis Images
         | 
| 934 | 
            -
            - **Original**: {visual_results.get('original_image_path', 'N/A')}
         | 
| 935 | 
            -
            - **Detection**: {visual_results.get('detection_image_path', 'N/A')}
         | 
| 936 | 
            -
            - **Segmentation**: {visual_results.get('segmentation_image_path', 'N/A')}
         | 
| 937 | 
            -
            - **Annotated**: {visual_results.get('segmentation_annotated_path', 'N/A')}
         | 
| 938 | 
            -
            ## 🎯 Clinical Summary
         | 
| 939 | 
            -
            Automated analysis provides quantitative measurements; verify via clinical examination.
         | 
| 940 | 
            -
            ## 💊 Recommendations
         | 
| 941 | 
            -
            - Cleanse wound gently; select dressing per exudate/infection risk
         | 
| 942 | 
            -
            - Debride necrotic tissue if indicated (clinical decision)
         | 
| 943 | 
            -
            - Document with serial photos and measurements
         | 
| 944 | 
            -
            ## 📅 Monitoring
         | 
| 945 | 
            -
            - Daily in week 1, then every 2–3 days (or as indicated)
         | 
| 946 | 
            -
            - Weekly progress review
         | 
| 947 | 
            -
            ## 📚 Guideline Context
         | 
| 948 | 
            -
            {(guideline_context or '')[:800]}{"..." if guideline_context and len(guideline_context) > 800 else ''}
         | 
| 949 | 
            -
            **Disclaimer:** Automated, for decision support only. Verify clinically.
         | 
| 950 | 
            -
            """
         | 
| 951 | 
            -
             | 
| 952 | 
            -
                def generate_final_report(
         | 
| 953 | 
            -
                    self,
         | 
| 954 | 
            -
                    patient_info: str,
         | 
| 955 | 
            -
                    visual_results: Dict,
         | 
| 956 | 
            -
                    guideline_context: str,
         | 
| 957 | 
            -
                    image_pil: Image.Image,
         | 
| 958 | 
            -
                    max_new_tokens: Optional[int] = None,
         | 
| 959 | 
            -
                ) -> str:
         | 
| 960 | 
            -
                    try:
         | 
| 961 | 
            -
                        report = generate_medgemma_report(
         | 
| 962 | 
            -
                            patient_info, visual_results, guideline_context, image_pil, max_new_tokens
         | 
| 963 | 
             
                        )
         | 
| 964 | 
            -
                         | 
| 965 | 
            -
                            return report
         | 
| 966 | 
            -
                        logging.warning("VLM unavailable/invalid; using fallback.")
         | 
| 967 | 
            -
                        return self._generate_fallback_report(patient_info, visual_results, guideline_context)
         | 
| 968 | 
             
                    except Exception as e:
         | 
| 969 | 
            -
                        logging.error(f" | 
| 970 | 
            -
                         | 
| 971 | 
            -
             | 
| 972 | 
            -
                def save_and_commit_image(self, image_pil: Image.Image) -> str:
         | 
| 973 | 
            -
                    try:
         | 
| 974 | 
            -
                        os.makedirs(self.uploads_dir, exist_ok=True)
         | 
| 975 | 
            -
                        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
         | 
| 976 | 
            -
                        filename = f"{ts}.png"
         | 
| 977 | 
            -
                        path = os.path.join(self.uploads_dir, filename)
         | 
| 978 | 
            -
                        image_pil.convert("RGB").save(path)
         | 
| 979 | 
            -
                        logging.info(f"✅ Image saved locally: {path}")
         | 
| 980 | 
            -
             | 
| 981 | 
            -
                        if HF_TOKEN and DATASET_ID:
         | 
| 982 | 
            -
                            try:
         | 
| 983 | 
            -
                                HfApi, HfFolder = _import_hf_hub()
         | 
| 984 | 
            -
                                HfFolder.save_token(HF_TOKEN)
         | 
| 985 | 
            -
                                api = HfApi()
         | 
| 986 | 
            -
                                api.upload_file(
         | 
| 987 | 
            -
                                    path_or_fileobj=path,
         | 
| 988 | 
            -
                                    path_in_repo=f"images/{filename}",
         | 
| 989 | 
            -
                                    repo_id=DATASET_ID,
         | 
| 990 | 
            -
                                    repo_type="dataset",
         | 
| 991 | 
            -
                                    token=HF_TOKEN,
         | 
| 992 | 
            -
                                    commit_message=f"Upload wound image: {filename}",
         | 
| 993 | 
            -
                                )
         | 
| 994 | 
            -
                                logging.info("✅ Image committed to HF dataset")
         | 
| 995 | 
            -
                            except Exception as e:
         | 
| 996 | 
            -
                                logging.warning(f"HF upload failed: {e}")
         | 
| 997 | 
            -
             | 
| 998 | 
            -
                        return path
         | 
| 999 | 
            -
                    except Exception as e:
         | 
| 1000 | 
            -
                        logging.error(f"Failed to save/commit image: {e}")
         | 
| 1001 | 
            -
                        return ""
         | 
| 1002 | 
            -
             | 
| 1003 | 
            -
                def full_analysis_pipeline(self, image_pil: Image.Image, questionnaire_data: Dict) -> Dict:
         | 
| 1004 | 
            -
                    try:
         | 
| 1005 | 
            -
                        saved_path = self.save_and_commit_image(image_pil)
         | 
| 1006 | 
            -
                        visual_results = self.perform_visual_analysis(image_pil)
         | 
| 1007 | 
            -
             | 
| 1008 | 
            -
                        pi = questionnaire_data or {}
         | 
| 1009 | 
            -
                        patient_info = (
         | 
| 1010 | 
            -
                            f"Age: {pi.get('age','N/A')}, "
         | 
| 1011 | 
            -
                            f"Diabetic: {pi.get('diabetic','N/A')}, "
         | 
| 1012 | 
            -
                            f"Allergies: {pi.get('allergies','N/A')}, "
         | 
| 1013 | 
            -
                            f"Date of Wound: {pi.get('date_of_injury','N/A')}, "
         | 
| 1014 | 
            -
                            f"Professional Care: {pi.get('professional_care','N/A')}, "
         | 
| 1015 | 
            -
                            f"Oozing/Bleeding: {pi.get('oozing_bleeding','N/A')}, "
         | 
| 1016 | 
            -
                            f"Infection: {pi.get('infection','N/A')}, "
         | 
| 1017 | 
            -
                            f"Moisture: {pi.get('moisture','N/A')}"
         | 
| 1018 | 
            -
                        )
         | 
| 1019 | 
            -
             | 
| 1020 | 
            -
                        query = (
         | 
| 1021 | 
            -
                            f"best practices for managing a {visual_results.get('wound_type','Unknown')} "
         | 
| 1022 | 
            -
                            f"with moisture '{pi.get('moisture','unknown')}' and infection '{pi.get('infection','unknown')}' "
         | 
| 1023 | 
            -
                            f"in a diabetic status '{pi.get('diabetic','unknown')}'"
         | 
| 1024 | 
            -
                        )
         | 
| 1025 | 
            -
                        guideline_context = self.query_guidelines(query)
         | 
| 1026 |  | 
| 1027 | 
            -
             | 
|  | |
|  | |
|  | |
|  | |
| 1028 |  | 
| 1029 | 
            -
                        return {
         | 
| 1030 | 
            -
                            "success": True,
         | 
| 1031 | 
            -
                            "visual_analysis": visual_results,
         | 
| 1032 | 
            -
                            "report": report,
         | 
| 1033 | 
            -
                            "saved_image_path": saved_path,
         | 
| 1034 | 
            -
                            "guideline_context": (guideline_context or "")[:500] + (
         | 
| 1035 | 
            -
                                "..." if guideline_context and len(guideline_context) > 500 else ""
         | 
| 1036 | 
            -
                            ),
         | 
| 1037 | 
            -
                        }
         | 
| 1038 | 
            -
                    except Exception as e:
         | 
| 1039 | 
            -
                        logging.error(f"Pipeline error: {e}")
         | 
| 1040 | 
            -
                        return {
         | 
| 1041 | 
            -
                            "success": False,
         | 
| 1042 | 
            -
                            "error": str(e),
         | 
| 1043 | 
            -
                            "visual_analysis": {},
         | 
| 1044 | 
            -
                            "report": f"Analysis failed: {str(e)}",
         | 
| 1045 | 
            -
                            "saved_image_path": None,
         | 
| 1046 | 
            -
                            "guideline_context": "",
         | 
| 1047 | 
            -
                        }
         | 
| 1048 |  | 
| 1049 | 
            -
             | 
| 1050 | 
            -
             | 
| 1051 | 
            -
             | 
| 1052 | 
            -
             | 
| 1053 | 
            -
             | 
| 1054 | 
            -
             | 
| 1055 | 
            -
             | 
| 1056 | 
            -
             | 
| 1057 | 
            -
             | 
| 1058 | 
            -
                            image_pil = Image.fromarray(image)
         | 
| 1059 | 
            -
                        else:
         | 
| 1060 | 
            -
                            raise ValueError(f"Unsupported image type: {type(image)}")
         | 
| 1061 |  | 
| 1062 | 
            -
             | 
| 1063 | 
            -
             | 
| 1064 | 
            -
                        logging.error(f"Wound analysis error: {e}")
         | 
| 1065 | 
            -
                        return {
         | 
| 1066 | 
            -
                            "success": False,
         | 
| 1067 | 
            -
                            "error": str(e),
         | 
| 1068 | 
            -
                            "visual_analysis": {},
         | 
| 1069 | 
            -
                            "report": f"Analysis initialization failed: {str(e)}",
         | 
| 1070 | 
            -
                            "saved_image_path": None,
         | 
| 1071 | 
            -
                            "guideline_context": "",
         | 
| 1072 | 
            -
                        }
         | 
|  | |
| 1 | 
            +
            #!/usr/bin/env python3
         | 
|  | |
|  | |
| 2 |  | 
| 3 | 
             
            import os
         | 
| 4 | 
             
            import logging
         | 
| 5 | 
            +
            import traceback
         | 
| 6 | 
            +
            import gradio as gr
         | 
| 7 | 
            +
            import spaces
         | 
| 8 |  | 
| 9 | 
            +
            # Import internal modules
         | 
| 10 | 
            +
            from src.config import Config
         | 
| 11 | 
            +
            from src.database import DatabaseManager
         | 
| 12 | 
            +
            from src.auth import AuthManager
         | 
| 13 | 
            +
            from src.ai_processor import AIProcessor
         | 
| 14 | 
            +
            from src.ui_components_original import UIComponents
         | 
| 15 |  | 
| 16 | 
            +
            # Logging setup
         | 
| 17 | 
            +
            logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
         | 
|  | |
|  | |
| 18 |  | 
| 19 | 
            +
            class SmartHealApp:
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 20 | 
             
                def __init__(self):
         | 
| 21 | 
            +
                    self.ui_components = None
         | 
| 22 | 
            +
                    try:
         | 
| 23 | 
            +
                        self.config = Config()
         | 
| 24 | 
            +
                        self.database_manager = DatabaseManager(self.config.get_mysql_config())
         | 
| 25 | 
            +
                        self.auth_manager = AuthManager(self.database_manager)
         | 
| 26 | 
            +
                        self.ai_processor = AIProcessor()
         | 
| 27 | 
            +
                        self.ui_components = UIComponents(
         | 
| 28 | 
            +
                            self.auth_manager,
         | 
| 29 | 
            +
                            self.database_manager,
         | 
| 30 | 
            +
                            self.ai_processor
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 31 | 
             
                        )
         | 
| 32 | 
            +
                        logging.info("✅ SmartHeal App initialized successfully.")
         | 
|  | |
|  | |
|  | |
| 33 | 
             
                    except Exception as e:
         | 
| 34 | 
            +
                        logging.error(f"Initialization error: {e}")
         | 
| 35 | 
            +
                        traceback.print_exc()
         | 
| 36 | 
            +
                        raise
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 37 |  | 
| 38 | 
            +
                def launch(self, port=7860, share=True):
         | 
| 39 | 
            +
                    interface = self.ui_components.create_interface()
         | 
| 40 | 
            +
                    interface.launch(
         | 
| 41 | 
            +
                        share=share
         | 
| 42 | 
            +
                    )
         | 
| 43 |  | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 44 |  | 
| 45 | 
            +
            def main():
         | 
| 46 | 
            +
                try:
         | 
| 47 | 
            +
                    app = SmartHealApp()
         | 
| 48 | 
            +
                    app.launch()
         | 
| 49 | 
            +
                except KeyboardInterrupt:
         | 
| 50 | 
            +
                    logging.info("App interrupted by user.")
         | 
| 51 | 
            +
                except Exception:
         | 
| 52 | 
            +
                    logging.error("App failed to start.")
         | 
| 53 | 
            +
                    raise
         | 
|  | |
|  | |
|  | |
| 54 |  | 
| 55 | 
            +
            if __name__ == "__main__":
         | 
| 56 | 
            +
                main()
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  |