Spaces:
Running
Running
| # src/ui_components_original.py | |
| import gradio as gr | |
| import os | |
| import re | |
| import logging | |
| import base64 | |
| from datetime import datetime | |
| from PIL import Image | |
| import html | |
| from typing import Optional, Dict, Any | |
| import numpy as np | |
| import cv2 | |
| import tempfile | |
| # ---- Safe imports for local vs package execution ---- | |
| try: | |
| from .patient_history import PatientHistoryManager, ReportGenerator | |
| except Exception: | |
| from patient_history import PatientHistoryManager, ReportGenerator # local dev | |
| # ---- Optional spaces.GPU fallback (local dev) ---- | |
| try: | |
| import spaces | |
| def _SPACES_GPU(*args, **kwargs): | |
| return spaces.GPU(*args, **kwargs) | |
| except Exception: | |
| def _SPACES_GPU(*_args, **_kwargs): | |
| def deco(f): | |
| return f | |
| return deco | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") | |
| def pil_to_base64(pil_image: Image.Image) -> Optional[str]: | |
| """Convert PIL Image to base64 data URL""" | |
| import io | |
| if pil_image is None: | |
| return None | |
| try: | |
| if pil_image.mode != 'RGB': | |
| pil_image = pil_image.convert('RGB') | |
| buffer = io.BytesIO() | |
| pil_image.save(buffer, format='PNG') | |
| img_str = base64.b64encode(buffer.getvalue()).decode() | |
| return f"data:image/png;base64,{img_str}" | |
| except Exception as e: | |
| logging.error(f"Error converting PIL image to base64: {e}") | |
| return None | |
| # ============================================================================= | |
| # GPU-DECORATED FUNCTION (STANDALONE) | |
| # ============================================================================= | |
| def standalone_run_analysis( | |
| # instance/context | |
| ui_instance, | |
| current_user: Dict[str, Any], | |
| database_manager, | |
| wound_analyzer, | |
| # UI inputs | |
| mode, existing_label, | |
| np_name, np_age, np_gender, | |
| w_loc, w_dur, pain, moist, infect, diabetic, | |
| prev_tx, med_hist, meds, alls, notes, img_path | |
| , seg_adjust=0.0, manual_mask=None | |
| ): | |
| """Runs in the ZeroGPU worker; returns HTML for the UI.""" | |
| def _label_to_id(label: str): | |
| if not label: | |
| return None | |
| try: | |
| return int(str(label).split("•", 1)[0].strip()) | |
| except Exception: | |
| return None | |
| def _fetch_patient_core(pid: int): | |
| row = database_manager.execute_query_one( | |
| "SELECT id, name, age, gender FROM patients WHERE id=%s LIMIT 1", (pid,) | |
| ) | |
| return row or {} | |
| def _response_to_patient_id(resp_ref): | |
| if isinstance(resp_ref, dict): | |
| pid = resp_ref.get("patient_id") | |
| if pid is not None: | |
| try: | |
| return int(pid) | |
| except Exception: | |
| pass | |
| resp_id = resp_ref.get("response_id") or resp_ref.get("id") | |
| else: | |
| resp_id = resp_ref | |
| if not resp_id: | |
| return None | |
| row = database_manager.execute_query_one( | |
| "SELECT patient_id FROM questionnaire_responses WHERE id=%s LIMIT 1", | |
| (int(resp_id),) | |
| ) | |
| try: | |
| return int(row["patient_id"]) if row and "patient_id" in row else None | |
| except Exception: | |
| return None | |
| try: | |
| if not img_path: | |
| return "<div class='status-error'>❌ Please upload a wound image.</div>" | |
| user_id = int(current_user.get("id", 0) or 0) | |
| if not user_id: | |
| return "<div class='status-error'>❌ Please login first.</div>" | |
| # Resolve patient | |
| if mode == "Existing patient": | |
| pid = _label_to_id(existing_label) | |
| if not pid: | |
| return "<div class='status-warning'>⚠️ Select an existing patient.</div>" | |
| pcore = _fetch_patient_core(pid) | |
| patient_name_v = pcore.get("name") | |
| patient_age_v = pcore.get("age") | |
| patient_gender_v = pcore.get("gender") | |
| else: | |
| patient_name_v = np_name | |
| patient_age_v = np_age | |
| patient_gender_v = np_gender | |
| # Save questionnaire | |
| q_payload = { | |
| 'user_id': user_id, | |
| 'patient_name': patient_name_v, | |
| 'patient_age': patient_age_v, | |
| 'patient_gender': patient_gender_v, | |
| 'wound_location': w_loc, | |
| 'wound_duration': w_dur, | |
| 'pain_level': pain, | |
| 'moisture_level': moist, | |
| 'infection_signs': infect, | |
| 'diabetic_status': diabetic, | |
| 'previous_treatment': prev_tx, | |
| 'medical_history': med_hist, | |
| 'medications': meds, | |
| 'allergies': alls, | |
| 'additional_notes': notes | |
| } | |
| response_id = database_manager.save_questionnaire(q_payload) | |
| # normalize | |
| response_id = (response_id.get("response_id") if isinstance(response_id, dict) else response_id) | |
| try: | |
| response_id = int(response_id) | |
| except Exception: | |
| return "<div class='status-error'>❌ Could not resolve response ID.</div>" | |
| patient_id = _response_to_patient_id(response_id) | |
| if not patient_id: | |
| return "<div class='status-error'>❌ Could not resolve patient ID.</div>" | |
| # Save wound image binary | |
| try: | |
| with Image.open(img_path) as pil: | |
| pil = pil.convert("RGB") | |
| img_meta = database_manager.save_wound_image(patient_id, pil) | |
| image_db_id = img_meta["id"] if img_meta else None | |
| except Exception as e: | |
| logging.error(f"save_wound_image error: {e}") | |
| image_db_id = None | |
| # Prepare AI inputs | |
| q_for_ai = { | |
| 'age': patient_age_v, | |
| 'diabetic': 'Yes' if diabetic != 'Non-diabetic' else 'No', | |
| 'allergies': alls, | |
| 'date_of_injury': 'Unknown', | |
| 'professional_care': 'Yes', | |
| 'oozing_bleeding': 'Minor Oozing' if infect != 'None' else 'None', | |
| 'infection': 'Yes' if infect != 'None' else 'No', | |
| 'moisture': moist, | |
| 'patient_name': patient_name_v, | |
| 'patient_gender': patient_gender_v, | |
| 'wound_location': w_loc, | |
| 'wound_duration': w_dur, | |
| 'pain_level': pain, | |
| 'previous_treatment': prev_tx, | |
| 'medical_history': med_hist, | |
| 'medications': meds, | |
| 'additional_notes': notes | |
| } | |
| # Run AI with optional segmentation adjustment and manual mask | |
| analysis_result = wound_analyzer.analyze_wound( | |
| img_path, | |
| q_for_ai, | |
| seg_adjust=seg_adjust or 0.0, | |
| manual_mask_path=manual_mask if manual_mask else None | |
| ) | |
| if not analysis_result or not analysis_result.get("success"): | |
| err = (analysis_result or {}).get("error", "Unknown analysis error") | |
| return f"<div class='status-error'>❌ AI Analysis failed: {html.escape(str(err))}</div>" | |
| # Persist AI analysis | |
| try: | |
| database_manager.save_analysis(response_id, image_db_id, analysis_result) | |
| except Exception as e: | |
| logging.error(f"save_analysis error: {e}") | |
| # Format via instance method to keep UI consistent | |
| return ui_instance._format_comprehensive_analysis_results( | |
| analysis_result, img_path, q_for_ai | |
| ) | |
| except Exception as e: | |
| logging.exception("standalone_run_analysis exception") | |
| return f"<div class='status-error'>❌ System error in GPU worker: {html.escape(str(e))}</div>" | |
| # ============================================================================= | |
| # UI CLASS DEFINITION | |
| # ============================================================================= | |
| class UIComponents: | |
| def __init__(self, auth_manager, database_manager, wound_analyzer): | |
| self.auth_manager = auth_manager | |
| self.database_manager = database_manager | |
| self.wound_analyzer = wound_analyzer | |
| self.current_user = {} | |
| self.patient_history_manager = PatientHistoryManager(database_manager) | |
| self.report_generator = ReportGenerator() | |
| # Ensure uploads directory exists | |
| if not os.path.exists("uploads"): | |
| os.makedirs("uploads", exist_ok=True) | |
| def image_to_base64(self, image_path): | |
| """Convert image to base64 data URL for embedding in HTML""" | |
| if not image_path or not os.path.exists(image_path): | |
| return None | |
| try: | |
| with open(image_path, "rb") as image_file: | |
| encoded_string = base64.b64encode(image_file.read()).decode() | |
| image_ext = os.path.splitext(image_path)[1].lower() | |
| if image_ext in [".jpg", ".jpeg"]: | |
| mime_type = "image/jpeg" | |
| elif image_ext == ".png": | |
| mime_type = "image/png" | |
| elif image_ext == ".gif": | |
| mime_type = "image/gif" | |
| else: | |
| mime_type = "image/png" | |
| return f"data:{mime_type};base64,{encoded_string}" | |
| except Exception as e: | |
| logging.error(f"Error converting image to base64: {e}") | |
| return None | |
| def markdown_to_html(self, markdown_text): | |
| """Convert markdown text to proper HTML format with enhanced support""" | |
| if not markdown_text: | |
| return "" | |
| # Escape HTML entities | |
| html_text = html.escape(markdown_text) | |
| # Headers | |
| html_text = re.sub(r"^### (.*?)$", r"<h3>\1</h3>", html_text, flags=re.MULTILINE) | |
| html_text = re.sub(r"^## (.*?)$", r"<h2>\1</h2>", html_text, flags=re.MULTILINE) | |
| html_text = re.sub(r"^# (.*?)$", r"<h1>\1</h1>", html_text, flags=re.MULTILINE) | |
| # Bold, italic | |
| html_text = re.sub(r"\*\*(.*?)\*\*", r"<strong>\1</strong>", html_text) | |
| html_text = re.sub(r"\*(.*?)\*", r"<em>\1</em>", html_text) | |
| # Code blocks | |
| html_text = re.sub(r"```(.*?)```", r"<pre><code>\1</code></pre>", html_text, flags=re.DOTALL) | |
| # Inline code | |
| html_text = re.sub(r"`(.*?)`", r"<code>\1</code>", html_text) | |
| # Blockquotes | |
| html_text = re.sub(r"^> (.*?)$", r"<blockquote>\1</blockquote>", html_text, flags=re.MULTILINE) | |
| # Links | |
| html_text = re.sub(r"\[(.*?)\]\((.*?)\)", r"<a href=\"\2\">\1</a>", html_text) | |
| # Horizontal rules | |
| html_text = re.sub(r"^\s*[-*_]{3,}\s*$", r"<hr>", html_text, flags=re.MULTILINE) | |
| # Bullet points to <ul> | |
| lines = html_text.split("\n") | |
| in_list = False | |
| result_lines = [] | |
| for line in lines: | |
| stripped = line.strip() | |
| if stripped.startswith("- "): | |
| if not in_list: | |
| result_lines.append("<ul>") | |
| in_list = True | |
| result_lines.append(f"<li>{stripped[2:]}</li>") | |
| else: | |
| if in_list: | |
| result_lines.append("</ul>") | |
| in_list = False | |
| if stripped: | |
| result_lines.append(f"<p>{stripped}</p>") | |
| else: | |
| result_lines.append("<br>") | |
| if in_list: | |
| result_lines.append("</ul>") | |
| return "\n".join(result_lines) | |
| def get_organizations_dropdown(self): | |
| """Get list of organizations for dropdown""" | |
| try: | |
| organizations = self.database_manager.get_organizations() | |
| return [f"{org['org_name']} - {org['location']}" for org in organizations] | |
| except Exception as e: | |
| logging.error(f"Error getting organizations: {e}") | |
| return ["Default Hospital - Location"] | |
| def get_custom_css(self): | |
| return """ | |
| /* =================== SMARTHEAL CSS =================== */ | |
| /* Global Styling */ | |
| body, html { | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif !important; | |
| background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%) !important; | |
| color: #1A202C !important; | |
| line-height: 1.6 !important; | |
| } | |
| /* Professional Header with Logo */ | |
| .medical-header { | |
| background: linear-gradient(135deg, #3182ce 0%, #2c5aa0 100%) !important; | |
| color: white !important; | |
| padding: 32px 40px !important; | |
| border-radius: 20px 20px 0 0 !important; | |
| display: flex !important; | |
| align-items: center !important; | |
| justify-content: center !important; | |
| margin-bottom: 0 !important; | |
| box-shadow: 0 10px 40px rgba(49, 130, 206, 0.3) !important; | |
| border: none !important; | |
| position: relative !important; | |
| overflow: hidden !important; | |
| } | |
| .logo { | |
| width: 80px !important; | |
| height: 80px !important; | |
| border-radius: 50% !important; | |
| margin-right: 24px !important; | |
| border: 4px solid rgba(255, 255, 255, 0.3) !important; | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2) !important; | |
| background: white !important; | |
| padding: 4px !important; | |
| } | |
| .medical-header h1 { | |
| font-size: 3.5rem !important; | |
| font-weight: 800 !important; | |
| margin: 0 !important; | |
| text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3) !important; | |
| background: linear-gradient(45deg, #ffffff, #f8f9fa) !important; | |
| -webkit-background-clip: text !important; | |
| -webkit-text-fill-color: transparent !important; | |
| background-clip: text !important; | |
| filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.3)) !important; | |
| } | |
| .medical-header p { | |
| font-size: 1.3rem !important; | |
| margin: 8px 0 0 0 !important; | |
| opacity: 0.95 !important; | |
| font-weight: 500 !important; | |
| text-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2) !important; | |
| } | |
| /* Enhanced Form Styling */ | |
| .gr-form { | |
| background: linear-gradient(145deg, #ffffff 0%, #f8f9fa 100%) !important; | |
| border-radius: 20px !important; | |
| padding: 32px !important; | |
| margin: 24px 0 !important; | |
| box-shadow: 0 16px 48px rgba(0, 0, 0, 0.1) !important; | |
| border: 1px solid rgba(229, 62, 62, 0.1) !important; | |
| backdrop-filter: blur(10px) !important; | |
| position: relative !important; | |
| overflow: hidden !important; | |
| } | |
| /* Professional Input Fields */ | |
| .gr-textbox, .gr-number { | |
| border-radius: 12px !important; | |
| border: 2px solid #E2E8F0 !important; | |
| background: #FFFFFF !important; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important; | |
| font-size: 1rem !important; | |
| color: #1A202C !important; | |
| padding: 16px 20px !important; | |
| } | |
| .gr-textbox:focus, .gr-number:focus, .gr-textbox input:focus, .gr-number input:focus { | |
| border-color: #E53E3E !important; | |
| box-shadow: 0 0 0 4px rgba(229, 62, 62, 0.1) !important; | |
| background: #FFFFFF !important; | |
| outline: none !important; | |
| transform: translateY(-1px) !important; | |
| } | |
| /* Enhanced Button Styling */ | |
| button.gr-button, button.gr-button-primary { | |
| background: linear-gradient(135deg, #E53E3E 0%, #C53030 100%) !important; | |
| color: #FFFFFF !important; | |
| border: none !important; | |
| border-radius: 12px !important; | |
| font-weight: 700 !important; | |
| padding: 16px 32px !important; | |
| font-size: 1.1rem !important; | |
| letter-spacing: 0.5px !important; | |
| text-align: center !important; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; | |
| box-shadow: 0 4px 16px rgba(229, 62, 62, 0.3) !important; | |
| position: relative !important; | |
| overflow: hidden !important; | |
| text-transform: uppercase !important; | |
| cursor: pointer !important; | |
| } | |
| button.gr-button:hover, button.gr-button-primary:hover { | |
| background: linear-gradient(135deg, #C53030 0%, #9C2A2A 100%) !important; | |
| box-shadow: 0 8px 32px rgba(229, 62, 62, 0.4) !important; | |
| transform: translateY(-3px) !important; | |
| } | |
| /* Professional Status Messages */ | |
| .status-success { | |
| background: linear-gradient(135deg, #F0FFF4 0%, #E6FFFA 100%) !important; | |
| border: 2px solid #38A169 !important; | |
| color: #22543D !important; | |
| padding: 20px 24px !important; | |
| border-radius: 16px !important; | |
| font-weight: 600 !important; | |
| margin: 16px 0 !important; | |
| box-shadow: 0 8px 24px rgba(56, 161, 105, 0.2) !important; | |
| backdrop-filter: blur(10px) !important; | |
| } | |
| .status-error { | |
| background: linear-gradient(135deg, #FFF5F5 0%, #FED7D7 100%) !important; | |
| border: 2px solid #E53E3E !important; | |
| color: #742A2A !important; | |
| padding: 20px 24px !important; | |
| border-radius: 16px !important; | |
| font-weight: 600 !important; | |
| margin: 16px 0 !important; | |
| box-shadow: 0 8px 24px rgba(229, 62, 62, 0.2) !important; | |
| backdrop-filter: blur(10px) !important; | |
| } | |
| .status-warning { | |
| background: linear-gradient(135deg, #FFFBEB 0%, #FEF3C7 100%) !important; | |
| border: 2px solid #F59E0B !important; | |
| color: #92400E !important; | |
| padding: 20px 24px !important; | |
| border-radius: 16px !important; | |
| font-weight: 600 !important; | |
| margin: 16px 0 !important; | |
| box-shadow: 0 8px 24px rgba(245, 158, 11, 0.2) !important; | |
| backdrop-filter: blur(10px) !important; | |
| } | |
| /* Image Gallery */ | |
| .image-gallery { | |
| display: grid !important; | |
| grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)) !important; | |
| gap: 20px !important; | |
| margin: 20px 0 !important; | |
| } | |
| .image-item { | |
| background: white !important; | |
| border-radius: 16px !important; | |
| padding: 20px !important; | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1) !important; | |
| transition: transform 0.3s ease !important; | |
| } | |
| .image-item:hover { | |
| transform: translateY(-5px) !important; | |
| box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15) !important; | |
| } | |
| .image-item img { | |
| width: 100% !important; | |
| height: auto !important; | |
| border-radius: 12px !important; | |
| margin-bottom: 12px !important; | |
| } | |
| .image-item h4 { | |
| color: #2D3748 !important; | |
| margin: 0 0 8px 0 !important; | |
| font-size: 1.2rem !important; | |
| font-weight: 700 !important; | |
| } | |
| .image-item p { | |
| color: #4A5568 !important; | |
| margin: 0 !important; | |
| font-size: 0.95rem !important; | |
| line-height: 1.5 !important; | |
| } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| .medical-header { padding: 16px !important; text-align: center !important; } | |
| .medical-header h1 { font-size: 2rem !important; } | |
| .logo { width: 48px !important; height: 48px !important; margin-right: 16px !important; } | |
| .gr-form { padding: 16px !important; margin: 8px 0 !important; } | |
| .image-gallery { grid-template-columns: 1fr; } | |
| } | |
| """ | |
| def create_interface(self): | |
| """ | |
| SmartHeal UI – aligned with current DB + history manager: | |
| • Login (practitioner / organization) | |
| • Practitioner: Wound Analysis (existing vs new patient), Patient History, View Details | |
| """ | |
| import gradio as gr | |
| from PIL import Image | |
| import os, html, logging | |
| # ----------------------- helpers (inner) ----------------------- | |
| self._patient_choices = [] # list[str] labels in dropdown | |
| self._patient_map = {} # label -> patient_id | |
| def _to_data_url_if_local(path_or_url: str) -> str: | |
| if not path_or_url: | |
| return "" | |
| try: | |
| if os.path.exists(path_or_url): | |
| return self.image_to_base64(path_or_url) or "" | |
| return path_or_url | |
| except Exception: | |
| return "" | |
| def _refresh_patient_dropdown(user_id: int): | |
| """Query patient's list and prepare dropdown choices.""" | |
| self._patient_choices.clear() | |
| self._patient_map.clear() | |
| try: | |
| rows = self.patient_history_manager.get_patient_list(user_id) or [] | |
| for r in rows: | |
| pid = int(r.get("id") or 0) | |
| nm = r.get("patient_name") or "Unknown" | |
| age = r.get("patient_age") or "" | |
| gen = r.get("patient_gender") or "" | |
| v = int(r.get("total_visits") or 0) | |
| label = f"{pid} • {nm} ({age}y {gen}) — visits: {v}" | |
| self._patient_choices.append(label) | |
| self._patient_map[label] = pid | |
| except Exception as e: | |
| logging.error(f"refresh dropdown error: {e}") | |
| def _label_to_id(label: str): | |
| if not label: | |
| return None | |
| try: | |
| return int(str(label).split("•", 1)[0].strip()) | |
| except Exception: | |
| return None | |
| def _resolve_org_id_from_dropdown(label: str) -> Optional[int]: | |
| """ | |
| Dropdown items look like: 'Org Name - Location'. | |
| Try to resolve to organizations.id. | |
| """ | |
| if not label: | |
| return None | |
| try: | |
| if " - " in label: | |
| org_name, location = label.split(" - ", 1) | |
| row = self.database_manager.execute_query_one( | |
| "SELECT id FROM organizations WHERE name=%s AND location=%s ORDER BY id DESC LIMIT 1", | |
| (org_name.strip(), location.strip()) | |
| ) | |
| if row and "id" in row: | |
| return int(row["id"]) | |
| else: | |
| row = self.database_manager.execute_query_one( | |
| "SELECT id FROM organizations WHERE name=%s ORDER BY id DESC LIMIT 1", | |
| (label.strip(),) | |
| ) | |
| if row and "id" in row: | |
| return int(row["id"]) | |
| except Exception as e: | |
| logging.error(f"resolve org id error: {e}") | |
| return None | |
| # ----------------------- Blocks UI ----------------------- | |
| with gr.Blocks(css=self.get_custom_css(), title="SmartHeal - AI Wound Care Assistant") as app: | |
| # Header | |
| logo_url = "https://scontent.fccu31-2.fna.fbcdn.net/v/t39.30808-6/275933824_102121829111657_3325198727201325354_n.jpg?_nc_cat=104&ccb=1-7&_nc_sid=6ee11a&_nc_ohc=5F0FMH9ni8QQ7kNvwHM_7v-&_nc_oc=AdnDo4fj3kdh7ShWq75N3ZEXKuGjbAu9-xZpx6bd82Vo4w0y6D-iHL64ETyW4lWod7s&_nc_zt=23&_nc_ht=scontent.fccu31-2.fna&_nc_gid=a4EiY054p4ChBMLqHCkaIA&oh=00_AfVn-aHeCy95qNhA--DhvjkWp6qdzowKpPRyJ8jevATOmQ&oe=68B1CF4B" | |
| gr.HTML(f""" | |
| <div class="medical-header"> | |
| <img src="{logo_url}" class="logo" alt="SmartHeal Logo"> | |
| <div> | |
| <h1>SmartHeal AI</h1> | |
| <p>Advanced Wound Care Analysis & Clinical Support System</p> | |
| </div> | |
| </div> | |
| """) | |
| # Disclaimer | |
| gr.HTML(""" | |
| <div style="border:2px solid #FF6B6B;background:#FFE5E5;padding:15px;border-radius:12px;margin:10px 0;"> | |
| <h3 style="color:#D63031;margin:0 0 8px 0;">⚠️ IMPORTANT DISCLAIMER</h3> | |
| <p><strong>This system is for testing/education and not a substitute for clinical judgment.</strong></p> | |
| </div> | |
| """) | |
| # Panels: auth vs practitioner vs organization | |
| with gr.Row(): | |
| with gr.Column(visible=True) as auth_panel: | |
| with gr.Tabs(): | |
| with gr.Tab("🔐 Professional Login"): | |
| login_username = gr.Textbox(label="👤 Username") | |
| login_password = gr.Textbox(label="🔒 Password", type="password") | |
| login_btn = gr.Button("🚀 Sign In", variant="primary") | |
| login_status = gr.HTML("<div class='status-warning'>Please sign in.</div>") | |
| with gr.Tab("📝 New Registration"): | |
| signup_username = gr.Textbox(label="👤 Username") | |
| signup_email = gr.Textbox(label="📧 Email") | |
| signup_password = gr.Textbox(label="🔒 Password", type="password") | |
| signup_name = gr.Textbox(label="👨⚕️ Full Name") | |
| signup_role = gr.Radio(["practitioner", "organization"], label="Account Type", value="practitioner") | |
| with gr.Group(visible=False) as org_fields: | |
| org_name = gr.Textbox(label="Organization Name") | |
| phone = gr.Textbox(label="Phone") | |
| country_code = gr.Textbox(label="Country Code") | |
| department = gr.Textbox(label="Department") | |
| location = gr.Textbox(label="Location") | |
| with gr.Group(visible=True) as prac_fields: | |
| organization_dropdown = gr.Dropdown(choices=self.get_organizations_dropdown(), label="Select Organization") | |
| signup_btn = gr.Button("✨ Create Account", variant="primary") | |
| signup_status = gr.HTML() | |
| with gr.Column(visible=False) as practitioner_panel: | |
| user_info = gr.HTML("") | |
| logout_btn_prac = gr.Button("🚪 Logout", variant="secondary") | |
| with gr.Tabs(): | |
| # ------------------- WOUND ANALYSIS ------------------- | |
| with gr.Tab("🔬 Wound Analysis"): | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.HTML("<h3>📋 Patient Selection</h3>") | |
| patient_mode = gr.Radio( | |
| ["Existing patient", "New patient"], | |
| label="Patient mode", | |
| value="Existing patient" | |
| ) | |
| existing_patient_dd = gr.Dropdown( | |
| choices=[], | |
| label="Select existing patient (ID • Name)", | |
| interactive=True | |
| ) | |
| with gr.Group(visible=False) as new_patient_group: | |
| new_patient_name = gr.Textbox(label="Patient Name") | |
| new_patient_age = gr.Number(label="Age", value=30, minimum=0, maximum=120) | |
| new_patient_gender = gr.Dropdown(choices=["Male", "Female", "Other"], value="Male", label="Gender") | |
| gr.HTML("<h3>🩹 Wound Information</h3>") | |
| wound_location = gr.Textbox(label="Wound Location", placeholder="e.g., Left ankle") | |
| wound_duration = gr.Textbox(label="Wound Duration", placeholder="e.g., 2 weeks") | |
| pain_level = gr.Slider(0, 10, value=5, step=1, label="Pain Level (0-10)") | |
| gr.HTML("<h3>⚕️ Clinical Assessment</h3>") | |
| moisture_level = gr.Dropdown(["Dry", "Moist", "Wet", "Saturated"], value="Moist", label="Moisture Level") | |
| infection_signs = gr.Dropdown(["None", "Mild", "Moderate", "Severe"], value="None", label="Signs of Infection") | |
| diabetic_status = gr.Dropdown(["Non-diabetic", "Type 1", "Type 2", "Gestational"], value="Non-diabetic", label="Diabetic Status") | |
| with gr.Column(scale=1): | |
| gr.HTML("<h3>📸 Wound Image</h3>") | |
| wound_image = gr.Image(label="Upload Wound Image", type="filepath") | |
| # Slider to adjust the automatic segmentation mask. Positive values dilate | |
| # (expand) the mask, negative values erode (shrink) it. The value represents | |
| # roughly percentage change where each 5 units corresponds to one iteration. | |
| seg_adjust_slider = gr.Slider( | |
| minimum=-20, | |
| maximum=20, | |
| value=0, | |
| step=1, | |
| label="Segmentation Adjustment", | |
| info="Adjust the automatic segmentation (negative shrinks, positive expands)" | |
| ) | |
| gr.HTML("<h3>📝 Medical History</h3>") | |
| previous_treatment = gr.Textbox(label="Previous Treatment", lines=3) | |
| medical_history = gr.Textbox(label="Medical History", lines=3) | |
| medications = gr.Textbox(label="Current Medications", lines=2) | |
| allergies = gr.Textbox(label="Known Allergies", lines=2) | |
| additional_notes = gr.Textbox(label="Additional Notes", lines=3) | |
| # Initial analysis button | |
| analyze_btn = gr.Button("🔬 Preview Segmentation", variant="primary", elem_id="analyze-btn") | |
| # Segmentation preview section (initially hidden) | |
| with gr.Group(visible=False) as segmentation_preview_group: | |
| gr.HTML("<h3>🎯 Segmentation Preview</h3>") | |
| segmentation_preview = gr.Image(label="Automatic Segmentation", interactive=False) | |
| with gr.Row(): | |
| accept_segmentation_btn = gr.Button("✅ Accept & Generate Full Report", variant="primary") | |
| manual_edit_btn = gr.Button("✏️ Manual Edit", variant="secondary") | |
| segmentation_only_btn = gr.Button("🎯 Get Segmentation Only", variant="secondary") | |
| # Manual editing section (initially hidden) | |
| with gr.Group(visible=False) as manual_edit_group: | |
| gr.HTML(""" | |
| <div style="background: #e6f3ff; padding: 15px; border-radius: 8px; margin: 10px 0;"> | |
| <h4 style="margin: 0 0 10px 0; color: #1a365d;">📝 Manual Segmentation Instructions</h4> | |
| <p style="margin: 0; color: #2c5282;"> | |
| Use the drawing tool below to manually mark the wound area. | |
| Select the pen tool and draw over the wound region to create your mask. | |
| </p> | |
| </div> | |
| """) | |
| # Manual mask input using ImageMask component | |
| manual_mask_input = gr.ImageMask( | |
| sources=["upload"], | |
| layers=False, | |
| transforms=[], | |
| format="png", | |
| label="Manual Segmentation - Draw on the image to mark wound area", | |
| show_label=True, | |
| interactive=True | |
| ) | |
| with gr.Row(): | |
| process_manual_btn = gr.Button("🔬 Generate Report with Manual Mask", variant="primary") | |
| manual_segmentation_only_btn = gr.Button("🎯 Get Manual Segmentation Only", variant="secondary") | |
| analysis_output = gr.HTML("") | |
| # ------------------- PATIENT HISTORY ------------------- | |
| with gr.Tab("📋 Patient History"): | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| history_btn = gr.Button("📄 Load Patient History", variant="primary") | |
| patient_history_output = gr.HTML("") | |
| with gr.Column(scale=1): | |
| search_patient_name = gr.Textbox(label="Search patient by name") | |
| search_patient_btn = gr.Button("🔍 Search", variant="secondary") | |
| specific_patient_output = gr.HTML("") | |
| gr.HTML("<hr style='margin:10px 0 6px 0;border:none;border-top:1px solid #e2e8f0'>") | |
| with gr.Row(): | |
| view_details_dd = gr.Dropdown(choices=[], label="Select patient to view details") | |
| view_details_btn = gr.Button("📈 View Details (Timeline)", variant="primary") | |
| view_details_output = gr.HTML("") | |
| with gr.Column(visible=False) as organization_panel: | |
| gr.HTML("<div class='status-warning'>Organization dashboard coming soon.</div>") | |
| logout_btn_org = gr.Button("🚪 Logout", variant="secondary") | |
| # ----------------------- handlers ----------------------- | |
| def toggle_role_fields(role): | |
| return { | |
| org_fields: gr.update(visible=(role == "organization")), | |
| prac_fields: gr.update(visible=(role != "organization")) | |
| } | |
| def handle_signup(username, email, password, name, role, org_name_v, phone_v, cc_v, dept_v, loc_v, org_dropdown): | |
| try: | |
| organization_id = None | |
| if role == "practitioner": | |
| organization_id = _resolve_org_id_from_dropdown(org_dropdown) | |
| ok = self.auth_manager.create_user( | |
| username=username, | |
| email=email, | |
| password=password, | |
| name=name, | |
| role=role, | |
| org_name=(org_name_v or name) if role == "organization" else "", | |
| phone=phone_v if role == "organization" else "", | |
| country_code=cc_v if role == "organization" else "", | |
| department=dept_v if role == "organization" else "", | |
| location=loc_v if role == "organization" else "", | |
| organization_id=organization_id | |
| ) | |
| if ok: | |
| return "<div class='status-success'>✅ Account created. Please log in.</div>" | |
| return "<div class='status-error'>❌ Could not create account. Username/email may exist.</div>" | |
| except Exception as e: | |
| return f"<div class='status-error'>❌ Error: {html.escape(str(e))}</div>" | |
| def handle_login(username, password): | |
| user = self.auth_manager.authenticate_user(username, password) | |
| if not user: | |
| return { | |
| login_status: "<div class='status-error'>❌ Invalid credentials.</div>" | |
| } | |
| self.current_user = user | |
| uid = int(user.get("id")) | |
| role = user.get("role") | |
| if role == "practitioner": | |
| _refresh_patient_dropdown(uid) | |
| info = f"<div class='status-success'>Welcome, <strong>{html.escape(user.get('name','User'))}</strong> — {html.escape(role)}</div>" | |
| updates = {login_status: info} | |
| if role == "practitioner": | |
| updates.update({ | |
| auth_panel: gr.update(visible=False), | |
| practitioner_panel: gr.update(visible=True), | |
| user_info: info, | |
| existing_patient_dd: gr.update(choices=self._patient_choices), | |
| view_details_dd: gr.update(choices=self._patient_choices), | |
| }) | |
| else: | |
| updates.update({ | |
| auth_panel: gr.update(visible=False), | |
| organization_panel: gr.update(visible=True), | |
| }) | |
| return updates | |
| def handle_logout(): | |
| self.current_user = {} | |
| return { | |
| auth_panel: gr.update(visible=True), | |
| practitioner_panel: gr.update(visible=False), | |
| organization_panel: gr.update(visible=False), | |
| login_status: "<div class='status-warning'>Please sign in.</div>", | |
| segmentation_preview_group: gr.update(visible=False), | |
| manual_edit_group: gr.update(visible=False), | |
| analysis_output: "" | |
| } | |
| def toggle_patient_mode(mode): | |
| return { | |
| existing_patient_dd: gr.update(visible=(mode == "Existing patient")), | |
| new_patient_group: gr.update(visible=(mode == "New patient")) | |
| } | |
| def process_image_for_segmentation( | |
| mode, existing_label, np_name, np_age, np_gender, | |
| w_loc, w_dur, pain, moist, infect, diabetic, | |
| prev_tx, med_hist, meds, alls, notes, img_path, seg_adjust | |
| ): | |
| """Process image and show segmentation preview""" | |
| if not img_path: | |
| return { | |
| segmentation_preview_group: gr.update(visible=False), | |
| analysis_output: "<div class='status-error'>❌ Please upload a wound image.</div>" | |
| } | |
| try: | |
| # Run initial analysis to get segmentation | |
| user_id = int(self.current_user.get("id", 0) or 0) | |
| if not user_id: | |
| return { | |
| segmentation_preview_group: gr.update(visible=False), | |
| analysis_output: "<div class='status-error'>❌ Please login first.</div>" | |
| } | |
| # Prepare questionnaire data for AI | |
| if mode == "Existing patient": | |
| pid = _label_to_id(existing_label) | |
| if not pid: | |
| return { | |
| segmentation_preview_group: gr.update(visible=False), | |
| analysis_output: "<div class='status-warning'>⚠️ Select an existing patient.</div>" | |
| } | |
| # Fetch patient data | |
| row = self.database_manager.execute_query_one( | |
| "SELECT id, name, age, gender FROM patients WHERE id=%s LIMIT 1", (pid,) | |
| ) | |
| pcore = row or {} | |
| patient_name_v = pcore.get("name") | |
| patient_age_v = pcore.get("age") | |
| patient_gender_v = pcore.get("gender") | |
| else: | |
| patient_name_v = np_name | |
| patient_age_v = np_age | |
| patient_gender_v = np_gender | |
| q_for_ai = { | |
| 'age': patient_age_v, | |
| 'diabetic': 'Yes' if diabetic != 'Non-diabetic' else 'No', | |
| 'allergies': alls, | |
| 'date_of_injury': 'Unknown', | |
| 'professional_care': 'Yes', | |
| 'oozing_bleeding': 'Minor Oozing' if infect != 'None' else 'None', | |
| 'infection': 'Yes' if infect != 'None' else 'No', | |
| 'moisture': moist, | |
| 'patient_name': patient_name_v, | |
| 'patient_gender': patient_gender_v, | |
| 'wound_location': w_loc, | |
| 'wound_duration': w_dur, | |
| 'pain_level': pain, | |
| 'previous_treatment': prev_tx, | |
| 'medical_history': med_hist, | |
| 'medications': meds, | |
| 'additional_notes': notes | |
| } | |
| # Run visual analysis only to get segmentation | |
| image_pil = Image.open(img_path) | |
| visual_results = self.wound_analyzer.perform_visual_analysis(image_pil) | |
| if not visual_results: | |
| return { | |
| segmentation_preview_group: gr.update(visible=False), | |
| analysis_output: "<div class='status-error'>❌ Failed to analyze image.</div>" | |
| } | |
| # Get segmentation image path | |
| seg_path = visual_results.get("segmentation_image_path") | |
| if not seg_path or not os.path.exists(seg_path): | |
| return { | |
| segmentation_preview_group: gr.update(visible=False), | |
| analysis_output: "<div class='status-error'>❌ Segmentation failed.</div>" | |
| } | |
| return { | |
| segmentation_preview_group: gr.update(visible=True), | |
| segmentation_preview: seg_path, | |
| manual_edit_group: gr.update(visible=False), | |
| analysis_output: "<div class='status-success'>✅ Segmentation preview ready. Review and choose to accept or manually edit.</div>" | |
| } | |
| except Exception as e: | |
| logging.error(f"Segmentation preview error: {e}") | |
| return { | |
| segmentation_preview_group: gr.update(visible=False), | |
| analysis_output: f"<div class='status-error'>❌ Error: {html.escape(str(e))}</div>" | |
| } | |
| def show_manual_edit_interface(img_path): | |
| """Show manual editing interface with the original image""" | |
| if not img_path or not os.path.exists(img_path): | |
| return { | |
| manual_edit_group: gr.update(visible=False), | |
| analysis_output: "<div class='status-error'>❌ Original image not available for editing.</div>" | |
| } | |
| return { | |
| manual_edit_group: gr.update(visible=True), | |
| manual_mask_input: img_path, # Load the original image for manual editing | |
| analysis_output: "<div class='status-warning'>⚠️ Use the drawing tool to manually mark the wound area, then click your desired action.</div>" | |
| } | |
| def process_manual_mask(mask_data): | |
| """Process the manual mask from ImageMask component""" | |
| if not mask_data: | |
| return None | |
| try: | |
| # Extract the mask from the ImageMask component | |
| # The mask_data contains both the background image and the drawn mask | |
| if isinstance(mask_data, dict): | |
| # Check if composite exists (newer format) | |
| if "composite" in mask_data: | |
| composite_img = mask_data["composite"] | |
| # Convert to grayscale and extract the drawn areas | |
| gray = cv2.cvtColor(composite_img, cv2.COLOR_RGB2GRAY) | |
| # Create mask where drawn areas are white (255) and background is black (0) | |
| _, mask = cv2.threshold(gray, 1, 255, cv2.THRESH_BINARY) | |
| # Check if layers exist (older format) | |
| elif "layers" in mask_data and len(mask_data["layers"]) > 0: | |
| # Get the alpha channel from the first layer (the drawn mask) | |
| alpha_channel = mask_data["layers"][0][:, :, 3] | |
| # Convert to binary mask - drawn areas have alpha > 0 | |
| mask = np.where(alpha_channel > 0, 255, 0).astype(np.uint8) | |
| else: | |
| return None | |
| # Save the mask temporarily | |
| with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: | |
| cv2.imwrite(tmp.name, mask) | |
| manual_mask_path = tmp.name | |
| return manual_mask_path | |
| else: | |
| return None | |
| except Exception as e: | |
| logging.error(f"Manual mask processing error: {e}") | |
| return None | |
| def get_segmentation_only(img_path, manual_mask_path=None): | |
| """Get only the segmentation mask without full analysis""" | |
| try: | |
| if not img_path: | |
| return "<div class='status-error'>❌ No image provided.</div>" | |
| # Run visual analysis to get segmentation | |
| image_pil = Image.open(img_path) | |
| if manual_mask_path: | |
| # Use manual mask | |
| visual_results = self.wound_analyzer.analyze_wound( | |
| img_path, {}, seg_adjust=0.0, manual_mask_path=manual_mask_path | |
| ) | |
| mask_type = "Manual" | |
| else: | |
| # Use automatic segmentation | |
| visual_results = self.wound_analyzer.perform_visual_analysis(image_pil) | |
| mask_type = "Automatic" | |
| if not visual_results: | |
| return "<div class='status-error'>❌ Failed to generate segmentation.</div>" | |
| # Get the segmentation mask path | |
| roi_mask_path = visual_results.get("roi_mask_path") | |
| seg_path = visual_results.get("segmentation_image_path") | |
| if not roi_mask_path or not os.path.exists(roi_mask_path): | |
| return "<div class='status-error'>❌ Segmentation mask not found.</div>" | |
| # Convert mask to base64 for display | |
| mask_b64 = self.image_to_base64(roi_mask_path) | |
| seg_b64 = self.image_to_base64(seg_path) if seg_path and os.path.exists(seg_path) else None | |
| html_output = f""" | |
| <div style="max-width: 800px; margin: 0 auto; background: white; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.1); overflow: hidden;"> | |
| <div style="background: linear-gradient(135deg, #3182ce 0%, #2c5aa0 100%); color: white; padding: 30px; text-align: center;"> | |
| <h1 style="margin: 0; font-size: 28px; font-weight: 700;">🎯 {mask_type} Wound Segmentation</h1> | |
| <p style="margin: 10px 0 0 0; opacity: 0.9; font-size: 16px;">Binary mask showing wound boundaries</p> | |
| </div> | |
| <div style="padding: 30px;"> | |
| <div class="status-success" style="margin-bottom: 20px;"> | |
| <strong>✅ Segmentation Status:</strong> {mask_type} segmentation completed successfully | |
| </div> | |
| <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 20px 0;"> | |
| <div style="background: #f8f9fa; padding: 20px; border-radius: 12px; text-align: center;"> | |
| <h3 style="color: #2d3748; margin-top: 0;">Binary Mask</h3> | |
| {f'<img src="{mask_b64}" style="width: 100%; border-radius: 8px;" alt="Segmentation Mask">' if mask_b64 else '<p>Mask not available</p>'} | |
| <p style="color: #4a5568; margin: 10px 0 0 0; font-size: 14px;">White = Wound, Black = Background</p> | |
| </div> | |
| <div style="background: #f8f9fa; padding: 20px; border-radius: 12px; text-align: center;"> | |
| <h3 style="color: #2d3748; margin-top: 0;">Overlay Visualization</h3> | |
| {f'<img src="{seg_b64}" style="width: 100%; border-radius: 8px;" alt="Segmentation Overlay">' if seg_b64 else '<p>Overlay not available</p>'} | |
| <p style="color: #4a5568; margin: 10px 0 0 0; font-size: 14px;">Red overlay shows detected wound area</p> | |
| </div> | |
| </div> | |
| <div style="background: #fff5f5; border: 2px solid #feb2b2; padding: 20px; border-radius: 12px; margin: 20px 0;"> | |
| <h3 style="color: #c53030; margin-top: 0;">📥 Download Instructions</h3> | |
| <p style="color: #742a2a; margin: 0;">Right-click on the binary mask image above and select "Save image as..." to download the segmentation mask for your use.</p> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| # Clean up temporary file if it exists | |
| if manual_mask_path and os.path.exists(manual_mask_path): | |
| try: | |
| os.unlink(manual_mask_path) | |
| except: | |
| pass | |
| return html_output | |
| except Exception as e: | |
| logging.error(f"Segmentation only error: {e}") | |
| return f"<div class='status-error'>❌ Error: {html.escape(str(e))}</div>" | |
| def run_full_analysis_with_manual_mask( | |
| mode, existing_label, np_name, np_age, np_gender, | |
| w_loc, w_dur, pain, moist, infect, diabetic, | |
| prev_tx, med_hist, meds, alls, notes, img_path, seg_adjust, mask_data | |
| ): | |
| """Run full analysis with manual mask""" | |
| try: | |
| # Process manual mask | |
| manual_mask_path = process_manual_mask(mask_data) | |
| # Run the full analysis with manual mask | |
| result_html = standalone_run_analysis( | |
| self, self.current_user, self.database_manager, self.wound_analyzer, | |
| mode, existing_label, np_name, np_age, np_gender, | |
| w_loc, w_dur, pain, moist, infect, diabetic, | |
| prev_tx, med_hist, meds, alls, notes, img_path, | |
| seg_adjust, manual_mask_path | |
| ) | |
| # Clean up temporary file | |
| if manual_mask_path and os.path.exists(manual_mask_path): | |
| try: | |
| os.unlink(manual_mask_path) | |
| except: | |
| pass | |
| return { | |
| analysis_output: result_html, | |
| segmentation_preview_group: gr.update(visible=False), | |
| manual_edit_group: gr.update(visible=False) | |
| } | |
| except Exception as e: | |
| logging.error(f"Manual analysis error: {e}") | |
| return { | |
| analysis_output: f"<div class='status-error'>❌ Analysis failed: {html.escape(str(e))}</div>" | |
| } | |
| def get_manual_segmentation_only(mask_data, img_path): | |
| """Get only the manual segmentation mask""" | |
| try: | |
| manual_mask_path = process_manual_mask(mask_data) | |
| if not manual_mask_path: | |
| return "<div class='status-error'>❌ Failed to process manual mask.</div>" | |
| result = get_segmentation_only(img_path, manual_mask_path) | |
| return { | |
| analysis_output: result, | |
| segmentation_preview_group: gr.update(visible=False), | |
| manual_edit_group: gr.update(visible=False) | |
| } | |
| except Exception as e: | |
| logging.error(f"Manual segmentation only error: {e}") | |
| return { | |
| analysis_output: f"<div class='status-error'>❌ Error: {html.escape(str(e))}</div>" | |
| } | |
| def get_auto_segmentation_only(img_path): | |
| """Get only the automatic segmentation mask""" | |
| try: | |
| result = get_segmentation_only(img_path, None) | |
| return { | |
| analysis_output: result, | |
| segmentation_preview_group: gr.update(visible=False), | |
| manual_edit_group: gr.update(visible=False) | |
| } | |
| except Exception as e: | |
| logging.error(f"Auto segmentation only error: {e}") | |
| return { | |
| analysis_output: f"<div class='status-error'>❌ Error: {html.escape(str(e))}</div>" | |
| } | |
| def run_full_analysis_accept_segmentation( | |
| mode, existing_label, np_name, np_age, np_gender, | |
| w_loc, w_dur, pain, moist, infect, diabetic, | |
| prev_tx, med_hist, meds, alls, notes, img_path, seg_adjust | |
| ): | |
| """Run full analysis accepting the automatic segmentation""" | |
| try: | |
| result_html = standalone_run_analysis( | |
| self, self.current_user, self.database_manager, self.wound_analyzer, | |
| mode, existing_label, np_name, np_age, np_gender, | |
| w_loc, w_dur, pain, moist, infect, diabetic, | |
| prev_tx, med_hist, meds, alls, notes, img_path, | |
| seg_adjust, None # No manual mask | |
| ) | |
| return { | |
| analysis_output: result_html, | |
| segmentation_preview_group: gr.update(visible=False), | |
| manual_edit_group: gr.update(visible=False) | |
| } | |
| except Exception as e: | |
| logging.error(f"Analysis error: {e}") | |
| return { | |
| analysis_output: f"<div class='status-error'>❌ Analysis failed: {html.escape(str(e))}</div>" | |
| } | |
| def load_patient_history(): | |
| try: | |
| uid = int(self.current_user.get("id", 0)) | |
| if not uid: | |
| return "<div class='status-error'>❌ Please login first.</div>" | |
| history_data = self.patient_history_manager.get_patient_history(uid) | |
| if not history_data: | |
| return "<div class='status-warning'>⚠️ No patient history found.</div>" | |
| html_report = self.report_generator.generate_history_report(history_data) | |
| return html_report | |
| except Exception as e: | |
| logging.error(f"History load error: {e}") | |
| return f"<div class='status-error'>❌ Error: {html.escape(str(e))}</div>" | |
| def search_patient_by_name(name): | |
| try: | |
| uid = int(self.current_user.get("id", 0)) | |
| if not uid: | |
| return "<div class='status-error'>❌ Please login first.</div>" | |
| if not name or not name.strip(): | |
| return "<div class='status-warning'>⚠️ Enter a patient name to search.</div>" | |
| results = self.patient_history_manager.search_patients_by_name(uid, name.strip()) | |
| if not results: | |
| return f"<div class='status-warning'>⚠️ No patients found matching '{html.escape(name)}'.</div>" | |
| html_report = self.report_generator.generate_search_results(results, name) | |
| return html_report | |
| except Exception as e: | |
| logging.error(f"Patient search error: {e}") | |
| return f"<div class='status-error'>❌ Error: {html.escape(str(e))}</div>" | |
| def view_patient_details(selected_label): | |
| try: | |
| uid = int(self.current_user.get("id", 0)) | |
| if not uid: | |
| return "<div class='status-error'>❌ Please login first.</div>" | |
| pid = _label_to_id(selected_label) | |
| if not pid: | |
| return "<div class='status-warning'>⚠️ Select a patient to view details.</div>" | |
| details = self.patient_history_manager.get_patient_details(uid, pid) | |
| if not details: | |
| return "<div class='status-warning'>⚠️ No details found for selected patient.</div>" | |
| html_report = self.report_generator.generate_patient_timeline(details) | |
| return html_report | |
| except Exception as e: | |
| logging.error(f"Patient details error: {e}") | |
| return f"<div class='status-error'>❌ Error: {html.escape(str(e))}</div>" | |
| # ----------------------- Event bindings ----------------------- | |
| signup_role.change(toggle_role_fields, [signup_role], [org_fields, prac_fields]) | |
| signup_btn.click( | |
| handle_signup, | |
| [signup_username, signup_email, signup_password, signup_name, signup_role, | |
| org_name, phone, country_code, department, location, organization_dropdown], | |
| [signup_status] | |
| ) | |
| login_btn.click( | |
| handle_login, | |
| [login_username, login_password], | |
| [login_status, auth_panel, practitioner_panel, organization_panel, user_info, | |
| existing_patient_dd, view_details_dd] | |
| ) | |
| logout_btn_prac.click( | |
| handle_logout, | |
| [], | |
| [auth_panel, practitioner_panel, organization_panel, login_status, | |
| segmentation_preview_group, manual_edit_group, analysis_output] | |
| ) | |
| logout_btn_org.click( | |
| handle_logout, | |
| [], | |
| [auth_panel, practitioner_panel, organization_panel, login_status, | |
| segmentation_preview_group, manual_edit_group, analysis_output] | |
| ) | |
| patient_mode.change(toggle_patient_mode, [patient_mode], [existing_patient_dd, new_patient_group]) | |
| # Segmentation preview workflow | |
| analyze_btn.click( | |
| process_image_for_segmentation, | |
| [patient_mode, existing_patient_dd, new_patient_name, new_patient_age, new_patient_gender, | |
| wound_location, wound_duration, pain_level, moisture_level, infection_signs, diabetic_status, | |
| previous_treatment, medical_history, medications, allergies, additional_notes, wound_image, seg_adjust_slider], | |
| [segmentation_preview_group, segmentation_preview, manual_edit_group, analysis_output] | |
| ) | |
| # Accept segmentation and generate full report | |
| accept_segmentation_btn.click( | |
| run_full_analysis_accept_segmentation, | |
| [patient_mode, existing_patient_dd, new_patient_name, new_patient_age, new_patient_gender, | |
| wound_location, wound_duration, pain_level, moisture_level, infection_signs, diabetic_status, | |
| previous_treatment, medical_history, medications, allergies, additional_notes, wound_image, seg_adjust_slider], | |
| [analysis_output, segmentation_preview_group, manual_edit_group] | |
| ) | |
| # Get segmentation only (automatic) | |
| segmentation_only_btn.click( | |
| get_auto_segmentation_only, | |
| [wound_image], | |
| [analysis_output, segmentation_preview_group, manual_edit_group] | |
| ) | |
| # Show manual edit interface | |
| manual_edit_btn.click( | |
| show_manual_edit_interface, | |
| [wound_image], | |
| [manual_edit_group, manual_mask_input, analysis_output] | |
| ) | |
| # Process manual mask and generate report | |
| process_manual_btn.click( | |
| run_full_analysis_with_manual_mask, | |
| [patient_mode, existing_patient_dd, new_patient_name, new_patient_age, new_patient_gender, | |
| wound_location, wound_duration, pain_level, moisture_level, infection_signs, diabetic_status, | |
| previous_treatment, medical_history, medications, allergies, additional_notes, wound_image, seg_adjust_slider, manual_mask_input], | |
| [analysis_output, segmentation_preview_group, manual_edit_group] | |
| ) | |
| # Get manual segmentation only | |
| manual_segmentation_only_btn.click( | |
| get_manual_segmentation_only, | |
| [manual_mask_input, wound_image], | |
| [analysis_output, segmentation_preview_group, manual_edit_group] | |
| ) | |
| history_btn.click(load_patient_history, [], [patient_history_output]) | |
| search_patient_btn.click(search_patient_by_name, [search_patient_name], [specific_patient_output]) | |
| view_details_btn.click(view_patient_details, [view_details_dd], [view_details_output]) | |
| return app | |
| def _format_comprehensive_analysis_results(self, analysis_result, image_path, questionnaire_data): | |
| """Format comprehensive analysis results with enhanced visual presentation | |
| - Shows 'Manual Segmentation' card if a manual mask/overlay was used | |
| - Removes the Measurements card from the Visual Analysis Gallery | |
| """ | |
| import os | |
| try: | |
| visual_analysis = analysis_result.get("visual_analysis", {}) or {} | |
| report = analysis_result.get("report", "") | |
| # Extract key metrics | |
| wound_type = visual_analysis.get("wound_type", "Unknown") | |
| length_cm = visual_analysis.get("length_cm", 0) | |
| breadth_cm = visual_analysis.get("breadth_cm", 0) | |
| area_cm2 = visual_analysis.get("surface_area_cm2", 0) | |
| skin_tone_label = visual_analysis.get("skin_tone_label", "Unknown") | |
| ita_deg = visual_analysis.get("ita_degrees") | |
| tissue_type = visual_analysis.get("tissue_type", "Unknown") | |
| # Detect if manual mask was used (look across common keys/flags) | |
| manual_used = bool( | |
| analysis_result.get("manual_mask_used") | |
| or visual_analysis.get("manual_mask_used") | |
| ) | |
| # Try to discover manual overlay/binary mask paths (handle multiple possible keys) | |
| manual_overlay_path = None | |
| for k in [ | |
| "manual_segmentation_image_path", | |
| "manual_overlay_path", | |
| "manual_segmentation_overlay_path", | |
| ]: | |
| p = visual_analysis.get(k) | |
| if p and os.path.exists(p): | |
| manual_overlay_path = p | |
| manual_used = True | |
| break | |
| manual_binary_path = None | |
| for k in [ | |
| "manual_roi_mask_path", | |
| "manual_mask_binary_path", | |
| "manual_mask_path", | |
| ]: | |
| p = visual_analysis.get(k) | |
| if p and os.path.exists(p): | |
| manual_binary_path = p | |
| manual_used = True | |
| break | |
| # Generate risk assessment | |
| risk_assessment = self._generate_risk_assessment(questionnaire_data) | |
| risk_level = risk_assessment.get("risk_level", "Unknown") | |
| risk_score = risk_assessment.get("risk_score", 0) | |
| risk_factors = risk_assessment.get("risk_factors", []) | |
| risk_class = risk_level.lower().replace(" ", "_") | |
| # Format risk factors | |
| if risk_factors: | |
| risk_factors_html = "<ul style='margin: 10px 0; padding-left: 20px;'>" | |
| for factor in risk_factors: | |
| risk_factors_html += f"<li style='margin: 5px 0; color: #2d3748;'>{html.escape(str(factor))}</li>" | |
| risk_factors_html += "</ul>" | |
| else: | |
| risk_factors_html = "<p style='color: #4a5568; font-style: italic;'>No specific risk factors identified.</p>" | |
| # ---------------------- Image Gallery ---------------------- | |
| image_gallery_html = "<div class='image-gallery'>" | |
| # Original image | |
| if image_path and os.path.exists(image_path): | |
| img_b64 = self.image_to_base64(image_path) | |
| if img_b64: | |
| image_gallery_html += f""" | |
| <div class="image-item"> | |
| <img src="{img_b64}" alt="Original Wound Image"> | |
| <h4>📸 Original Image</h4> | |
| <p>Uploaded wound photograph for analysis</p> | |
| </div> | |
| """ | |
| # Detection visualization | |
| detection_path = visual_analysis.get("detection_image_path") | |
| if detection_path and os.path.exists(detection_path): | |
| img_b64 = self.image_to_base64(detection_path) | |
| if img_b64: | |
| conf = visual_analysis.get('detection_confidence', 0.0) | |
| try: | |
| conf_str = f"{float(conf):.1%}" | |
| except Exception: | |
| conf_str = str(conf) | |
| image_gallery_html += f""" | |
| <div class="image-item"> | |
| <img src="{img_b64}" alt="Wound Detection"> | |
| <h4>🎯 Wound Detection</h4> | |
| <p>AI-powered wound boundary detection with confidence: {conf_str}</p> | |
| </div> | |
| """ | |
| # Show MANUAL segmentation card if available/used | |
| if manual_overlay_path or manual_binary_path: | |
| if manual_overlay_path and os.path.exists(manual_overlay_path): | |
| img_b64 = self.image_to_base64(manual_overlay_path) | |
| elif manual_binary_path and os.path.exists(manual_binary_path): | |
| img_b64 = self.image_to_base64(manual_binary_path) | |
| else: | |
| img_b64 = None | |
| if img_b64: | |
| image_gallery_html += f""" | |
| <div class="image-item"> | |
| <img src="{img_b64}" alt="Manual Segmentation"> | |
| <h4>✏️ Manual Segmentation</h4> | |
| <p>Clinician-adjusted wound boundary used for this report</p> | |
| </div> | |
| """ | |
| # Automatic segmentation (still show if present) | |
| seg_path = visual_analysis.get("segmentation_image_path") | |
| if seg_path and os.path.exists(seg_path): | |
| img_b64 = self.image_to_base64(seg_path) | |
| if img_b64: | |
| image_gallery_html += f""" | |
| <div class="image-item"> | |
| <img src="{img_b64}" alt="Wound Segmentation"> | |
| <h4>🔍 Wound Segmentation</h4> | |
| <p>Precise wound boundary identification and tissue analysis</p> | |
| </div> | |
| """ | |
| # NOTE: Measurements card intentionally REMOVED from gallery as requested | |
| # (Do NOT add segmentation_annotated_path card) | |
| image_gallery_html += "</div>" | |
| # Convert report markdown to HTML | |
| report_html = self.markdown_to_html(report) if report else "" | |
| status_line = ( | |
| "Analysis completed successfully with <strong>manual</strong> segmentation" | |
| if manual_used else | |
| "Analysis completed successfully with comprehensive wound assessment" | |
| ) | |
| html_output = f""" | |
| <div style="max-width: 1200px; margin: 0 auto; background: white; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.1); overflow: hidden;"> | |
| <div style="background: linear-gradient(135deg, #3182ce 0%, #2c5aa0 100%); color: white; padding: 40px; text-align: center;"> | |
| <h1 style="margin: 0; font-size: 32px; font-weight: 700;">🔬 SmartHeal AI Comprehensive Analysis</h1> | |
| <p style="margin: 15px 0 0 0; opacity: 0.9; font-size: 18px;">Advanced Computer Vision & Medical AI Assessment</p> | |
| <div style="background: rgba(255,255,255,0.1); padding: 15px; border-radius: 8px; margin-top: 20px;"> | |
| <p style="margin: 0; font-size: 16px;"><strong>Patient:</strong> {html.escape(str(questionnaire_data.get('patient_name', 'Unknown')))} | <strong>Analysis Date:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p> | |
| </div> | |
| </div> | |
| <div style="padding: 40px;"> | |
| <div class="status-success" style="margin-bottom: 30px;"> | |
| <strong>✅ Analysis Status:</strong> {status_line} | |
| </div> | |
| <!-- Image Gallery Section --> | |
| <div style="margin-bottom: 40px;"> | |
| <h2 style="color: #2d3748; font-size: 24px; margin-bottom: 20px; border-bottom: 2px solid #e53e3e; padding-bottom: 10px;">🖼️ Visual Analysis Gallery</h2> | |
| {image_gallery_html} | |
| </div> | |
| <!-- Wound Detection & Classification --> | |
| <div style="background: #f8f9fa; padding: 30px; border-radius: 12px; margin-bottom: 30px;"> | |
| <h2 style="color: #2d3748; margin-top: 0;">🔍 Wound Detection & Classification</h2> | |
| <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0;"> | |
| <div style="background: white; padding: 20px; border-radius: 8px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> | |
| <h3 style="color: #3182ce; margin: 0 0 10px 0;">Wound Type</h3> | |
| <p style="font-weight: 600; font-size: 18px; color: #2d3748; margin: 0;">{html.escape(str(wound_type))}</p> | |
| </div> | |
| <div style="background: white; padding: 20px; border-radius: 8px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> | |
| <h3 style="color: #3182ce; margin: 0 0 10px 0;">Location</h3> | |
| <p style="font-weight: 600; font-size: 18px; color: #2d3748; margin: 0;">{html.escape(str(questionnaire_data.get('wound_location', 'Not specified')))}</p> | |
| </div> | |
| <div style="background: white; padding: 20px; border-radius: 8px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> | |
| <h3 style="color: #3182ce; margin: 0 0 10px 0;">Skin Tone</h3> | |
| <p style="font-weight: 600; font-size: 18px; color: #2d3748; margin: 0;">{html.escape(str(skin_tone_label))}{f" ({ita_deg:.1f}°)" if ita_deg is not None else ""}</p> | |
| </div> | |
| <div style="background: white; padding: 20px; border-radius: 8px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> | |
| <h3 style="color: #3182ce; margin: 0 0 10px 0;">Tissue Type</h3> | |
| <p style="font-weight: 600; font-size: 18px; color: #2d3748; margin: 0;">{html.escape(str(tissue_type))}</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Wound Measurements (numeric section remains) --> | |
| <div style="background: #e7f5ff; padding: 30px; border-radius: 12px; margin-bottom: 30px;"> | |
| <h2 style="color: #2d3748; margin-top: 0;">📏 Wound Measurements</h2> | |
| <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 20px; margin: 20px 0;"> | |
| <div style="background: white; padding: 20px; border-radius: 8px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> | |
| <h3 style="color: #e53e3e; margin: 0 0 10px 0;">Length</h3> | |
| <p style="font-weight: 700; font-size: 24px; color: #2d3748; margin: 0;">{length_cm:.2f} cm</p> | |
| </div> | |
| <div style="background: white; padding: 20px; border-radius: 8px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> | |
| <h3 style="color: #e53e3e; margin: 0 0 10px 0;">Width</h3> | |
| <p style="font-weight: 700; font-size: 24px; color: #2d3748; margin: 0;">{breadth_cm:.2f} cm</p> | |
| </div> | |
| <div style="background: white; padding: 20px; border-radius: 8px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> | |
| <h3 style="color: #e53e3e; margin: 0 0 10px 0;">Surface Area</h3> | |
| <p style="font-weight: 700; font-size: 24px; color: #2d3748; margin: 0;">{area_cm2:.2f} cm²</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Patient Information Summary --> | |
| <div style="background: #f0f8f0; padding: 30px; border-radius: 12px; margin-bottom: 30px;"> | |
| <h2 style="color: #2d3748; margin-top: 0;">👤 Patient Information Summary</h2> | |
| <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px;"> | |
| <div><strong>Age:</strong> {html.escape(str(questionnaire_data.get('age', 'Not specified')))} years</div> | |
| <div><strong>Gender:</strong> {html.escape(str(questionnaire_data.get('patient_gender', 'Not specified')))}</div> | |
| <div><strong>Diabetic Status:</strong> {html.escape(str(questionnaire_data.get('diabetic', 'Unknown')))}</div> | |
| <div><strong>Pain Level:</strong> {html.escape(str(questionnaire_data.get('pain_level', 'Not assessed')))} / 10</div> | |
| <div><strong>Wound Duration:</strong> {html.escape(str(questionnaire_data.get('wound_duration', 'Not specified')))}</div> | |
| <div><strong>Moisture Level:</strong> {html.escape(str(questionnaire_data.get('moisture', 'Not assessed')))}</div> | |
| </div> | |
| {f"<div style='margin-top: 20px;'><strong>Medical History:</strong> {html.escape(str(questionnaire_data.get('medical_history', 'None provided')))}</div>" if questionnaire_data.get('medical_history') else ""} | |
| {f"<div style='margin-top: 10px;'><strong>Current Medications:</strong> {html.escape(str(questionnaire_data.get('medications', 'None listed')))}</div>" if questionnaire_data.get('medications') else ""} | |
| {f"<div style='margin-top: 10px;'><strong>Known Allergies:</strong> {html.escape(str(questionnaire_data.get('allergies', 'None listed')))}</div>" if questionnaire_data.get('allergies') else ""} | |
| </div> | |
| <!-- AI Generated Report --> | |
| {f'<div style="background: #f8f9fa; padding: 30px; border-radius: 12px; margin-bottom: 30px;"><h2 style="color: #2d3748; margin-top: 0;">🤖 AI-Generated Clinical Report</h2><div style="background: white; padding: 25px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">{report_html}</div></div>' if report_html else ''} | |
| <!-- Important Disclaimers --> | |
| <div style="background: #fff5f5; border: 2px solid #feb2b2; padding: 25px; border-radius: 12px; margin: 30px 0;"> | |
| <h3 style="color: #c53030; margin-top: 0;">⚠️ Important Medical Disclaimers</h3> | |
| <ul style="color: #742a2a; line-height: 1.6;"> | |
| <li><strong>Not a Medical Diagnosis:</strong> This AI analysis is for informational purposes only and does not constitute medical advice, diagnosis, or treatment.</li> | |
| <li><strong>Professional Consultation Required:</strong> Always consult with qualified healthcare professionals for proper clinical assessment and treatment decisions.</li> | |
| <li><strong>Measurement Accuracy:</strong> All measurements are estimates based on computer vision algorithms and should be verified with clinical tools.</li> | |
| <li><strong>Risk Assessment Limitations:</strong> Risk factors are based on provided information and may not reflect the complete clinical picture.</li> | |
| </ul> | |
| </div> | |
| <!-- Footer --> | |
| <div style="text-align: center; padding: 30px 0; border-top: 2px solid #e2e8f0; margin-top: 30px;"> | |
| <p style="color: #6c757d; font-style: italic; font-size: 16px; margin: 0;"> | |
| 🏥 Analysis completed by <strong>SmartHeal AI</strong> - Advanced Wound Care Assistant<br> | |
| <small>Report generated on {datetime.now().strftime('%B %d, %Y at %I:%M %p')}</small> | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| return html_output | |
| except Exception as e: | |
| logging.error(f"Error formatting comprehensive results: {e}") | |
| return f"<div class='status-error'>❌ Error displaying results: {str(e)}</div>" | |
| def _generate_risk_assessment(self, questionnaire_data): | |
| """Generate risk assessment based on questionnaire data""" | |
| if not questionnaire_data: | |
| return {'risk_level': 'Unknown', 'risk_score': 0, 'risk_factors': []} | |
| risk_factors = [] | |
| risk_score = 0 | |
| try: | |
| # Age | |
| age = questionnaire_data.get('age', 0) | |
| if isinstance(age, str): | |
| try: | |
| age = int(age) | |
| except ValueError: | |
| age = 0 | |
| if age > 65: | |
| risk_factors.append("Advanced age (>65 years)") | |
| risk_score += 2 | |
| elif age > 50: | |
| risk_factors.append("Older adult (50-65 years)") | |
| risk_score += 1 | |
| # Diabetes | |
| diabetic_status = str(questionnaire_data.get('diabetic', '')).lower() | |
| if 'yes' in diabetic_status: | |
| risk_factors.append("Diabetes mellitus") | |
| risk_score += 3 | |
| # Infection | |
| infection = str(questionnaire_data.get('infection', '')).lower() | |
| if 'yes' in infection: | |
| risk_factors.append("Signs of infection present") | |
| risk_score += 3 | |
| # Pain | |
| pain_level = questionnaire_data.get('pain_level', 0) | |
| if isinstance(pain_level, str): | |
| try: | |
| pain_level = float(pain_level) | |
| except ValueError: | |
| pain_level = 0 | |
| if pain_level >= 7: | |
| risk_factors.append("High pain level (≥7/10)") | |
| risk_score += 2 | |
| elif pain_level >= 5: | |
| risk_factors.append("Moderate pain level (5-6/10)") | |
| risk_score += 1 | |
| # Duration | |
| duration = str(questionnaire_data.get('wound_duration', '')).lower() | |
| if any(term in duration for term in ['month', 'months', 'year', 'years']): | |
| risk_factors.append("Chronic wound (>4 weeks)") | |
| risk_score += 3 | |
| # Moisture | |
| moisture = str(questionnaire_data.get('moisture', '')).lower() | |
| if any(term in moisture for term in ['wet', 'saturated']): | |
| risk_factors.append("Excessive wound exudate") | |
| risk_score += 1 | |
| # Medical history | |
| medical_history = str(questionnaire_data.get('medical_history', '')).lower() | |
| if any(term in medical_history for term in ['vascular', 'circulation', 'heart']): | |
| risk_factors.append("Cardiovascular disease") | |
| risk_score += 2 | |
| if any(term in medical_history for term in ['immune', 'cancer', 'steroid']): | |
| risk_factors.append("Immune system compromise") | |
| risk_score += 2 | |
| if any(term in medical_history for term in ['smoking', 'tobacco']): | |
| risk_factors.append("Smoking history") | |
| risk_score += 2 | |
| # Risk level | |
| if risk_score >= 8: | |
| risk_level = "Very High" | |
| elif risk_score >= 6: | |
| risk_level = "High" | |
| elif risk_score >= 3: | |
| risk_level = "Moderate" | |
| else: | |
| risk_level = "Low" | |
| return { | |
| 'risk_score': risk_score, | |
| 'risk_level': risk_level, | |
| 'risk_factors': risk_factors | |
| } | |
| except Exception as e: | |
| logging.error(f"Risk assessment error: {e}") | |
| return { | |
| 'risk_score': 0, | |
| 'risk_level': 'Unknown', | |
| 'risk_factors': ['Unable to assess risk due to data processing error'] | |
| } | |