| import asyncio |
| import json |
| import tempfile |
| import os |
|
|
| import edge_tts |
| import gradio as gr |
| from src.inference import MediVisionPipeline |
| from src.model_loader import check_connection |
|
|
| |
| |
| |
| _pipeline: MediVisionPipeline | None = None |
|
|
|
|
| def get_pipeline() -> MediVisionPipeline: |
| global _pipeline |
| if _pipeline is None: |
| _pipeline = MediVisionPipeline() |
| return _pipeline |
|
|
|
|
| |
| |
| |
|
|
| _LANG_MAP = { |
| "English": "en", |
| "Tiếng Việt": "vn", |
| "中文": "zh", |
| "Español": "es", |
| "Français": "fr", |
| "日本語": "ja", |
| } |
|
|
| _LANG_CHOICES = list(_LANG_MAP.keys()) |
|
|
| _LANG_BCP47 = { |
| "en": "en-US", "vn": "vi-VN", "zh": "zh-CN", |
| "es": "es-ES", "fr": "fr-FR", "ja": "ja-JP", |
| } |
|
|
| |
| _TTS_VOICE = { |
| "en": "en-US-JennyNeural", |
| "vn": "vi-VN-HoaiMyNeural", |
| "zh": "zh-CN-XiaoxiaoNeural", |
| "es": "es-ES-ElviraNeural", |
| "fr": "fr-FR-DeniseNeural", |
| "ja": "ja-JP-NanamiNeural", |
| } |
|
|
|
|
| def _tts_generate(text: str, lang: str) -> str | None: |
| """Generate TTS audio with edge-tts; returns temp file path or None on error.""" |
| if not text or not text.strip(): |
| return None |
| voice = _TTS_VOICE.get(lang, _TTS_VOICE["en"]) |
| try: |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") |
| tmp.close() |
|
|
| async def _run(): |
| communicate = edge_tts.Communicate(text.strip(), voice) |
| await communicate.save(tmp.name) |
|
|
| asyncio.run(_run()) |
| return tmp.name |
| except Exception: |
| return None |
|
|
| _I18N = { |
| "en": { |
| "img_label": "Upload Medical Image", |
| "symptoms_label": "Symptoms Description", |
| "symptoms_placeholder": "Describe what you feel — e.g. itchy red patch for 3 days, slight burning sensation...", |
| "analyze_btn": "🔬 Analyze", |
| "output_label": "Analysis Result", |
| "empty_output": "Upload an image and/or describe symptoms, then click Analyze.", |
| "diag_label": "Diagnosis Suggestion", |
| "severity_label": "Severity", |
| "actions_label": "Assessment & Guidance", |
| "confidence_label": "Confidence Score", |
| "disclaimer": "This is an AI assistant, not a licensed physician. Always consult a healthcare professional for serious conditions.", |
| "placeholder": "Please upload an image or enter symptoms.", |
| "backend_live": "AMD Cloud · Live", |
| "backend_offline": "AMD Cloud · Offline", |
| "error_title": "Backend Unavailable", |
| "error_body": "AMD Cloud backend is unreachable. Please try again later.", |
| "examples_label": "Quick Examples", |
| "metrics_latency": "Latency", |
| "metrics_throughput": "Throughput", |
| "metrics_tokens": "tokens", |
| "region_label": "Affected Body Region", |
| "region_none": "Not specified", |
| "region_optional_label": "Affected Body Region (Optional)", |
| "input_hint": "Provide an image, describe symptoms, or both — at least one is required. Body region is optional.", |
| "map_label": "Anatomical Map", |
| "map_select": "click to select", |
| "map_selected": "{n} region(s) selected", |
| "img_mode_label": "Upload Mode", |
| "img_mode_standard": "Standard (1 image)", |
| "img_mode_compare": "Compare (2 images — before & after)", |
| "img_label_day1": "Medical Image (Day 1)", |
| "img_label_dayx": "Comparison Image (Day X)", |
| "tab_patient": "Patient View", |
| "tab_doctor": "Export for Doctor (SOAP)", |
| "critical_warning": "⚠️ CRITICAL: Severe symptoms detected. Please visit a medical facility within 24 hours.", |
| "conditions_label": "Possible Conditions", |
| "soap_copy_btn": "Copy SOAP Note", |
| "soap_empty": "Run an analysis to generate the SOAP note.", |
| "tts_btn": "🔊 Listen", |
| "chat_label": "Follow-up Questions", |
| "chat_placeholder": "Ask a follow-up question about your condition...", |
| "chat_send": "Send", |
| "red_flags_label": "Red Flags", |
| "watch_for_label": "Watch For", |
| "analyzing_label": "Analyzing...", |
| }, |
| "vn": { |
| "img_label": "Tải lên hình ảnh y tế", |
| "symptoms_label": "Mô tả triệu chứng", |
| "symptoms_placeholder": "Mô tả những gì bạn cảm thấy — ví dụ: vết đỏ ngứa 3 ngày, hơi rát...", |
| "analyze_btn": "🔬 Phân tích", |
| "output_label": "Kết quả phân tích", |
| "empty_output": "Tải ảnh và/hoặc mô tả triệu chứng, rồi nhấn Phân tích.", |
| "diag_label": "Gợi ý chẩn đoán", |
| "severity_label": "Mức độ nghiêm trọng", |
| "actions_label": "Đánh giá & Hướng dẫn", |
| "confidence_label": "Độ tin cậy", |
| "disclaimer": "Đây là trợ lý AI, không phải bác sĩ. Hãy tham khảo chuyên gia y tế cho các tình trạng nghiêm trọng.", |
| "placeholder": "Vui lòng tải lên hình ảnh hoặc nhập triệu chứng.", |
| "backend_live": "AMD Cloud · Trực tuyến", |
| "backend_offline": "AMD Cloud · Ngoại tuyến", |
| "error_title": "Hệ thống không khả dụng", |
| "error_body": "Không thể kết nối AMD Cloud. Vui lòng thử lại sau.", |
| "examples_label": "Ví dụ nhanh", |
| "metrics_latency": "Độ trễ", |
| "metrics_throughput": "Thông lượng", |
| "metrics_tokens": "token", |
| "region_label": "Vùng cơ thể bị ảnh hưởng", |
| "region_none": "Không xác định", |
| "region_optional_label": "Vùng cơ thể bị ảnh hưởng (Không bắt buộc)", |
| "input_hint": "Cung cấp ảnh, mô tả triệu chứng hoặc cả hai — cần ít nhất một trong hai. Vùng cơ thể là tùy chọn.", |
| "map_label": "Bản đồ giải phẫu", |
| "map_select": "nhấn để chọn", |
| "map_selected": "{n} vùng đã chọn", |
| "img_mode_label": "Chế độ tải ảnh", |
| "img_mode_standard": "Tiêu chuẩn (1 ảnh)", |
| "img_mode_compare": "So sánh (2 ảnh — trước & sau)", |
| "img_label_day1": "Ảnh y tế (Ngày 1)", |
| "img_label_dayx": "Ảnh so sánh (Ngày X)", |
| "tab_patient": "Dành cho bệnh nhân", |
| "tab_doctor": "Xuất cho bác sĩ (SOAP)", |
| "critical_warning": "⚠️ CẢNH BÁO: Triệu chứng nghiêm trọng được phát hiện. Vui lòng đến cơ sở y tế trong vòng 24 giờ.", |
| "conditions_label": "Tình trạng có thể", |
| "soap_copy_btn": "Sao chép SOAP", |
| "soap_empty": "Thực hiện phân tích để tạo ghi chú SOAP.", |
| "tts_btn": "🔊 Nghe", |
| "chat_label": "Câu hỏi tiếp theo", |
| "chat_placeholder": "Đặt câu hỏi tiếp theo về tình trạng của bạn...", |
| "chat_send": "Gửi", |
| "red_flags_label": "Cảnh báo", |
| "watch_for_label": "Theo dõi", |
| "analyzing_label": "Đang phân tích...", |
| }, |
| "zh": { |
| "img_label": "上传医学图像", |
| "symptoms_label": "症状描述", |
| "symptoms_placeholder": "描述您的感受 — 例如:前臂红色瘙痒皮疹持续3天,略有灼热感...", |
| "analyze_btn": "🔬 分析", |
| "output_label": "分析结果", |
| "empty_output": "请上传图片和/或描述症状,然后点击分析。", |
| "diag_label": "诊断建议", |
| "severity_label": "严重程度", |
| "actions_label": "评估与指导", |
| "confidence_label": "置信度", |
| "disclaimer": "本工具为AI助手,不能替代执业医师。如有严重病情,请务必咨询专业医疗人员。", |
| "placeholder": "请上传图片或输入症状描述。", |
| "backend_live": "AMD Cloud · 在线", |
| "backend_offline": "AMD Cloud · 离线", |
| "error_title": "后端不可用", |
| "error_body": "AMD Cloud 后端无法访问,请稍后重试。", |
| "examples_label": "快速示例", |
| "metrics_latency": "延迟", |
| "metrics_throughput": "吞吐量", |
| "metrics_tokens": "tokens", |
| "region_label": "受影响的身体部位", |
| "region_none": "未指定", |
| "region_optional_label": "受影响的身体部位(可选)", |
| "input_hint": "请上传图片或描述症状(至少提供其中一项)。身体部位为可选项。", |
| "map_label": "解剖图", |
| "map_select": "点击选择", |
| "map_selected": "已选 {n} 个部位", |
| "img_mode_label": "上传模式", |
| "img_mode_standard": "标准模式(1张图片)", |
| "img_mode_compare": "对比模式(2张图片 — 前后对比)", |
| "img_label_day1": "医疗图像(第1天)", |
| "img_label_dayx": "对比图像(第X天)", |
| "tab_patient": "患者视图", |
| "tab_doctor": "导出给医生(SOAP)", |
| "critical_warning": "⚠️ 严重警告:检测到严重症状。请在24小时内前往医疗机构就诊。", |
| "conditions_label": "可能的病症", |
| "soap_copy_btn": "复制SOAP记录", |
| "soap_empty": "运行分析以生成SOAP记录。", |
| "tts_btn": "🔊 朗读", |
| "chat_label": "后续问题", |
| "chat_placeholder": "就您的病情提出后续问题...", |
| "chat_send": "发送", |
| "red_flags_label": "红旗症状", |
| "watch_for_label": "需关注症状", |
| "analyzing_label": "分析中...", |
| }, |
| "es": { |
| "img_label": "Subir imagen médica", |
| "symptoms_label": "Descripción de síntomas", |
| "symptoms_placeholder": "Describa lo que siente — ej. erupción roja con picazón en el antebrazo desde hace 3 días...", |
| "analyze_btn": "🔬 Analizar", |
| "output_label": "Resultado del análisis", |
| "empty_output": "Suba una imagen y/o describa sus síntomas, luego haga clic en Analizar.", |
| "diag_label": "Sugerencia de diagnóstico", |
| "severity_label": "Severidad", |
| "actions_label": "Evaluación y orientación", |
| "confidence_label": "Puntuación de confianza", |
| "disclaimer": "Este es un asistente de IA, no un médico autorizado. Consulte siempre a un profesional de la salud para condiciones graves.", |
| "placeholder": "Por favor, suba una imagen o describa sus síntomas.", |
| "backend_live": "AMD Cloud · En línea", |
| "backend_offline": "AMD Cloud · Sin conexión", |
| "error_title": "Backend no disponible", |
| "error_body": "El backend de AMD Cloud no está disponible. Por favor, inténtelo más tarde.", |
| "examples_label": "Ejemplos rápidos", |
| "metrics_latency": "Latencia", |
| "metrics_throughput": "Rendimiento", |
| "metrics_tokens": "tokens", |
| "region_label": "Región corporal afectada", |
| "region_none": "No especificado", |
| "region_optional_label": "Región corporal afectada (Opcional)", |
| "input_hint": "Suba una imagen, describa síntomas, o ambos — se requiere al menos uno. La región corporal es opcional.", |
| "map_label": "Mapa anatómico", |
| "map_select": "haga clic para seleccionar", |
| "map_selected": "{n} región(es) seleccionada(s)", |
| "img_mode_label": "Modo de carga", |
| "img_mode_standard": "Estándar (1 imagen)", |
| "img_mode_compare": "Comparar (2 imágenes — antes y después)", |
| "img_label_day1": "Imagen médica (Día 1)", |
| "img_label_dayx": "Imagen de comparación (Día X)", |
| "tab_patient": "Vista del paciente", |
| "tab_doctor": "Exportar para médico (SOAP)", |
| "critical_warning": "⚠️ CRÍTICO: Síntomas graves detectados. Por favor, acuda a un centro médico en las próximas 24 horas.", |
| "conditions_label": "Posibles condiciones", |
| "soap_copy_btn": "Copiar nota SOAP", |
| "soap_empty": "Ejecute un análisis para generar la nota SOAP.", |
| "tts_btn": "🔊 Escuchar", |
| "chat_label": "Preguntas de seguimiento", |
| "chat_placeholder": "Haga una pregunta de seguimiento sobre su condición...", |
| "chat_send": "Enviar", |
| "red_flags_label": "Señales de alerta", |
| "watch_for_label": "Vigilar", |
| "analyzing_label": "Analizando...", |
| }, |
| "fr": { |
| "img_label": "Télécharger une image médicale", |
| "symptoms_label": "Description des symptômes", |
| "symptoms_placeholder": "Décrivez ce que vous ressentez — ex. éruption rouge et prurigineuse sur l'avant-bras depuis 3 jours...", |
| "analyze_btn": "🔬 Analyser", |
| "output_label": "Résultat de l'analyse", |
| "empty_output": "Téléchargez une image et/ou décrivez vos symptômes, puis cliquez sur Analyser.", |
| "diag_label": "Suggestion de diagnostic", |
| "severity_label": "Sévérité", |
| "actions_label": "Évaluation et conseils", |
| "confidence_label": "Score de confiance", |
| "disclaimer": "Ceci est un assistant IA, pas un médecin agréé. Consultez toujours un professionnel de santé pour les situations graves.", |
| "placeholder": "Veuillez télécharger une image ou décrire vos symptômes.", |
| "backend_live": "AMD Cloud · En ligne", |
| "backend_offline": "AMD Cloud · Hors ligne", |
| "error_title": "Backend indisponible", |
| "error_body": "Le backend AMD Cloud est inaccessible. Veuillez réessayer plus tard.", |
| "examples_label": "Exemples rapides", |
| "metrics_latency": "Latence", |
| "metrics_throughput": "Débit", |
| "metrics_tokens": "tokens", |
| "region_label": "Région corporelle affectée", |
| "region_none": "Non spécifié", |
| "region_optional_label": "Région corporelle affectée (Facultatif)", |
| "input_hint": "Fournissez une image, décrivez vos symptômes, ou les deux — au moins un est requis. La région corporelle est facultative.", |
| "map_label": "Carte anatomique", |
| "map_select": "cliquer pour sélectionner", |
| "map_selected": "{n} région(s) sélectionnée(s)", |
| "img_mode_label": "Mode de téléchargement", |
| "img_mode_standard": "Standard (1 image)", |
| "img_mode_compare": "Comparer (2 images — avant et après)", |
| "img_label_day1": "Image médicale (Jour 1)", |
| "img_label_dayx": "Image de comparaison (Jour X)", |
| "tab_patient": "Vue patient", |
| "tab_doctor": "Exporter pour le médecin (SOAP)", |
| "critical_warning": "⚠️ CRITIQUE : Symptômes graves détectés. Veuillez vous rendre dans un établissement médical dans les 24 heures.", |
| "conditions_label": "Conditions possibles", |
| "soap_copy_btn": "Copier la note SOAP", |
| "soap_empty": "Lancez une analyse pour générer la note SOAP.", |
| "tts_btn": "🔊 Écouter", |
| "chat_label": "Questions de suivi", |
| "chat_placeholder": "Posez une question de suivi sur votre état...", |
| "chat_send": "Envoyer", |
| "red_flags_label": "Signaux d'alarme", |
| "watch_for_label": "À surveiller", |
| "analyzing_label": "Analyse en cours...", |
| }, |
| "ja": { |
| "img_label": "医療画像をアップロード", |
| "symptoms_label": "症状の説明", |
| "symptoms_placeholder": "感じていることを説明してください — 例:3日前から前腕に赤くかゆい発疹、少し灼熱感...", |
| "analyze_btn": "🔬 分析する", |
| "output_label": "分析結果", |
| "empty_output": "画像をアップロードし、症状を説明してから分析ボタンをクリックしてください。", |
| "diag_label": "診断提案", |
| "severity_label": "重症度", |
| "actions_label": "評価とガイダンス", |
| "confidence_label": "信頼スコア", |
| "disclaimer": "これはAIアシスタントであり、有資格の医師ではありません。深刻な症状については必ず医療専門家に相談してください。", |
| "placeholder": "画像をアップロードするか、症状を入力してください。", |
| "backend_live": "AMD Cloud · オンライン", |
| "backend_offline": "AMD Cloud · オフライン", |
| "error_title": "バックエンド利用不可", |
| "error_body": "AMD Cloudバックエンドに接続できません。後でもう一度お試しください。", |
| "examples_label": "クイック例", |
| "metrics_latency": "レイテンシ", |
| "metrics_throughput": "スループット", |
| "metrics_tokens": "トークン", |
| "region_label": "患部の体の部位", |
| "region_none": "指定なし", |
| "region_optional_label": "患部の体の部位(任意)", |
| "input_hint": "画像または症状(あるいは両方)を入力してください — どちらか一方が必要です。体の部位は任意です。", |
| "map_label": "解剖マップ", |
| "map_select": "クリックして選択", |
| "map_selected": "{n} 部位選択中", |
| "img_mode_label": "アップロードモード", |
| "img_mode_standard": "標準(画像1枚)", |
| "img_mode_compare": "比較(画像2枚 — 経過観察)", |
| "img_label_day1": "医療画像(第1日)", |
| "img_label_dayx": "比較画像(第X日)", |
| "tab_patient": "患者向け", |
| "tab_doctor": "医師向けエクスポート(SOAP)", |
| "critical_warning": "⚠️ 重大:重篤な症状が検出されました。24時間以内に医療機関を受診してください。", |
| "conditions_label": "考えられる疾患", |
| "soap_copy_btn": "SOAPノートをコピー", |
| "soap_empty": "分析を実行してSOAPノートを生成します。", |
| "tts_btn": "🔊 読み上げ", |
| "chat_label": "フォローアップの質問", |
| "chat_placeholder": "症状についてフォローアップの質問をしてください...", |
| "chat_send": "送信", |
| "red_flags_label": "赤信号", |
| "watch_for_label": "注意症状", |
| "analyzing_label": "分析中...", |
| }, |
| } |
|
|
|
|
| def _detect_lang_from_header(accept_language: str) -> str: |
| """Map Accept-Language header to a display name from _LANG_CHOICES.""" |
| if not accept_language: |
| return "English" |
| first = accept_language.split(",")[0].split(";")[0].strip().lower() |
| code = first.split("-")[0] |
| mapping = {"vi": "Tiếng Việt", "zh": "中文", "es": "Español", "fr": "Français", "ja": "日本語"} |
| return mapping.get(code, "English") |
|
|
|
|
| |
| |
| |
|
|
| def get_backend_status_html(lang: str = "en") -> str: |
| t = _I18N.get(lang, _I18N["en"]) |
| connected, _ = check_connection() |
| if connected: |
| dot, label, color = "#22c55e", t["backend_live"], "#86efac" |
| else: |
| dot, label, color = "#ef4444", t["backend_offline"], "#fca5a5" |
| return ( |
| f"<div style='font-size:0.78rem; color:{color}; font-family:monospace; " |
| f"white-space:nowrap; padding:6px 4px; font-weight:600;' " |
| f"role='status' aria-live='polite'>" |
| f"<span style='color:{dot};' aria-hidden='true'>●</span> {label}" |
| f"</div>" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| _SEVERITY_COLOR = { |
| "Low": ("#22c55e", "#dcfce7"), |
| "Medium": ("#eab308", "#fef9c3"), |
| "High": ("#f97316", "#ffedd5"), |
| "Urgent": ("#ef4444", "#fee2e2"), |
| } |
|
|
| |
| _SEVERITY_TRANSLATE = { |
| "en": {"Low": "Low", "Medium": "Medium", "High": "High", "Urgent": "Urgent"}, |
| "vn": {"Low": "Thấp", "Medium": "Trung bình", "High": "Cao", "Urgent": "Khẩn cấp"}, |
| "zh": {"Low": "低", "Medium": "中", "High": "高", "Urgent": "紧急"}, |
| "es": {"Low": "Leve", "Medium": "Moderada", "High": "Alta", "Urgent": "Urgente"}, |
| "fr": {"Low": "Faible", "Medium": "Modérée", "High": "Élevée", "Urgent": "Urgente"}, |
| "ja": {"Low": "軽度", "Medium": "中等度", "High": "重度", "Urgent": "緊急"}, |
| } |
|
|
|
|
| |
| |
| |
|
|
| |
| _BODY_REGIONS = [ |
| "Head / Face", "Neck", "Chest", "Abdomen", |
| "Upper Back", "Lower Back", "Left Arm", "Right Arm", |
| "Left Hand", "Right Hand", "Left Leg", "Right Leg", |
| "Left Foot", "Right Foot", "Groin / Genital", "Buttocks", |
| ] |
|
|
| |
| _REGION_TRANSLATIONS = { |
| "en": ["Head / Face", "Neck", "Chest", "Abdomen", |
| "Upper Back", "Lower Back", "Left Arm", "Right Arm", |
| "Left Hand", "Right Hand", "Left Leg", "Right Leg", |
| "Left Foot", "Right Foot", "Groin / Genital", "Buttocks"], |
| "vn": ["Đầu / Mặt", "Cổ", "Ngực", "Bụng", |
| "Lưng trên", "Lưng dưới", "Tay trái", "Tay phải", |
| "Bàn tay trái", "Bàn tay phải", "Chân trái", "Chân phải", |
| "Bàn chân trái", "Bàn chân phải", "Bẹn / Sinh dục", "Mông"], |
| "zh": ["头部 / 面部", "颈部", "胸部", "腹部", |
| "上背部", "下背部", "左臂", "右臂", |
| "左手", "右手", "左腿", "右腿", |
| "左脚", "右脚", "腹股沟 / 生殖器", "臀部"], |
| "es": ["Cabeza / Cara", "Cuello", "Pecho", "Abdomen", |
| "Espalda alta", "Espalda baja", "Brazo izquierdo", "Brazo derecho", |
| "Mano izquierda", "Mano derecha", "Pierna izquierda", "Pierna derecha", |
| "Pie izquierdo", "Pie derecho", "Ingle / Genitales", "Nalgas"], |
| "fr": ["Tête / Visage", "Cou", "Poitrine", "Abdomen", |
| "Haut du dos", "Bas du dos", "Bras gauche", "Bras droit", |
| "Main gauche", "Main droite", "Jambe gauche", "Jambe droite", |
| "Pied gauche", "Pied droit", "Aine / Organes génitaux", "Fesses"], |
| "ja": ["頭部 / 顔", "首", "胸部", "腹部", |
| "上背部", "下背部", "左腕", "右腕", |
| "左手", "右手", "左脚", "右脚", |
| "左足", "右足", "鼠径部 / 性器", "臀部"], |
| } |
|
|
|
|
| def _localized_regions(lang: str) -> list: |
| return _REGION_TRANSLATIONS.get(lang, _REGION_TRANSLATIONS["en"]) |
|
|
|
|
| def _display_to_en(display: str) -> str: |
| """Map any localized display name (any lang) back to the English region key.""" |
| if display in _BODY_REGIONS: |
| return display |
| for translations in _REGION_TRANSLATIONS.values(): |
| if display in translations: |
| return _BODY_REGIONS[translations.index(display)] |
| return "" |
|
|
|
|
| |
| _REGION_SHAPE_MAP = { |
| "Head / Face": ["svg-head"], |
| "Neck": ["svg-neck"], |
| "Chest": ["svg-chest"], |
| "Abdomen": ["svg-abdomen"], |
| "Upper Back": ["svg-upper-back"], |
| "Lower Back": ["svg-lower-back"], |
| "Left Arm": ["svg-left-arm"], |
| "Right Arm": ["svg-right-arm"], |
| "Left Hand": ["svg-left-hand"], |
| "Right Hand": ["svg-right-hand"], |
| "Left Leg": ["svg-left-leg"], |
| "Right Leg": ["svg-right-leg"], |
| "Left Foot": ["svg-left-foot"], |
| "Right Foot": ["svg-right-foot"], |
| "Groin / Genital": ["svg-groin"], |
| "Buttocks": ["svg-buttocks"], |
| } |
|
|
| _DIM = "#374151" |
| _HI = "#ED1C24" |
|
|
|
|
| def _body_map_svg(selected: list, lang: str = "en") -> str: |
| t = _I18N.get(lang, _I18N["en"]) |
| active = set() |
| for r in (selected or []): |
| for sid in _REGION_SHAPE_MAP.get(r, []): |
| active.add(sid) |
|
|
| def f(sid): |
| return _HI if sid in active else _DIM |
|
|
| def stroke(sid): |
| return "#ED1C24" if sid in active else "#4b5563" |
|
|
| def glow(sid): |
| return "drop-shadow(0 0 3px #ED1C24)" if sid in active else "none" |
|
|
| |
| locs = _REGION_TRANSLATIONS.get(lang, _REGION_TRANSLATIONS["en"]) |
| _svg_title: dict[str, str] = {} |
| for en_name, svg_ids in _REGION_SHAPE_MAP.items(): |
| loc_name = locs[_BODY_REGIONS.index(en_name)] if en_name in _BODY_REGIONS else en_name |
| for sid in svg_ids: |
| _svg_title[sid] = loc_name |
|
|
| def title(sid): |
| return f"<title>{_svg_title.get(sid, sid)}</title>" |
|
|
| counter_html = ( |
| f'<div style="font-size:0.65rem; color:#ED1C24; font-family:monospace; font-weight:600; text-align:center;">' |
| f'{t["map_selected"].format(n=len(active))}</div>' |
| if active else |
| f'<div style="font-size:0.65rem; color:#4b5563; font-family:monospace; text-align:center;">{t["map_select"]}</div>' |
| ) |
|
|
| return f""" |
| <div style='display:flex; flex-direction:column; align-items:center; gap:6px; |
| padding:8px 4px; user-select:none;'> |
| <div style='font-size:0.6rem; color:#64748b; font-family:monospace; |
| letter-spacing:0.06em; text-transform:uppercase;'>{t["map_label"]}</div> |
| <svg viewBox="0 0 80 180" width="84" height="188" |
| xmlns="http://www.w3.org/2000/svg" role="img" |
| aria-label="{t['map_label']}" style='overflow:visible; touch-action:manipulation;'> |
| <style> |
| .bpart {{ |
| transition: fill 0.2s ease, filter 0.2s ease; |
| cursor: pointer; |
| touch-action: manipulation; |
| }} |
| @media (hover: hover) {{ |
| .bpart:hover {{ fill: #f97316 !important; filter: drop-shadow(0 0 4px #f97316); }} |
| }} |
| </style> |
| <!-- Head --> |
| <ellipse class="bpart" id="svg-head" cx="40" cy="13" rx="11" ry="12" |
| fill="{f('svg-head')}" stroke="{stroke('svg-head')}" stroke-width="0.8" |
| style="filter:{glow('svg-head')}">{title('svg-head')}</ellipse> |
| <!-- Neck --> |
| <rect class="bpart" id="svg-neck" x="35" y="24" width="10" height="8" rx="2" |
| fill="{f('svg-neck')}" stroke="{stroke('svg-neck')}" stroke-width="0.8" |
| style="filter:{glow('svg-neck')}">{title('svg-neck')}</rect> |
| <!-- Chest --> |
| <rect class="bpart" id="svg-chest" x="22" y="32" width="36" height="22" rx="4" |
| fill="{f('svg-chest')}" stroke="{stroke('svg-chest')}" stroke-width="0.8" |
| style="filter:{glow('svg-chest')}">{title('svg-chest')}</rect> |
| <!-- Abdomen --> |
| <rect class="bpart" id="svg-abdomen" x="22" y="55" width="36" height="20" rx="4" |
| fill="{f('svg-abdomen')}" stroke="{stroke('svg-abdomen')}" stroke-width="0.8" |
| style="filter:{glow('svg-abdomen')}">{title('svg-abdomen')}</rect> |
| <!-- Upper Back (overlay stripe) --> |
| <rect class="bpart" id="svg-upper-back" x="22" y="32" width="36" height="11" rx="4" |
| fill="{'#ED1C24' if 'svg-upper-back' in active else 'none'}" opacity="0.45" |
| stroke="{'#ED1C24' if 'svg-upper-back' in active else 'none'}" stroke-width="0.6">{title('svg-upper-back')}</rect> |
| <!-- Lower Back (overlay stripe) --> |
| <rect class="bpart" id="svg-lower-back" x="22" y="55" width="36" height="10" rx="4" |
| fill="{'#ED1C24' if 'svg-lower-back' in active else 'none'}" opacity="0.45" |
| stroke="{'#ED1C24' if 'svg-lower-back' in active else 'none'}" stroke-width="0.6">{title('svg-lower-back')}</rect> |
| <!-- Arms --> |
| <rect class="bpart" id="svg-left-arm" x="7" y="32" width="13" height="38" rx="5" |
| fill="{f('svg-left-arm')}" stroke="{stroke('svg-left-arm')}" stroke-width="0.8" |
| style="filter:{glow('svg-left-arm')}">{title('svg-left-arm')}</rect> |
| <rect class="bpart" id="svg-right-arm" x="60" y="32" width="13" height="38" rx="5" |
| fill="{f('svg-right-arm')}" stroke="{stroke('svg-right-arm')}" stroke-width="0.8" |
| style="filter:{glow('svg-right-arm')}">{title('svg-right-arm')}</rect> |
| <!-- Hands --> |
| <ellipse class="bpart" id="svg-left-hand" cx="13" cy="76" rx="7" ry="5" |
| fill="{f('svg-left-hand')}" stroke="{stroke('svg-left-hand')}" stroke-width="0.8" |
| style="filter:{glow('svg-left-hand')}">{title('svg-left-hand')}</ellipse> |
| <ellipse class="bpart" id="svg-right-hand" cx="67" cy="76" rx="7" ry="5" |
| fill="{f('svg-right-hand')}" stroke="{stroke('svg-right-hand')}" stroke-width="0.8" |
| style="filter:{glow('svg-right-hand')}">{title('svg-right-hand')}</ellipse> |
| <!-- Groin --> |
| <rect class="bpart" id="svg-groin" x="27" y="76" width="26" height="8" rx="3" |
| fill="{f('svg-groin')}" stroke="{stroke('svg-groin')}" stroke-width="0.8" |
| style="filter:{glow('svg-groin')}">{title('svg-groin')}</rect> |
| <!-- Buttocks overlay --> |
| <rect class="bpart" id="svg-buttocks" x="27" y="76" width="26" height="8" rx="3" |
| fill="{'#ED1C24' if 'svg-buttocks' in active else 'none'}" opacity="0.55" |
| stroke="{'#ED1C24' if 'svg-buttocks' in active else 'none'}" stroke-width="0.6">{title('svg-buttocks')}</rect> |
| <!-- Legs --> |
| <rect class="bpart" id="svg-left-leg" x="22" y="85" width="15" height="52" rx="5" |
| fill="{f('svg-left-leg')}" stroke="{stroke('svg-left-leg')}" stroke-width="0.8" |
| style="filter:{glow('svg-left-leg')}">{title('svg-left-leg')}</rect> |
| <rect class="bpart" id="svg-right-leg" x="43" y="85" width="15" height="52" rx="5" |
| fill="{f('svg-right-leg')}" stroke="{stroke('svg-right-leg')}" stroke-width="0.8" |
| style="filter:{glow('svg-right-leg')}">{title('svg-right-leg')}</rect> |
| <!-- Feet --> |
| <ellipse class="bpart" id="svg-left-foot" cx="29" cy="142" rx="10" ry="5" |
| fill="{f('svg-left-foot')}" stroke="{stroke('svg-left-foot')}" stroke-width="0.8" |
| style="filter:{glow('svg-left-foot')}">{title('svg-left-foot')}</ellipse> |
| <ellipse class="bpart" id="svg-right-foot" cx="51" cy="142" rx="10" ry="5" |
| fill="{f('svg-right-foot')}" stroke="{stroke('svg-right-foot')}" stroke-width="0.8" |
| style="filter:{glow('svg-right-foot')}">{title('svg-right-foot')}</ellipse> |
| </svg> |
| {counter_html} |
| </div> |
| """ |
|
|
|
|
| def _severity_badge(severity: str, lang: str = "en") -> str: |
| color, bg = _SEVERITY_COLOR.get(severity, ("#6b7280", "#f3f4f6")) |
| display = _SEVERITY_TRANSLATE.get(lang, _SEVERITY_TRANSLATE["en"]).get(severity, severity) |
| return ( |
| f"<span style='background:{bg}; color:{color}; font-weight:700; " |
| f"padding:4px 14px; border-radius:999px; font-size:0.9rem; " |
| f"border:2px solid {color};'>{display}</span>" |
| ) |
|
|
|
|
| def _metrics_bar(metrics: dict, t: dict) -> str: |
| latency_ms = metrics.get("latency_ms", 0) |
| tok_per_sec = metrics.get("tokens_per_sec", 0) |
| total_tokens = metrics.get("total_tokens", 0) |
|
|
| latency_val = f"{latency_ms:,} ms" if latency_ms else "—" |
| throughput_val = f"{tok_per_sec} {t['metrics_tokens']}/s" if tok_per_sec else "—" |
| tokens_val = f"{total_tokens:,} {t['metrics_tokens']}" if total_tokens else "—" |
|
|
| def chip(icon, label, val): |
| return ( |
| f"<div style='display:flex; align-items:center; gap:5px; " |
| f"background:#0f172a; border:1px solid #1e3a5f; border-radius:6px; " |
| f"padding:4px 10px;'>" |
| f"<span style='color:#ED1C24; font-size:0.8rem;'>{icon}</span>" |
| f"<span style='color:#94a3b8; font-size:0.68rem;'>{label}</span>" |
| f"<span style='color:#e2e8f0; font-size:0.72rem; font-weight:700;'>{val}</span>" |
| f"</div>" |
| ) |
|
|
| return ( |
| f"<div style='display:flex; flex-wrap:wrap; column-gap:6px; row-gap:6px; " |
| f"margin-bottom:12px; align-items:center;'>" |
| f"<span style='font-size:0.68rem; color:#ED1C24; font-family:monospace; " |
| f"font-weight:700; letter-spacing:0.05em; white-space:nowrap;'>⚡ AMD MI300X</span>" |
| f"{chip('⏱', t['metrics_latency'], latency_val)}" |
| f"{chip('🚀', t['metrics_throughput'], throughput_val)}" |
| f"{chip('◊', t['metrics_tokens'], tokens_val)}" |
| f"</div>" |
| ) |
|
|
|
|
| def _confidence_bar(score: int, label: str) -> str: |
| if score == 0: |
| return "" |
| fill = "#ED1C24" if score >= 85 else "#f97316" if score >= 70 else "#eab308" |
| return ( |
| f"<div style='margin:6px 0 2px;'>" |
| f" <div style='font-size:0.8rem; color:#9ca3af; margin-bottom:4px;'>" |
| f" {label}: <b style='color:#fff;'>{score}%</b></div>" |
| f" <div style='background:#374151; border-radius:9999px; height:10px; overflow:hidden;'>" |
| f" <div style='background:{fill}; width:{score}%; height:100%; " |
| f"border-radius:9999px; transition:width 0.6s ease;'></div>" |
| f" </div>" |
| f"</div>" |
| ) |
|
|
|
|
| def _empty_output_html(lang: str) -> str: |
| t = _I18N.get(lang, _I18N["en"]) |
| return ( |
| f"<div style='color:#4b5563; text-align:center; padding:clamp(32px,8vw,60px) 16px; " |
| f"font-size:0.9rem; line-height:1.5;'>" |
| f"{t['empty_output']}" |
| f"</div>" |
| ) |
|
|
|
|
| def _empty_soap_html(lang: str) -> str: |
| return _build_soap_html("", lang) |
|
|
|
|
| def _loading_output_html(lang: str) -> str: |
| t = _I18N.get(lang, _I18N["en"]) |
| dots = "".join( |
| f"<div style='width:8px; height:8px; background:#ED1C24; border-radius:50%; " |
| f"animation:medipulse 1.4s ease-in-out infinite; animation-delay:{i * 0.2}s;'></div>" |
| for i in range(3) |
| ) |
| return ( |
| f"<div style='background:#111827; border:1px dashed #374151; border-radius:12px; " |
| f"padding:clamp(40px,8vw,70px) 16px; text-align:center; animation:fadeSlideIn 0.3s ease;'>" |
| f"<div style='font-size:2.5rem; animation:medispin 1.2s linear infinite; " |
| f"display:inline-block;'>⚕️</div>" |
| f"<div style='margin-top:16px; font-size:0.95rem; font-weight:600; color:#e2e8f0;'>" |
| f"{t.get('analyzing_label', 'Analyzing...')}</div>" |
| f"<div style='margin-top:6px; font-size:0.72rem; color:#4b5563; font-family:monospace;'>" |
| f"AMD MI300X · ROCm · 3-Step Pipeline</div>" |
| f"<div style='margin-top:20px; display:flex; justify-content:center; gap:8px;'>{dots}</div>" |
| f"</div>" |
| ) |
|
|
|
|
| def _condition_probability_bars(conditions: list, t: dict) -> str: |
| """Render probability bars for each possible condition.""" |
| if not conditions: |
| return "<span style='color:#6b7280;'>—</span>" |
|
|
| bars = [] |
| for c in conditions: |
| if isinstance(c, dict): |
| name = c.get("name", "Unknown") |
| prob = int(c.get("probability", 50)) |
| icd10 = c.get("icd10", "") |
| else: |
| name, prob, icd10 = str(c), 50, "" |
|
|
| fill = "#ef4444" if prob >= 70 else "#f97316" if prob >= 45 else "#eab308" |
| icd_badge = ( |
| f"<span style='font-size:0.6rem; color:#6b7280; background:#0f172a; " |
| f"padding:1px 5px; border-radius:3px; margin-left:4px; font-family:monospace;'>" |
| f"{icd10}</span>" |
| ) if icd10 else "" |
|
|
| bars.append( |
| f"<div style='margin-bottom:10px;'>" |
| f" <div style='display:flex; align-items:center; justify-content:space-between; margin-bottom:3px;'>" |
| f" <span style='font-size:0.8rem; color:#e2e8f0; font-weight:600;'>{name}{icd_badge}</span>" |
| f" <span style='font-size:0.75rem; color:{fill}; font-weight:700;'>{prob}%</span>" |
| f" </div>" |
| f" <div style='background:#374151; border-radius:9999px; height:7px; overflow:hidden;'>" |
| f" <div style='background:{fill}; width:{prob}%; height:100%; border-radius:9999px; " |
| f" transition:width 0.7s ease;'></div>" |
| f" </div>" |
| f"</div>" |
| ) |
| return "".join(bars) |
|
|
|
|
| def _red_flags_panel(red_flags: list, watch_symptoms: list, urgency_reason: str, t: dict = None) -> str: |
| """Render red flags and watch symptoms warning panel. Returns empty string if nothing to show.""" |
| if t is None: |
| t = _I18N["en"] |
| has_flags = bool(red_flags) |
| has_watch = bool(watch_symptoms) |
| has_urgency = bool(urgency_reason) |
| if not has_flags and not has_watch and not has_urgency: |
| return "" |
|
|
| flags_html = "" |
| if has_flags: |
| items = "".join( |
| f"<li style='margin:3px 0; color:#fca5a5;'>⚠ {f}</li>" |
| for f in red_flags |
| ) |
| flags_html = ( |
| f"<div style='font-size:0.72rem; color:#ef4444; font-weight:700; " |
| f"text-transform:uppercase; letter-spacing:.04em; margin-bottom:6px;'>{t.get('red_flags_label', 'Red Flags')}</div>" |
| f"<ul style='margin:0 0 10px; padding-left:18px; list-style:none;'>{items}</ul>" |
| ) |
|
|
| watch_html = "" |
| if has_watch: |
| items = "".join( |
| f"<li style='margin:3px 0; color:#fde68a;'>👁 {w}</li>" |
| for w in watch_symptoms |
| ) |
| watch_html = ( |
| f"<div style='font-size:0.72rem; color:#f59e0b; font-weight:700; " |
| f"text-transform:uppercase; letter-spacing:.04em; margin-bottom:6px;'>{t.get('watch_for_label', 'Watch For')}</div>" |
| f"<ul style='margin:0; padding-left:18px; list-style:none;'>{items}</ul>" |
| ) |
|
|
| urgency_html = "" |
| if has_urgency: |
| urgency_html = ( |
| f"<div style='font-size:0.75rem; color:#9ca3af; font-style:italic; " |
| f"border-top:1px solid #374151; padding-top:8px; margin-top:8px;'>" |
| f"ⓘ {urgency_reason}</div>" |
| ) |
|
|
| border_color = "#ef4444" if has_flags else "#f59e0b" |
| bg_color = "#1c0a0a" if has_flags else "#1c1000" |
|
|
| return ( |
| f"<div style='background:{bg_color}; border:1px solid {border_color}; " |
| f"border-left:4px solid {border_color}; border-radius:8px; " |
| f"padding:12px 14px; margin-bottom:12px;'>" |
| f"{flags_html}{watch_html}{urgency_html}" |
| f"</div>" |
| ) |
|
|
|
|
| def _build_result_html(result: dict, lang: str) -> str: |
| t = _I18N.get(lang, _I18N["en"]) |
| triage = result.get("triage_level", "Low") |
| patient_msg = result.get("patient_message", "") |
| conditions = result.get("possible_conditions", []) |
| red_flags = result.get("red_flags", []) |
| watch_symptoms = result.get("watch_symptoms", []) |
| urgency_reason = result.get("urgency_reason", "") |
| metrics = result.get("_metrics", {}) |
|
|
| backend_tag = ( |
| "<span style='font-size:0.7rem; background:#052e16; color:#86efac; " |
| "padding:2px 8px; border-radius:4px; margin-left:8px; " |
| "border:1px solid #16a34a;'>AMD Cloud</span>" |
| ) |
|
|
| triage_colors = { |
| "High": ("#ef4444", "#7f1d1d"), |
| "Medium": ("#f97316", "#431407"), |
| "Low": ("#22c55e", "#052e16"), |
| } |
| t_color, t_bg = triage_colors.get(triage, ("#22c55e", "#052e16")) |
|
|
| critical_banner = "" |
| if triage == "High": |
| critical_banner = f""" |
| <div style='animation:redflash 1s ease-in-out infinite; |
| background:#7f1d1d; border:2px solid #ef4444; border-radius:8px; |
| padding:14px 18px; margin-bottom:16px; text-align:center;'> |
| <span style='color:#fca5a5; font-weight:900; font-size:0.95rem; line-height:1.5;'> |
| {t['critical_warning']} |
| </span> |
| </div>""" |
|
|
| msg_html = "".join( |
| f"<p style='margin:0 0 8px; color:#d1d5db; line-height:1.6;'>{line}</p>" |
| for line in patient_msg.split("\n") if line.strip() |
| ) if patient_msg else "<p style='color:#6b7280;'>—</p>" |
|
|
| cond_bars = _condition_probability_bars(conditions, t) |
| alert_panel = _red_flags_panel(red_flags, watch_symptoms, urgency_reason, t) |
|
|
| return f""" |
| <div style='background:#111827; border:1px solid #ED1C24; border-radius:12px; |
| padding:clamp(14px,4vw,20px); font-family:Arial,sans-serif; color:#f9fafb; |
| animation:fadeSlideIn 0.3s ease;'> |
| |
| <div style='display:flex; align-items:center; gap:10px; margin-bottom:12px;'> |
| <div style='background:#ED1C24; width:4px; border-radius:2px; height:36px; flex-shrink:0;'></div> |
| <div style='min-width:0;'> |
| <div style='font-size:clamp(1rem,3vw,1.1rem); font-weight:700; color:#ED1C24; white-space:nowrap;'> |
| MediVision {backend_tag} |
| </div> |
| <div style='font-size:0.72rem; color:#6b7280; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;'> |
| AMD MI300X · ROCm · Qwen2.5-VL-7B · 3-Step Pipeline |
| </div> |
| </div> |
| </div> |
| |
| {_metrics_bar(metrics, t)} |
| {critical_banner} |
| |
| <div style='background:#1f2937; border-radius:8px; padding:14px; margin-bottom:12px;'> |
| <div style='font-size:0.72rem; text-transform:uppercase; letter-spacing:.05em; |
| color:#9ca3af; margin-bottom:6px;'>{t['severity_label']}</div> |
| <span style='background:{t_bg}; color:{t_color}; font-weight:700; |
| padding:6px 18px; border-radius:999px; font-size:0.9rem; |
| border:2px solid {t_color}; display:inline-block;'> |
| {_SEVERITY_TRANSLATE.get(lang, _SEVERITY_TRANSLATE["en"]).get(triage, triage)} |
| </span> |
| </div> |
| |
| <div style='background:#1f2937; border-radius:8px; padding:14px; margin-bottom:12px;'> |
| <div style='font-size:0.72rem; text-transform:uppercase; letter-spacing:.05em; |
| color:#9ca3af; margin-bottom:10px;'>{t['conditions_label']}</div> |
| {cond_bars} |
| </div> |
| |
| {alert_panel} |
| |
| <div style='background:#1f2937; border-radius:8px; padding:14px; margin-bottom:12px;'> |
| <div style='font-size:0.72rem; text-transform:uppercase; letter-spacing:.05em; |
| color:#9ca3af; margin-bottom:8px;'> |
| {t['actions_label']} |
| </div> |
| {msg_html} |
| </div> |
| |
| <div style='background:#1a1a2e; border-left:4px solid #ED1C24; border-radius:4px; |
| padding:10px 14px; font-size:0.78rem; color:#9ca3af; line-height:1.5;'> |
| ⚠️ {t['disclaimer']} |
| </div> |
| |
| </div> |
| """ |
|
|
|
|
| def _build_soap_html(soap_text: str, lang: str = "en") -> str: |
| t = _I18N.get(lang, _I18N["en"]) |
| if not soap_text: |
| return ( |
| f"<div style='color:#4b5563; text-align:center; padding:40px 0; font-size:0.9rem;'>" |
| f"{t['soap_empty']}</div>" |
| ) |
| lines_html = "".join( |
| f"<div style='padding:3px 0; color:{'#ED1C24' if line.startswith('S ') or line.startswith('O ') or line.startswith('A ') or line.startswith('P ') else '#d1d5db'}; " |
| f"font-weight:{'700' if line[:2] in ('S ', 'O ', 'A ', 'P ') else '400'};'>{line}</div>" |
| for line in soap_text.split("\n") if line.strip() |
| ) |
| return f""" |
| <div style='background:#0f172a; border:1px solid #1e3a5f; border-radius:12px; |
| padding:clamp(14px,4vw,20px); font-family:monospace; font-size:0.82rem; |
| line-height:1.7; word-break:break-word; overflow-wrap:break-word;'> |
| <div style='display:flex; justify-content:space-between; align-items:center; |
| margin-bottom:14px; flex-wrap:wrap; gap:8px;'> |
| <span style='color:#ED1C24; font-weight:700; font-size:0.9rem; font-family:sans-serif;'> |
| SOAP Clinical Note |
| </span> |
| <button onclick="navigator.clipboard.writeText(this.dataset.text).then(()=>this.textContent='{t['soap_copy_btn']} ✓').catch(()=>null)" |
| data-text="{soap_text.replace(chr(34), '"')}" |
| style='background:#1e3a5f; color:#93c5fd; border:1px solid #2563eb; border-radius:6px; |
| padding:6px 14px; cursor:pointer; font-size:0.78rem; min-height:36px; |
| touch-action:manipulation; white-space:nowrap;' |
| aria-label="{t['soap_copy_btn']}"> |
| {t['soap_copy_btn']} |
| </button> |
| </div> |
| {lines_html} |
| </div> |
| """ |
|
|
|
|
| |
| |
| |
|
|
| def _error_html(t: dict, exc: Exception) -> str: |
| return ( |
| "<div style='background:#111827; border:1px solid #ef4444; border-radius:12px; " |
| "padding:clamp(16px,4vw,24px); font-family:Arial,sans-serif; text-align:center;'>" |
| "<div style='font-size:1.4rem; margin-bottom:12px;' role='img' aria-label='Warning'>⚠️</div>" |
| f"<div style='font-size:1rem; font-weight:700; color:#ef4444; margin-bottom:8px;'>{t['error_title']}</div>" |
| f"<div style='font-size:0.875rem; color:#9ca3af; margin-bottom:16px; line-height:1.5;'>{t['error_body']}</div>" |
| f"<div style='font-size:0.75rem; color:#6b7280; font-family:monospace; " |
| f"background:#1f2937; padding:10px 12px; border-radius:6px; " |
| f"word-break:break-word; text-align:left;'>{exc}</div>" |
| "</div>" |
| ) |
|
|
|
|
| def _ui_updates(lang_choice: str, current_regions=None): |
| """Return gr.update() for the 5 translatable input-area components (no output_html).""" |
| lang = _LANG_MAP.get(lang_choice, "en") |
| t = _I18N[lang] |
| new_choices = _localized_regions(lang) |
| |
| translated = [] |
| for r in (current_regions or []): |
| en = _display_to_en(r) |
| if en in _BODY_REGIONS: |
| translated.append(new_choices[_BODY_REGIONS.index(en)]) |
| hint_html = ( |
| f"<p style='font-size:0.8rem; color:#6b7280; margin:4px 0 10px; line-height:1.5;'>{t['input_hint']}</p>" |
| ) |
| chat_label_html = ( |
| f"<div style='font-size:0.78rem; text-transform:uppercase; " |
| f"letter-spacing:.05em; color:#9ca3af; margin:14px 0 8px; font-weight:600;'>{t['chat_label']}</div>" |
| ) |
| return ( |
| gr.update(label=t["img_label"], choices=[t["img_mode_standard"], t["img_mode_compare"]]), |
| gr.update(label=t["img_label_day1"]), |
| gr.update(label=t["img_label_dayx"]), |
| gr.update(label=t["symptoms_label"], placeholder=t["symptoms_placeholder"]), |
| gr.update(value=t["analyze_btn"]), |
| gr.update(label=t["region_optional_label"], choices=new_choices, value=translated), |
| gr.update(value=hint_html), |
| gr.update(placeholder=t["chat_placeholder"]), |
| gr.update(value=t["chat_send"]), |
| gr.update(value=t["tts_btn"]), |
| gr.update(value=chat_label_html), |
| ) |
|
|
|
|
| def _regions_to_prompt(selected) -> str: |
| """Convert list of display names (any lang) to English prompt string.""" |
| if not selected: |
| return "" |
| if isinstance(selected, str): |
| selected = [selected] |
| en_regions = [_display_to_en(r) for r in selected] |
| return ", ".join(r for r in en_regions if r) |
|
|
|
|
| def on_region_change(selected, lang_choice: str = "English"): |
| """Re-render the body map SVG when selection changes (map display→EN first).""" |
| if isinstance(selected, str): |
| selected = [selected] |
| en_keys = [_display_to_en(r) for r in (selected or [])] |
| lang = _LANG_MAP.get(lang_choice, "en") |
| return _body_map_svg(en_keys, lang) |
|
|
|
|
| def on_svg_click(svg_id: str, current_regions: list, lang_choice: str) -> tuple: |
| """Toggle a body region from an SVG click. Returns (new_dropdown_value, new_svg_html).""" |
| |
| svg_to_region = {v[0]: k for k, v in _REGION_SHAPE_MAP.items() if v} |
| clicked_en = svg_to_region.get(svg_id, "") |
| if not clicked_en: |
| return current_regions, _body_map_svg([_display_to_en(r) for r in (current_regions or [])]) |
|
|
| lang = _LANG_MAP.get(lang_choice, "en") |
| choices = _localized_regions(lang) |
| en_list = _BODY_REGIONS |
| clicked_display = choices[en_list.index(clicked_en)] if clicked_en in en_list else clicked_en |
|
|
| current = list(current_regions or []) |
| |
| en_current = [_display_to_en(r) for r in current] |
| if clicked_en in en_current: |
| current = [r for r in current if _display_to_en(r) != clicked_en] |
| else: |
| current.append(clicked_display) |
|
|
| new_en = [_display_to_en(r) for r in current] |
| return current, _body_map_svg(new_en, lang) |
|
|
|
|
| def on_lang_change(lang_choice: str, selected_regions): |
| lang = _LANG_MAP.get(lang_choice, "en") |
| t = _I18N.get(lang, _I18N["en"]) |
| mode_upd, day1_upd, dayx_upd, sym_upd, btn_upd, region_upd, hint_upd, chat_ph_upd, chat_send_upd, _tts_upd, chat_lbl_upd = _ui_updates( |
| lang_choice, current_regions=selected_regions |
| ) |
| return (mode_upd, day1_upd, dayx_upd, sym_upd, btn_upd, region_upd, hint_upd, |
| _empty_output_html(lang), _empty_soap_html(lang), get_backend_status_html(lang), |
| chat_ph_upd, chat_send_upd, gr.update(visible=False, value=t["tts_btn"]), chat_lbl_upd) |
|
|
|
|
| def on_load(request: gr.Request): |
| lang_display = _detect_lang_from_header( |
| request.headers.get("accept-language", "") |
| ) |
| mode_upd, day1_upd, dayx_upd, sym_upd, btn_upd, region_upd, hint_upd, chat_ph_upd, chat_send_upd, _tts_upd, chat_lbl_upd = _ui_updates( |
| lang_display, current_regions=[] |
| ) |
| lang = _LANG_MAP.get(lang_display, "en") |
| t = _I18N.get(lang, _I18N["en"]) |
| return ( |
| lang_display, |
| mode_upd, day1_upd, dayx_upd, |
| sym_upd, btn_upd, region_upd, hint_upd, |
| _body_map_svg([], lang), |
| _empty_output_html(lang), |
| _empty_soap_html(lang), |
| get_backend_status_html(lang), |
| chat_ph_upd, chat_send_upd, gr.update(visible=False, value=t["tts_btn"]), chat_lbl_upd, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def predict(image_1, image_2, symptoms: str, lang_choice: str, selected_regions): |
| lang = _LANG_MAP.get(lang_choice, "en") |
| t = _I18N[lang] |
| _empty_ctx = {} |
|
|
| if not image_1 and not image_2 and not (symptoms or "").strip(): |
| return ( |
| _empty_output_html(lang), _empty_soap_html(lang), |
| get_backend_status_html(lang), _empty_ctx, [], gr.update(visible=False), |
| gr.update(visible=False), "", |
| ) |
|
|
| region = _regions_to_prompt(selected_regions) |
|
|
| try: |
| result = get_pipeline().process( |
| image_1, image_2, (symptoms or "").strip(), lang=lang, region=region |
| ) |
| patient_msg = result.get("patient_message", "") |
| ctx = { |
| "visual_description": result.get("visual_description", ""), |
| "possible_conditions": result.get("possible_conditions", []), |
| "triage_level": result.get("triage_level", "Low"), |
| "urgency_reason": result.get("urgency_reason", ""), |
| "red_flags": result.get("red_flags", []), |
| "watch_symptoms": result.get("watch_symptoms", []), |
| "patient_message": patient_msg, |
| } |
| return ( |
| _build_result_html(result, lang), |
| _build_soap_html(result.get("soap_note", ""), lang), |
| get_backend_status_html(lang), |
| ctx, [], |
| gr.update(visible=True), |
| gr.update(visible=bool(patient_msg)), |
| patient_msg, |
| ) |
| except Exception as exc: |
| return ( |
| _error_html(t, exc), _empty_soap_html(lang), |
| get_backend_status_html(lang), _empty_ctx, [], gr.update(visible=False), |
| gr.update(visible=False), "", |
| ) |
|
|
|
|
| def on_tts_click(patient_msg: str, lang_choice: str): |
| """Generate TTS audio for the patient message.""" |
| lang = _LANG_MAP.get(lang_choice, "en") |
| audio_path = _tts_generate(patient_msg, lang) |
| return audio_path |
|
|
|
|
| |
| |
| |
|
|
| def on_chat_send(question: str, history: list, context: dict, lang_choice: str): |
| from src.agents import chat_agent |
| lang = _LANG_MAP.get(lang_choice, "en") |
| if not question or not question.strip(): |
| return history, "" |
| |
| msgs = list(history or []) |
| tuples = [] |
| for i in range(0, len(msgs) - 1, 2): |
| if msgs[i].get("role") == "user" and msgs[i+1].get("role") == "assistant": |
| tuples.append([msgs[i]["content"], msgs[i+1]["content"]]) |
| try: |
| answer, _ = chat_agent(question.strip(), context, tuples, lang) |
| except Exception as exc: |
| answer = f"⚠️ {exc}" |
| history = list(history or []) + [ |
| {"role": "user", "content": question.strip()}, |
| {"role": "assistant", "content": answer}, |
| ] |
| return history, "" |
|
|
|
|
| |
| |
| |
|
|
| CSS = """ |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap'); |
| |
| /* ── Base ── */ |
| body, .gradio-container { |
| background-color: #030712 !important; |
| color: #f9fafb !important; |
| font-family: 'Inter', 'Segoe UI', Arial, sans-serif; |
| } |
| |
| /* ── Accessibility: focus rings ── */ |
| *:focus-visible { |
| outline: 2px solid #ED1C24 !important; |
| outline-offset: 2px !important; |
| border-radius: 4px; |
| } |
| input:focus, textarea:focus { |
| border-color: #ED1C24 !important; |
| box-shadow: 0 0 0 2px rgba(237,28,36,0.25) !important; |
| } |
| |
| /* ── iOS: prevent auto-zoom on small inputs (must be ≥16px) ── */ |
| @media (max-width: 768px) { |
| input[type="text"], input[type="search"], select, |
| textarea.scroll-hide { font-size: 16px !important; } |
| } |
| |
| /* ── Analyze button ── */ |
| button.primary, .gr-button-primary { |
| background: linear-gradient(135deg, #ED1C24 0%, #b01318 100%) !important; |
| color: #fff !important; |
| border: none !important; |
| font-weight: 700 !important; |
| font-size: 1rem !important; |
| letter-spacing: 0.05em !important; |
| text-transform: uppercase !important; |
| width: 100% !important; |
| padding: 14px 0 !important; |
| border-radius: 8px !important; |
| box-shadow: 0 0 16px rgba(237,28,36,0.45), 0 4px 12px rgba(0,0,0,0.4) !important; |
| transition: box-shadow 0.25s ease, opacity 0.2s !important; |
| } |
| button.primary:hover { |
| opacity: 0.92 !important; |
| box-shadow: 0 0 28px rgba(237,28,36,0.65), 0 6px 16px rgba(0,0,0,0.5) !important; |
| } |
| button.primary:active { opacity: 0.8 !important; transform: scale(0.99) !important; } |
| |
| /* Chat "Send" — override full-width / glow from above */ |
| #chat-section button.primary, |
| #chat-section .gr-button-primary { |
| width: auto !important; |
| min-width: 72px !important; |
| padding: 10px 18px !important; |
| font-size: 0.875rem !important; |
| letter-spacing: 0.02em !important; |
| text-transform: none !important; |
| box-shadow: 0 2px 8px rgba(237,28,36,0.3) !important; |
| } |
| |
| /* ── Panels / boxes ── */ |
| .gr-box, .gr-panel { |
| background: #111827 !important; |
| border: 1px solid #1f2937 !important; |
| border-radius: 10px !important; |
| } |
| |
| /* ── Form labels ── */ |
| label span, .gr-form > label { |
| color: #cbd5e1 !important; |
| font-size: 0.82rem !important; |
| font-weight: 600 !important; |
| text-transform: uppercase; |
| letter-spacing: 0.04em; |
| } |
| footer { display: none !important; } |
| |
| /* ── TTS button ── */ |
| #tts-btn { |
| background: #1e3a5f !important; |
| color: #93c5fd !important; |
| border: 1px solid #2563eb !important; |
| font-weight: 600 !important; |
| font-size: 0.875rem !important; |
| letter-spacing: 0.03em !important; |
| text-transform: none !important; |
| padding: 8px 18px !important; |
| min-height: 44px !important; |
| border-radius: 6px !important; |
| box-shadow: none !important; |
| width: auto !important; |
| transition: background 0.2s, border-color 0.2s !important; |
| } |
| #tts-btn:hover { |
| background: #1d4ed8 !important; |
| border-color: #3b82f6 !important; |
| opacity: 1 !important; |
| box-shadow: none !important; |
| } |
| |
| /* ── Tab bar — min 44px touch target, clear selected state ── */ |
| [role="tab"] { |
| min-height: 44px !important; |
| padding: 8px 16px !important; |
| color: #9ca3af !important; |
| font-size: 0.875rem !important; |
| font-weight: 500 !important; |
| transition: color 0.2s !important; |
| } |
| [role="tab"][aria-selected="true"] { color: #f9fafb !important; } |
| |
| /* ── Animations ── */ |
| @keyframes redflash { |
| 0%, 100% { opacity: 1; box-shadow: 0 0 12px rgba(239,68,68,0.6); } |
| 50% { opacity: 0.7; box-shadow: 0 0 24px rgba(239,68,68,0.9); } |
| } |
| @keyframes fadeSlideIn { |
| from { opacity: 0; transform: translateY(8px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| @keyframes medispin { |
| from { transform: rotate(0deg); } |
| to { transform: rotate(360deg); } |
| } |
| @keyframes medipulse { |
| 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } |
| 40% { transform: scale(1.1); opacity: 1; } |
| } |
| |
| /* ── Respect prefers-reduced-motion ── */ |
| @media (prefers-reduced-motion: reduce) { |
| *, *::before, *::after { |
| animation-duration: 0.01ms !important; |
| animation-iteration-count: 1 !important; |
| transition-duration: 0.01ms !important; |
| } |
| } |
| |
| /* ── Scrollbar ── */ |
| ::-webkit-scrollbar { width: 6px; height: 6px; } |
| ::-webkit-scrollbar-track { background: #111827; } |
| ::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; } |
| ::-webkit-scrollbar-thumb:hover { background: #4b5563; } |
| |
| /* ── Topbar ── */ |
| #topbar { |
| display: flex !important; |
| align-items: center !important; |
| justify-content: space-between !important; |
| flex-wrap: wrap !important; |
| gap: 8px !important; |
| padding: 8px 0 !important; |
| border-bottom: 1px solid #1f2937; |
| margin-bottom: 16px; |
| } |
| #topbar > .gr-row, #topbar > div { width: 100% !important; } |
| #lang-col { min-width: 160px !important; max-width: 200px !important; } |
| #lang-col label span { text-transform: none !important; font-size: 0.82rem !important; } |
| |
| /* ── Chat section: smooth reveal ── */ |
| #chat-section { animation: fadeSlideIn 0.3s ease; } |
| |
| /* ── Main 2-column → stack on mobile ── */ |
| #main-row { flex-wrap: wrap !important; } |
| @media (max-width: 768px) { |
| #main-row > div { |
| min-width: 100% !important; |
| width: 100% !important; |
| flex: 0 0 100% !important; |
| } |
| /* Chatbot height on mobile */ |
| #chat-box > div { height: 200px !important; } |
| } |
| |
| /* ── SVG body map: tap without double-tap zoom ── */ |
| .bpart { touch-action: manipulation; } |
| |
| /* ── Image upload: mobile ── */ |
| @media (pointer: coarse), (max-width: 768px) { |
| .upload-container [data-testid="drop-zone"] .upload-text:first-child, |
| .svelte-upload .file-preview-title, |
| span.drag-text, .drag-drop-label { display: none !important; } |
| .upload-container { min-height: 120px !important; } |
| .upload-container button { font-size: 0.9rem !important; padding: 10px 20px !important; } |
| } |
| |
| /* ── Quick Examples — horizontal scroll on mobile ── */ |
| @media (max-width: 768px) { |
| .gr-samples table, table.gr-samples-table { |
| display: flex !important; |
| overflow-x: auto !important; |
| -webkit-overflow-scrolling: touch !important; |
| gap: 8px !important; |
| padding-bottom: 6px !important; |
| } |
| .gr-samples tr, .gr-samples-table tr { |
| display: inline-flex !important; |
| flex-direction: column !important; |
| min-width: 220px !important; |
| background: #111827 !important; |
| border: 1px solid #1f2937 !important; |
| border-radius: 8px !important; |
| padding: 8px 10px !important; |
| white-space: normal !important; |
| } |
| .gr-samples thead, .gr-samples-table thead { display: none !important; } |
| } |
| |
| /* ── Safe area insets (notch / home-bar) ── */ |
| @supports (padding-bottom: env(safe-area-inset-bottom)) { |
| .gradio-container { |
| padding-left: max(12px, env(safe-area-inset-left)) !important; |
| padding-right: max(12px, env(safe-area-inset-right)) !important; |
| padding-bottom: max(16px, env(safe-area-inset-bottom)) !important; |
| } |
| } |
| """ |
|
|
| |
| |
| |
|
|
| HEADER_HTML = """ |
| <div style='text-align:center; padding:clamp(16px,4vw,28px) 8px 8px; user-select:none;'> |
| <div style='font-size:clamp(1.6rem,6vw,2.2rem); font-weight:900; letter-spacing:-0.02em; line-height:1.1;'> |
| <span style='color:#ED1C24;'>Medi</span><span style='color:#f9fafb;'>Vision</span> |
| </div> |
| <div style='color:#9ca3af; font-size:clamp(0.8rem,2.5vw,0.92rem); margin-top:6px; line-height:1.4;'> |
| Multilingual Dermatology & Wound Care AI Assistant |
| </div> |
| <div style='margin-top:10px; display:flex; flex-wrap:wrap; gap:6px; justify-content:center;'> |
| <span style='background:#1f2937; color:#ED1C24; font-size:0.7rem; font-weight:700; |
| padding:4px 12px; border-radius:999px; border:1px solid #ED1C24; white-space:nowrap;'> |
| AMD Instinct™ MI300X |
| </span> |
| <span style='background:#1f2937; color:#9ca3af; font-size:0.7rem; font-weight:600; |
| padding:4px 12px; border-radius:999px; border:1px solid #374151; white-space:nowrap;'> |
| ROCm · Qwen2.5-VL-7B |
| </span> |
| <span style='background:#1f2937; color:#9ca3af; font-size:0.7rem; font-weight:600; |
| padding:4px 12px; border-radius:999px; border:1px solid #374151; white-space:nowrap;'> |
| EN · VI · ZH · ES · FR · JA |
| </span> |
| <span style='background:#1f2937; color:#9ca3af; font-size:0.7rem; font-weight:600; |
| padding:4px 12px; border-radius:999px; border:1px solid #374151; white-space:nowrap;'> |
| AMD Developer Hackathon 2026 |
| </span> |
| </div> |
| </div> |
| """ |
|
|
| FOOTER_HTML = """ |
| <div style='text-align:center; padding:16px 8px 8px; border-top:1px solid #1f2937; margin-top:12px;'> |
| <span style='color:#4b5563; font-size:0.75rem; line-height:1.8;'> |
| Powered by <span style='color:#ED1C24; font-weight:700;'>AMD MI300X + ROCm</span> |
| · Track 3: Vision & Multimodal AI · MIT License |
| </span> |
| </div> |
| """ |
|
|
| BLOCKS_JS = """ |
| () => { |
| /* ── SVG body-map click → hidden bridge input ── */ |
| document.addEventListener('click', function(e) { |
| var el = e.target.closest('.bpart'); |
| if (!el) return; |
| var svgId = el.id; |
| if (!svgId) return; |
| var bridge = document.getElementById('svg-click-bridge'); |
| if (!bridge) return; |
| var input = bridge.querySelector('input, textarea'); |
| if (!input) return; |
| var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set |
| || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; |
| setter.call(input, svgId); |
| input.dispatchEvent(new InputEvent('input', { bubbles: true, data: svgId })); |
| input.dispatchEvent(new Event('change', { bubbles: true })); |
| }); |
| } |
| """ |
|
|
| _TAB_LABELS_JS = """{ |
| 'English': ['Patient View', 'Export for Doctor (SOAP)'], |
| 'Tiếng Việt': ['Dành cho bệnh nhân', 'Xuất cho bác sĩ (SOAP)'], |
| '中文': ['患者视图', '导出给医生(SOAP)'], |
| 'Español': ['Vista del paciente', 'Exportar para médico (SOAP)'], |
| 'Français': ['Vue patient', 'Exporter pour le médecin (SOAP)'], |
| '日本語': ['患者向け', '医師向けエクスポート(SOAP)'], |
| }""" |
|
|
| UPDATE_TABS_JS = f"""(langDisplay) => {{ |
| var TAB_LABELS = {_TAB_LABELS_JS}; |
| var labels = TAB_LABELS[langDisplay] || TAB_LABELS['English']; |
| var tabContainer = document.getElementById('output-tabs'); |
| if (!tabContainer) return langDisplay; |
| /* Gradio renders tab buttons inside a div[role=tablist] */ |
| var tablist = tabContainer.querySelector('[role=tablist]') || tabContainer; |
| var tabButtons = Array.from(tablist.querySelectorAll('button[role=tab]')); |
| if (tabButtons.length < 2) tabButtons = Array.from(tablist.querySelectorAll('button')); |
| tabButtons.slice(0, 2).forEach(function(btn, i) {{ |
| if (labels[i] === undefined) return; |
| /* Find the text span inside the button (Gradio wraps label in a span) */ |
| var span = btn.querySelector('span') || btn; |
| span.textContent = labels[i]; |
| }}); |
| return langDisplay; |
| }}""" |
|
|
| with gr.Blocks(css=CSS, js=BLOCKS_JS, theme=gr.themes.Base(), title="MediVision — Dermatology & Wound Care AI") as demo: |
|
|
| gr.HTML(HEADER_HTML) |
|
|
| |
| with gr.Row(elem_id="topbar"): |
| with gr.Column(scale=5): |
| status_bar = gr.HTML(value="<div style='height:24px;'></div>") |
| with gr.Column(scale=0, elem_id="lang-col"): |
| lang_radio = gr.Dropdown( |
| choices=_LANG_CHOICES, |
| value="English", |
| label="🌐 Language", |
| container=False, |
| show_label=False, |
| ) |
|
|
| |
| svg_click_bridge = gr.Textbox( |
| value="", |
| visible=False, |
| elem_id="svg-click-bridge", |
| ) |
|
|
| |
| with gr.Row(equal_height=False, elem_id="main-row"): |
|
|
| with gr.Column(scale=1, min_width=300): |
| img_mode = gr.Radio( |
| choices=[_I18N["en"]["img_mode_standard"], _I18N["en"]["img_mode_compare"]], |
| value=_I18N["en"]["img_mode_standard"], |
| label=_I18N["en"]["img_mode_label"], |
| elem_id="img-mode-radio", |
| ) |
| with gr.Row(equal_height=True): |
| input_img = gr.Image( |
| type="filepath", |
| label=_I18N["en"]["img_label_day1"], |
| height=200, |
| ) |
| input_img_2 = gr.Image( |
| type="filepath", |
| label=_I18N["en"]["img_label_dayx"], |
| height=200, |
| visible=False, |
| ) |
| symptoms_txt = gr.Textbox( |
| label="Symptoms Description", |
| placeholder="Describe what you feel — e.g. itchy red patch for 3 days...", |
| lines=4, |
| ) |
|
|
| input_hint_html = gr.HTML( |
| value=f"<p style='font-size:0.8rem; color:#6b7280; margin:4px 0 10px; line-height:1.5;'>" |
| f"{_I18N['en']['input_hint']}</p>", |
| elem_id="input-hint", |
| ) |
|
|
| with gr.Row(equal_height=True): |
| with gr.Column(scale=0, min_width=80): |
| body_map_html = gr.HTML(value=_body_map_svg([])) |
| with gr.Column(scale=1): |
| region_selector = gr.Dropdown( |
| choices=_BODY_REGIONS, |
| value=[], |
| multiselect=True, |
| label="Affected Body Region", |
| container=True, |
| ) |
|
|
| submit_btn = gr.Button("🔬 Analyze", variant="primary", size="lg", elem_id="analyze-btn") |
|
|
| gr.Examples( |
| examples=[ |
| [None, "I have a red, itchy rash on my forearm for 3 days. It burns slightly.", "English"], |
| [None, "Small wound on my hand after a cut, slightly swollen with some redness.", "English"], |
| [None, "Vết thương nhỏ ở bàn tay, hơi sưng và có dấu hiệu đỏ xung quanh.", "Tiếng Việt"], |
| [None, "手臂上出现红色瘙痒皮疹,已持续3天,略有灼热感。", "中文"], |
| [None, "Tengo una erupción roja y con picazón en el antebrazo desde hace 3 días.", "Español"], |
| [None, "J'ai une éruption rouge et prurigineuse sur l'avant-bras depuis 3 jours.", "Français"], |
| [None, "3日前から前腕に赤くてかゆい発疹があり、少し灼熱感があります。", "日本語"], |
| ], |
| inputs=[input_img, symptoms_txt, lang_radio], |
| label="Quick Examples", |
| ) |
|
|
| with gr.Column(scale=1, min_width=340): |
| with gr.Tabs(elem_id="output-tabs"): |
| with gr.TabItem(_I18N["en"]["tab_patient"], elem_id="tab-patient"): |
| output_html = gr.HTML(value=_empty_output_html("en")) |
| with gr.TabItem(_I18N["en"]["tab_doctor"], elem_id="tab-doctor"): |
| soap_html = gr.HTML(value=_empty_soap_html("en")) |
|
|
| tts_btn = gr.Button( |
| _I18N["en"]["tts_btn"], |
| variant="secondary", |
| size="sm", |
| elem_id="tts-btn", |
| visible=False, |
| min_width=100, |
| ) |
| tts_audio = gr.Audio( |
| value=None, |
| label=None, |
| autoplay=True, |
| visible=False, |
| show_label=False, |
| show_download_button=False, |
| ) |
|
|
| |
| with gr.Group(visible=False, elem_id="chat-section") as chat_section: |
| chat_label_html = gr.HTML( |
| "<div style='font-size:0.78rem; text-transform:uppercase; " |
| "letter-spacing:.05em; color:#9ca3af; margin:14px 0 8px; font-weight:600;'>" |
| f"{_I18N['en']['chat_label']}</div>" |
| ) |
| chat_box = gr.Chatbot( |
| value=[], |
| elem_id="chat-box", |
| height=280, |
| show_label=False, |
| type="messages", |
| ) |
| with gr.Row(equal_height=True): |
| chat_input = gr.Textbox( |
| placeholder=_I18N["en"]["chat_placeholder"], |
| show_label=False, |
| lines=1, |
| scale=5, |
| container=False, |
| ) |
| chat_send_btn = gr.Button( |
| _I18N["en"]["chat_send"], |
| variant="primary", |
| scale=1, |
| min_width=72, |
| ) |
|
|
| |
| analysis_context_state = gr.State({}) |
| chat_history_state = gr.State([]) |
| patient_msg_state = gr.State("") |
|
|
| |
|
|
| |
| img_mode.change( |
| fn=lambda m, lc: gr.update(visible=_I18N[_LANG_MAP.get(lc, "en")]["img_mode_compare"] in m), |
| inputs=[img_mode, lang_radio], |
| outputs=[input_img_2], |
| ) |
|
|
| |
| for _svg_evt in [svg_click_bridge.input, svg_click_bridge.change]: |
| _svg_evt( |
| fn=on_svg_click, |
| inputs=[svg_click_bridge, region_selector, lang_radio], |
| outputs=[region_selector, body_map_html], |
| ) |
|
|
| |
| region_selector.change( |
| fn=on_region_change, |
| inputs=[region_selector, lang_radio], |
| outputs=[body_map_html], |
| ) |
|
|
| lang_radio.change( |
| fn=on_lang_change, |
| inputs=[lang_radio, region_selector], |
| outputs=[img_mode, input_img, input_img_2, symptoms_txt, submit_btn, |
| region_selector, input_hint_html, output_html, soap_html, status_bar, |
| chat_input, chat_send_btn, tts_btn, chat_label_html], |
| ).then( |
| fn=None, |
| inputs=[lang_radio], |
| js=UPDATE_TABS_JS, |
| ) |
|
|
| submit_btn.click( |
| fn=lambda lc: _loading_output_html(_LANG_MAP.get(lc, "en")), |
| inputs=[lang_radio], |
| outputs=[output_html], |
| ).then( |
| fn=predict, |
| inputs=[input_img, input_img_2, symptoms_txt, lang_radio, region_selector], |
| outputs=[output_html, soap_html, status_bar, |
| analysis_context_state, chat_history_state, chat_section, |
| tts_btn, patient_msg_state], |
| api_name="analyze", |
| ).then( |
| fn=lambda h: h, |
| inputs=[chat_history_state], |
| outputs=[chat_box], |
| ) |
|
|
| tts_btn.click( |
| fn=on_tts_click, |
| inputs=[patient_msg_state, lang_radio], |
| outputs=[tts_audio], |
| ).then( |
| fn=lambda a: gr.update(visible=bool(a)), |
| inputs=[tts_audio], |
| outputs=[tts_audio], |
| ) |
|
|
| |
| def _do_send(q, hist, ctx, lc): |
| return on_chat_send(q, hist, ctx, lc) |
|
|
| chat_send_btn.click( |
| fn=_do_send, |
| inputs=[chat_input, chat_history_state, analysis_context_state, lang_radio], |
| outputs=[chat_history_state, chat_input], |
| ).then(fn=lambda h: h, inputs=[chat_history_state], outputs=[chat_box]) |
|
|
| chat_input.submit( |
| fn=_do_send, |
| inputs=[chat_input, chat_history_state, analysis_context_state, lang_radio], |
| outputs=[chat_history_state, chat_input], |
| ).then(fn=lambda h: h, inputs=[chat_history_state], outputs=[chat_box]) |
|
|
| demo.load( |
| fn=on_load, |
| inputs=[], |
| outputs=[ |
| lang_radio, |
| img_mode, input_img, input_img_2, |
| symptoms_txt, submit_btn, region_selector, input_hint_html, |
| body_map_html, output_html, soap_html, status_bar, |
| chat_input, chat_send_btn, tts_btn, chat_label_html, |
| ], |
| ).then( |
| fn=None, |
| inputs=[lang_radio], |
| js=UPDATE_TABS_JS, |
| ) |
|
|
| gr.HTML(FOOTER_HTML) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch(server_name="0.0.0.0", server_port=7860) |
|
|