Upload 20 files
Browse files- .env +1 -0
- .gitattributes +10 -0
- README.md +100 -7
- app.py +403 -0
- images/dark.png +3 -0
- images/hindi.png +3 -0
- images/light.png +3 -0
- images/llm-1.png +3 -0
- images/norwegian.png +3 -0
- images/notes.png +3 -0
- images/urdu.png +3 -0
- images/zoom.png +3 -0
- kill_port.py +21 -0
- notebooks/Data.ipynb +0 -0
- notebooks/data_preparation.ipynb +0 -0
- notebooks/fine-tuning-blip.ipynb +3 -0
- notebooks/swin-bart.ipynb +3 -0
- notebooks/swin-bert.ipynb +0 -0
- requirements.txt +8 -0
- static/style.css +0 -0
- templates/index.html +1466 -0
.env
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
HUGGING_FACE_HUB_TOKEN=ADD_YOUR_KEY
|
.gitattributes
CHANGED
|
@@ -33,3 +33,13 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
images/dark.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
images/hindi.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
images/light.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
images/llm-1.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
images/norwegian.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
images/notes.png filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
images/urdu.png filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
images/zoom.png filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
notebooks/fine-tuning-blip.ipynb filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
notebooks/swin-bart.ipynb filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,10 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🩺 Chest X-ray Report Generation via Vision-Language Models
|
| 2 |
+
|
| 3 |
+
A modular monolithic web application that generates radiology-style reports from chest X-ray images using Vision-Language Models (VLMs) and supports multilingual, contextual question-answering via Large Language Models (LLMs).
|
| 4 |
+
|
| 5 |
+

|
| 6 |
+

|
| 7 |
+

|
| 8 |
+

|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## Overview
|
| 13 |
+
|
| 14 |
+
This project combines computer vision and natural language understanding to assist medical students and practitioners in interpreting chest X-rays. Users can:
|
| 15 |
+
|
| 16 |
+
- Upload chest X-ray images.
|
| 17 |
+
- Automatically generate medical-style reports using Swin-T5.
|
| 18 |
+
- Ask contextual questions about the report.
|
| 19 |
+
- Receive multilingual explanations (e.g., Hindi, Urdu, Norwegian).
|
| 20 |
+
- Take structured notes as a student or educator.
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
## Models and Data
|
| 24 |
+
- VLMs used in this project are BLIP, Swin-BART, and Swin-T5
|
| 25 |
+
- LLM used in this project is LLaMA3-8B Instruct (https://huggingface.co/meta-llama/Meta-Llama-3-8B-Instruct)
|
| 26 |
+
- Dataset used is called "CheXpert Plus". The first chunk of size 155GB is used (https://stanfordaimi.azurewebsites.net/datasets/5158c524-d3ab-4e02-96e9-6ee9efc110a1)
|
| 27 |
+
- The weights of the best performing model (Swin +T5) can be found here (https://studntnu-my.sharepoint.com/personal/aleksace_ntnu_no/_layouts/15/onedrive.aspx?id=/personal/aleksace_ntnu_no/Documents/InnovationProject/swin-t5-model.pth&parent=/personal/aleksace_ntnu_no/Documents/InnovationProject&ga=1)
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## Features
|
| 31 |
+
|
| 32 |
+
- 🔍 **Vision-Language Report Generation** (Swin-T5, Swin-BART, BLIP)
|
| 33 |
+
- 💬 **Interactive Chatbot (LLaMA-3.1)** with multilingual responses
|
| 34 |
+
- 🖼️ **Zoomable image preview**
|
| 35 |
+
- 📝 **Note-taking section for medical education**
|
| 36 |
+
- 🌗 **Dark/Light mode toggle**
|
| 37 |
+
- 🧪 **ROUGE-1 metric evaluation**
|
| 38 |
+
- 🔐 **No external API dependencies (except Hugging Face for model access)**
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## Technology Stack
|
| 43 |
+
|
| 44 |
+
| Layer | Technology |
|
| 45 |
+
|--------------|-------------------------------------|
|
| 46 |
+
| Backend | Python, Flask, PyTorch, Hugging Face Transformers |
|
| 47 |
+
| Frontend | HTML5, CSS3, JavaScript, Bootstrap |
|
| 48 |
+
| Deep Learning | Swin-T5, LLaMA-3, BLIP, Torchvision |
|
| 49 |
+
| Deployment | Docker, NVIDIA CUDA, Git, GitHub |
|
| 50 |
+
| Development | VS Code |
|
| 51 |
+
|
| 52 |
---
|
| 53 |
+
|
| 54 |
+
## Application Architecture
|
| 55 |
+
|
| 56 |
+
This is a **modular monolithic** application organized into the following components:
|
| 57 |
+
|
| 58 |
+
- `app.py`: Main Flask entry point
|
| 59 |
+
- `vlm_utils.py`: Vision-Language Model loading and inference
|
| 60 |
+
- `chat_utils.py`: LLM-based contextual question answering
|
| 61 |
+
- `preprocess.py`: Image transformations and metadata extraction
|
| 62 |
+
- `templates/`: Jinja2 HTML files (frontend)
|
| 63 |
+
- `static/`: CSS, JS, and assets
|
| 64 |
+
|
| 65 |
---
|
| 66 |
|
| 67 |
+
## Getting Started
|
| 68 |
+
|
| 69 |
+
### Prerequisites
|
| 70 |
+
|
| 71 |
+
- Python 3.9+
|
| 72 |
+
- CUDA-enabled GPU (recommended)
|
| 73 |
+
- Docker (optional for containerized setup)
|
| 74 |
+
|
| 75 |
+
### Setup Instructions
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
# 1. Clone the repository
|
| 79 |
+
git clone https://github.com/ammarlodhi255/Chest-xray-report-generation-app-using-VLM-and-LLM.git
|
| 80 |
+
cd Chest-xray-report-generation-app-using-VLM-and-LLM
|
| 81 |
+
|
| 82 |
+
# 2. Create virtual environment
|
| 83 |
+
python -m venv venv
|
| 84 |
+
source venv/bin/activate # or venv\Scripts\activate on Windows
|
| 85 |
+
|
| 86 |
+
# 3. Install dependencies
|
| 87 |
+
pip install -r requirements.txt
|
| 88 |
+
|
| 89 |
+
# 4. (Optional) Load HF Token for private LLaMA access
|
| 90 |
+
export HF_TOKEN=your_token_here
|
| 91 |
+
|
| 92 |
+
# 5. Running the App
|
| 93 |
+
python app.py
|
| 94 |
+
|
| 95 |
+
Then visit: http://127.0.0.1:5000
|
| 96 |
+
```
|
| 97 |
+
### LLM Interactions
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+

|
| 101 |
+

|
| 102 |
+

|
| 103 |
+

|
app.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import io
|
| 3 |
+
import base64
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
import torchvision.transforms as transforms
|
| 7 |
+
from PIL import Image
|
| 8 |
+
from flask import Flask, request, render_template, flash, redirect, url_for, jsonify
|
| 9 |
+
from dotenv import load_dotenv # Import dotenv
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# Import necessary classes from your original script / transformers
|
| 13 |
+
from transformers import (
|
| 14 |
+
SwinModel,
|
| 15 |
+
T5ForConditionalGeneration,
|
| 16 |
+
T5Tokenizer,
|
| 17 |
+
AutoModelForCausalLM, # Added for Llama
|
| 18 |
+
AutoTokenizer, # Added for Llama
|
| 19 |
+
)
|
| 20 |
+
from transformers.modeling_outputs import BaseModelOutput
|
| 21 |
+
|
| 22 |
+
load_dotenv() # Load environment variables from .env file
|
| 23 |
+
|
| 24 |
+
# --- Configuration ---
|
| 25 |
+
MODEL_PATH = '/cluster/home/ammaa/Downloads/Projects/CheXpert-Report-Generation/swin-t5-model.pth' # Path to your trained model weights
|
| 26 |
+
SWIN_MODEL_NAME = "microsoft/swin-base-patch4-window7-224"
|
| 27 |
+
T5_MODEL_NAME = "t5-base"
|
| 28 |
+
LLAMA_MODEL_NAME = "meta-llama/Meta-Llama-3.1-8B-Instruct" # Llama model
|
| 29 |
+
HF_TOKEN = os.getenv("HUGGING_FACE_HUB_TOKEN") # Get token from env
|
| 30 |
+
|
| 31 |
+
if not HF_TOKEN:
|
| 32 |
+
print("Warning: HUGGING_FACE_HUB_TOKEN environment variable not set. Llama model download might fail.")
|
| 33 |
+
|
| 34 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 35 |
+
UPLOAD_FOLDER = 'uploads' # Optional: If you want to save uploads temporarily
|
| 36 |
+
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'}
|
| 37 |
+
|
| 38 |
+
# Ensure the upload folder exists if you use it
|
| 39 |
+
# if not os.path.exists(UPLOAD_FOLDER):
|
| 40 |
+
# os.makedirs(UPLOAD_FOLDER)
|
| 41 |
+
|
| 42 |
+
# --- Swin-T5 Model Definition ---
|
| 43 |
+
class ImageCaptioningModel(nn.Module):
|
| 44 |
+
def __init__(self,
|
| 45 |
+
swin_model_name=SWIN_MODEL_NAME,
|
| 46 |
+
t5_model_name=T5_MODEL_NAME):
|
| 47 |
+
super().__init__()
|
| 48 |
+
self.swin = SwinModel.from_pretrained(swin_model_name)
|
| 49 |
+
self.t5 = T5ForConditionalGeneration.from_pretrained(t5_model_name)
|
| 50 |
+
self.img_proj = nn.Linear(self.swin.config.hidden_size, self.t5.config.d_model)
|
| 51 |
+
|
| 52 |
+
def forward(self, images, labels=None):
|
| 53 |
+
swin_outputs = self.swin(images)
|
| 54 |
+
img_feats = swin_outputs.last_hidden_state
|
| 55 |
+
img_feats_proj = self.img_proj(img_feats)
|
| 56 |
+
encoder_outputs = BaseModelOutput(last_hidden_state=img_feats_proj)
|
| 57 |
+
if labels is not None:
|
| 58 |
+
outputs = self.t5(encoder_outputs=encoder_outputs, labels=labels)
|
| 59 |
+
else:
|
| 60 |
+
outputs = self.t5(encoder_outputs=encoder_outputs)
|
| 61 |
+
return outputs
|
| 62 |
+
|
| 63 |
+
# --- Global Variables for Model Components ---
|
| 64 |
+
swin_t5_model = None
|
| 65 |
+
swin_t5_tokenizer = None
|
| 66 |
+
transform = None
|
| 67 |
+
llama_model = None
|
| 68 |
+
llama_tokenizer = None
|
| 69 |
+
|
| 70 |
+
def load_swin_t5_model_components():
|
| 71 |
+
"""Loads the Swin-T5 model, tokenizer, and transformation pipeline."""
|
| 72 |
+
global swin_t5_model, swin_t5_tokenizer, transform
|
| 73 |
+
try:
|
| 74 |
+
print(f"Loading Swin-T5 model components on device: {DEVICE}")
|
| 75 |
+
# Initialize model structure
|
| 76 |
+
swin_t5_model = ImageCaptioningModel(swin_model_name=SWIN_MODEL_NAME, t5_model_name=T5_MODEL_NAME)
|
| 77 |
+
|
| 78 |
+
# Load state dictionary
|
| 79 |
+
if not os.path.exists(MODEL_PATH):
|
| 80 |
+
raise FileNotFoundError(f"Swin-T5 Model file not found at {MODEL_PATH}.")
|
| 81 |
+
# Load Swin-T5 model to the primary DEVICE (can be CPU or GPU)
|
| 82 |
+
swin_t5_model.load_state_dict(torch.load(MODEL_PATH, map_location=DEVICE))
|
| 83 |
+
swin_t5_model.to(DEVICE)
|
| 84 |
+
swin_t5_model.eval() # Set to evaluation mode
|
| 85 |
+
print("Swin-T5 Model loaded successfully.")
|
| 86 |
+
|
| 87 |
+
# Load tokenizer
|
| 88 |
+
swin_t5_tokenizer = T5Tokenizer.from_pretrained(T5_MODEL_NAME)
|
| 89 |
+
print("Swin-T5 Tokenizer loaded successfully.")
|
| 90 |
+
|
| 91 |
+
# Define image transformations
|
| 92 |
+
transform = transforms.Compose([
|
| 93 |
+
transforms.Resize((224, 224)),
|
| 94 |
+
transforms.ToTensor(),
|
| 95 |
+
transforms.Normalize(mean=[0.485, 0.456, 0.406],
|
| 96 |
+
std=[0.229, 0.224, 0.225])
|
| 97 |
+
])
|
| 98 |
+
print("Transforms defined.")
|
| 99 |
+
|
| 100 |
+
except Exception as e:
|
| 101 |
+
print(f"Error loading Swin-T5 model components: {e}")
|
| 102 |
+
raise
|
| 103 |
+
|
| 104 |
+
def load_llama_model_components():
|
| 105 |
+
"""Loads the Llama model and tokenizer."""
|
| 106 |
+
global llama_model, llama_tokenizer
|
| 107 |
+
if not HF_TOKEN:
|
| 108 |
+
print("Skipping Llama model load: Hugging Face token not found.")
|
| 109 |
+
return # Don't attempt to load if no token
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
print(f"Loading Llama model ({LLAMA_MODEL_NAME}) components...")
|
| 113 |
+
# Use bfloat16 for memory efficiency if available, otherwise float16/32
|
| 114 |
+
torch_dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16
|
| 115 |
+
|
| 116 |
+
llama_tokenizer = AutoTokenizer.from_pretrained(LLAMA_MODEL_NAME, token=HF_TOKEN)
|
| 117 |
+
llama_model = AutoModelForCausalLM.from_pretrained(
|
| 118 |
+
LLAMA_MODEL_NAME,
|
| 119 |
+
torch_dtype=torch_dtype,
|
| 120 |
+
device_map="auto", # Automatically distribute across GPUs/CPU RAM if needed
|
| 121 |
+
token=HF_TOKEN
|
| 122 |
+
# Add quantization config here if needed (e.g., load_in_4bit=True with bitsandbytes)
|
| 123 |
+
# quantization_config=BitsAndBytesConfig(...)
|
| 124 |
+
)
|
| 125 |
+
llama_model.eval() # Set to evaluation mode
|
| 126 |
+
print("Llama Model and Tokenizer loaded successfully.")
|
| 127 |
+
|
| 128 |
+
except Exception as e:
|
| 129 |
+
print(f"Error loading Llama model components: {e}")
|
| 130 |
+
# Decide if the app should run without the chat feature or crash
|
| 131 |
+
llama_model = None
|
| 132 |
+
llama_tokenizer = None
|
| 133 |
+
print("WARNING: Chatbot functionality will be disabled due to loading error.")
|
| 134 |
+
# raise # Uncomment this if the chat feature is critical
|
| 135 |
+
|
| 136 |
+
# --- Inference Function (Swin-T5) ---
|
| 137 |
+
def generate_report(image_bytes, selected_vlm, max_length=100):
|
| 138 |
+
"""Generates a report/caption for the given image bytes using Swin-T5."""
|
| 139 |
+
global swin_t5_model, swin_t5_tokenizer, transform
|
| 140 |
+
if not all([swin_t5_model, swin_t5_tokenizer, transform]):
|
| 141 |
+
# Check if loading failed or wasn't called
|
| 142 |
+
if swin_t5_model is None or swin_t5_tokenizer is None or transform is None:
|
| 143 |
+
load_swin_t5_model_components() # Attempt to load again if missing
|
| 144 |
+
if not all([swin_t5_model, swin_t5_tokenizer, transform]):
|
| 145 |
+
raise RuntimeError("Swin-T5 model components failed to load.")
|
| 146 |
+
else:
|
| 147 |
+
raise RuntimeError("Swin-T5 model components not loaded properly.")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if selected_vlm != "swin_t5_chexpert":
|
| 151 |
+
return "Error: Selected VLM is not supported."
|
| 152 |
+
|
| 153 |
+
try:
|
| 154 |
+
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
| 155 |
+
input_image = transform(image).unsqueeze(0).to(DEVICE) # Add batch dimension and send to device
|
| 156 |
+
|
| 157 |
+
# Perform inference
|
| 158 |
+
with torch.no_grad():
|
| 159 |
+
swin_outputs = swin_t5_model.swin(input_image)
|
| 160 |
+
img_feats = swin_outputs.last_hidden_state
|
| 161 |
+
img_feats_proj = swin_t5_model.img_proj(img_feats)
|
| 162 |
+
encoder_outputs = BaseModelOutput(last_hidden_state=img_feats_proj)
|
| 163 |
+
|
| 164 |
+
generated_ids = swin_t5_model.t5.generate(
|
| 165 |
+
encoder_outputs=encoder_outputs,
|
| 166 |
+
max_length=max_length,
|
| 167 |
+
num_beams=4,
|
| 168 |
+
early_stopping=True
|
| 169 |
+
)
|
| 170 |
+
report = swin_t5_tokenizer.decode(generated_ids[0], skip_special_tokens=True)
|
| 171 |
+
return report
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
print(f"Error during Swin-T5 report generation: {e}")
|
| 175 |
+
return f"Error generating report: {e}"
|
| 176 |
+
|
| 177 |
+
# --- Chat Function (Llama 3.1) ---
|
| 178 |
+
def generate_chat_response(question, report_context, max_new_tokens=250):
|
| 179 |
+
"""Generates a chat response using Llama based on the report context."""
|
| 180 |
+
global llama_model, llama_tokenizer
|
| 181 |
+
if not llama_model or not llama_tokenizer:
|
| 182 |
+
return "Chatbot is currently unavailable."
|
| 183 |
+
|
| 184 |
+
# System prompt to guide the LLM
|
| 185 |
+
system_prompt = "You are a helpful medical assistant. I'm a medical student, your task is to help me understand the following report."
|
| 186 |
+
# Construct the prompt using the chat template
|
| 187 |
+
messages = [
|
| 188 |
+
{"role": "system", "content": system_prompt},
|
| 189 |
+
{"role": "user", "content": f"Based on the following report:\n\n---\n{report_context}\n---\n\nPlease answer this question: {question}"}
|
| 190 |
+
]
|
| 191 |
+
|
| 192 |
+
# Prepare input for the model
|
| 193 |
+
try:
|
| 194 |
+
# Use the tokenizer's chat template
|
| 195 |
+
input_ids = llama_tokenizer.apply_chat_template(
|
| 196 |
+
messages,
|
| 197 |
+
add_generation_prompt=True,
|
| 198 |
+
return_tensors="pt"
|
| 199 |
+
).to(llama_model.device) # Move input IDs to the same device as the model
|
| 200 |
+
|
| 201 |
+
# Set terminators for generation
|
| 202 |
+
# Common terminators for Llama 3 Instruct
|
| 203 |
+
terminators = [
|
| 204 |
+
llama_tokenizer.eos_token_id,
|
| 205 |
+
llama_tokenizer.convert_tokens_to_ids("<|eot_id|>")
|
| 206 |
+
]
|
| 207 |
+
|
| 208 |
+
with torch.no_grad():
|
| 209 |
+
outputs = llama_model.generate(
|
| 210 |
+
input_ids,
|
| 211 |
+
max_new_tokens=max_new_tokens,
|
| 212 |
+
eos_token_id=terminators,
|
| 213 |
+
do_sample=True, # Use sampling for more natural responses
|
| 214 |
+
temperature=0.6,
|
| 215 |
+
top_p=0.9,
|
| 216 |
+
pad_token_id=llama_tokenizer.eos_token_id # Avoid warning, set pad_token_id
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
# Decode the response, skipping the input prompt part
|
| 220 |
+
response_ids = outputs[0][input_ids.shape[-1]:]
|
| 221 |
+
response_text = llama_tokenizer.decode(response_ids, skip_special_tokens=True)
|
| 222 |
+
return response_text.strip()
|
| 223 |
+
|
| 224 |
+
except Exception as e:
|
| 225 |
+
print(f"Error during Llama chat generation: {e}")
|
| 226 |
+
return f"Error generating chat response: {e}"
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# --- Flask Application Setup ---
|
| 230 |
+
app = Flask(__name__)
|
| 231 |
+
app.secret_key = os.urandom(24)
|
| 232 |
+
|
| 233 |
+
# Load models when the application starts
|
| 234 |
+
print("Loading models on application startup...")
|
| 235 |
+
try:
|
| 236 |
+
load_swin_t5_model_components()
|
| 237 |
+
load_llama_model_components() # Load Llama
|
| 238 |
+
print("Model loading complete.")
|
| 239 |
+
except Exception as e:
|
| 240 |
+
print(f"FATAL ERROR during model loading: {e}")
|
| 241 |
+
# Depending on requirements, you might want to exit or continue with limited functionality
|
| 242 |
+
# exit(1) # Example: Exit if models are critical
|
| 243 |
+
|
| 244 |
+
def allowed_file(filename):
|
| 245 |
+
return '.' in filename and \
|
| 246 |
+
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
| 247 |
+
|
| 248 |
+
# ---- NEW: Function to Parse Filename ----
|
| 249 |
+
def parse_patient_info(filename):
|
| 250 |
+
"""
|
| 251 |
+
Parses a filename like '00069-34-Frontal-AP-63.0-Male-White.png'
|
| 252 |
+
Returns a dictionary with 'view', 'age', 'gender', 'ethnicity'.
|
| 253 |
+
Returns None if parsing fails.
|
| 254 |
+
"""
|
| 255 |
+
try:
|
| 256 |
+
base_name = os.path.splitext(filename)[0]
|
| 257 |
+
parts = base_name.split('-')
|
| 258 |
+
# Expected structure based on example: ... - ViewPart1 - ViewPartN - Age - Gender - Ethnicity
|
| 259 |
+
if len(parts) < 5: # Need at least initial parts, age, gender, ethnicity
|
| 260 |
+
print(f"Warning: Filename '{filename}' has fewer parts than expected.")
|
| 261 |
+
return None
|
| 262 |
+
|
| 263 |
+
ethnicity = parts[-1]
|
| 264 |
+
gender = parts[-2]
|
| 265 |
+
age_str = parts[-3]
|
| 266 |
+
# Handle potential '.0' in age and convert to int
|
| 267 |
+
try:
|
| 268 |
+
age = int(float(age_str))
|
| 269 |
+
except ValueError:
|
| 270 |
+
print(f"Warning: Could not parse age '{age_str}' from filename '{filename}'.")
|
| 271 |
+
return None # Or set age to None/default
|
| 272 |
+
|
| 273 |
+
# Assume view is everything between the second part (index 1) and the age part (index -3)
|
| 274 |
+
view_parts = parts[2:-3]
|
| 275 |
+
view = '-'.join(view_parts) if view_parts else "Unknown" # Handle cases with missing view
|
| 276 |
+
|
| 277 |
+
# Basic validation
|
| 278 |
+
if gender.lower() not in ['male', 'female', 'other', 'unknown']: # Be flexible
|
| 279 |
+
print(f"Warning: Unusual gender '{gender}' found in filename '{filename}'.")
|
| 280 |
+
# Decide whether to return None or keep it
|
| 281 |
+
|
| 282 |
+
return {
|
| 283 |
+
'view': view,
|
| 284 |
+
'age': age,
|
| 285 |
+
'gender': gender.capitalize(), # Capitalize for display
|
| 286 |
+
'ethnicity': ethnicity.capitalize() # Capitalize for display
|
| 287 |
+
}
|
| 288 |
+
except IndexError:
|
| 289 |
+
print(f"Error parsing filename '{filename}': Index out of bounds.")
|
| 290 |
+
return None
|
| 291 |
+
except Exception as e:
|
| 292 |
+
print(f"Error parsing filename '{filename}': {e}")
|
| 293 |
+
return None
|
| 294 |
+
|
| 295 |
+
# --- Routes ---
|
| 296 |
+
|
| 297 |
+
@app.route('/', methods=['GET'])
|
| 298 |
+
def index():
|
| 299 |
+
"""Renders the main page."""
|
| 300 |
+
chatbot_available = bool(llama_model and llama_tokenizer)
|
| 301 |
+
return render_template('index.html', chatbot_available=chatbot_available)
|
| 302 |
+
|
| 303 |
+
@app.route('/predict', methods=['POST'])
|
| 304 |
+
def predict():
|
| 305 |
+
"""Handles image upload and prediction."""
|
| 306 |
+
chatbot_available = bool(llama_model and llama_tokenizer) # Check again
|
| 307 |
+
patient_info = None # Initialize patient_info
|
| 308 |
+
|
| 309 |
+
if 'image' not in request.files:
|
| 310 |
+
flash('No image file part in the request.', 'danger')
|
| 311 |
+
return redirect(url_for('index'))
|
| 312 |
+
|
| 313 |
+
file = request.files['image']
|
| 314 |
+
vlm_choice = request.form.get('vlm_choice', 'swin_t5_chexpert')
|
| 315 |
+
try:
|
| 316 |
+
max_length = int(request.form.get('max_length', 100))
|
| 317 |
+
if not (10 <= max_length <= 512):
|
| 318 |
+
raise ValueError("Max length must be between 10 and 512.")
|
| 319 |
+
except ValueError as e:
|
| 320 |
+
flash(f'Invalid Max Length value: {e}', 'danger')
|
| 321 |
+
return redirect(url_for('index'))
|
| 322 |
+
|
| 323 |
+
if file.filename == '':
|
| 324 |
+
flash('No image selected for uploading.', 'warning')
|
| 325 |
+
return redirect(url_for('index'))
|
| 326 |
+
|
| 327 |
+
if file and allowed_file(file.filename):
|
| 328 |
+
try:
|
| 329 |
+
image_bytes = file.read()
|
| 330 |
+
|
| 331 |
+
# ---- ADDED: Parse filename ----
|
| 332 |
+
original_filename = file.filename
|
| 333 |
+
patient_info = parse_patient_info(original_filename)
|
| 334 |
+
if patient_info:
|
| 335 |
+
print(f"Parsed Patient Info: {patient_info}")
|
| 336 |
+
else:
|
| 337 |
+
print(f"Could not parse patient info from filename: {original_filename}")
|
| 338 |
+
# ---- END ADDED ----
|
| 339 |
+
|
| 340 |
+
# Generate report using Swin-T5
|
| 341 |
+
report = generate_report(image_bytes, vlm_choice, max_length)
|
| 342 |
+
|
| 343 |
+
# Check for errors in report generation
|
| 344 |
+
if report.startswith("Error"):
|
| 345 |
+
flash(f'Report Generation Failed: {report}', 'danger')
|
| 346 |
+
# Still render with image if possible, but show error
|
| 347 |
+
image_data = base64.b64encode(image_bytes).decode('utf-8')
|
| 348 |
+
return render_template('index.html',
|
| 349 |
+
report=None, # Or pass the error message
|
| 350 |
+
image_data=image_data,
|
| 351 |
+
patient_info=patient_info, # Pass parsed info even if report failed
|
| 352 |
+
chatbot_available=chatbot_available)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
image_data = base64.b64encode(image_bytes).decode('utf-8')
|
| 356 |
+
|
| 357 |
+
# Render the page with results AND the report text for JS/Chat
|
| 358 |
+
return render_template('index.html',
|
| 359 |
+
report=report,
|
| 360 |
+
image_data=image_data,
|
| 361 |
+
patient_info=patient_info, # Pass the parsed info
|
| 362 |
+
chatbot_available=chatbot_available) # Pass availability again
|
| 363 |
+
|
| 364 |
+
except FileNotFoundError as fnf_error:
|
| 365 |
+
flash(f'Model file not found: {fnf_error}. Please check server configuration.', 'danger')
|
| 366 |
+
print(f"Model file error: {fnf_error}\n{traceback.format_exc()}")
|
| 367 |
+
return redirect(url_for('index'))
|
| 368 |
+
except RuntimeError as rt_error:
|
| 369 |
+
flash(f'Model loading error: {rt_error}. Please check server logs.', 'danger')
|
| 370 |
+
print(f"Runtime error during prediction (model loading?): {rt_error}\n{traceback.format_exc()}")
|
| 371 |
+
return redirect(url_for('index'))
|
| 372 |
+
except Exception as e:
|
| 373 |
+
flash(f'An unexpected error occurred during prediction: {e}', 'danger')
|
| 374 |
+
print(f"Error during prediction: {e}\n{traceback.format_exc()}")
|
| 375 |
+
return redirect(url_for('index'))
|
| 376 |
+
else:
|
| 377 |
+
flash('Invalid image file type. Allowed types: png, jpg, jpeg.', 'danger')
|
| 378 |
+
return redirect(url_for('index'))
|
| 379 |
+
|
| 380 |
+
# --- New Chat Endpoint ---
|
| 381 |
+
@app.route('/chat', methods=['POST'])
|
| 382 |
+
def chat():
|
| 383 |
+
"""Handles chat requests based on the generated report."""
|
| 384 |
+
if not llama_model or not llama_tokenizer:
|
| 385 |
+
return jsonify({"answer": "Chatbot is not available."}), 503 # Service unavailable
|
| 386 |
+
|
| 387 |
+
data = request.get_json()
|
| 388 |
+
if not data or 'question' not in data or 'report_context' not in data:
|
| 389 |
+
return jsonify({"error": "Missing question or report context"}), 400
|
| 390 |
+
|
| 391 |
+
question = data['question']
|
| 392 |
+
report_context = data['report_context']
|
| 393 |
+
|
| 394 |
+
try:
|
| 395 |
+
answer = generate_chat_response(question, report_context)
|
| 396 |
+
return jsonify({"answer": answer})
|
| 397 |
+
except Exception as e:
|
| 398 |
+
print(f"Error in /chat endpoint: {e}")
|
| 399 |
+
return jsonify({"error": "Failed to generate chat response"}), 500
|
| 400 |
+
|
| 401 |
+
if __name__ == '__main__':
|
| 402 |
+
# Make sure to set debug=False for production/sharing
|
| 403 |
+
app.run(host='0.0.0.0', port=5000, debug=False)
|
images/dark.png
ADDED
|
Git LFS Details
|
images/hindi.png
ADDED
|
Git LFS Details
|
images/light.png
ADDED
|
Git LFS Details
|
images/llm-1.png
ADDED
|
Git LFS Details
|
images/norwegian.png
ADDED
|
Git LFS Details
|
images/notes.png
ADDED
|
Git LFS Details
|
images/urdu.png
ADDED
|
Git LFS Details
|
images/zoom.png
ADDED
|
Git LFS Details
|
kill_port.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import subprocess
|
| 2 |
+
import os
|
| 3 |
+
import signal
|
| 4 |
+
|
| 5 |
+
def kill_port_linux(port):
|
| 6 |
+
try:
|
| 7 |
+
# Find processes using the port
|
| 8 |
+
result = subprocess.check_output(f'lsof -i :{port}', shell=True).decode()
|
| 9 |
+
lines = result.strip().split('\n')
|
| 10 |
+
for line in lines[1:]: # skip header line
|
| 11 |
+
parts = line.split()
|
| 12 |
+
pid = int(parts[1])
|
| 13 |
+
os.kill(pid, signal.SIGKILL)
|
| 14 |
+
print(f'Killed PID {pid} using port {port}')
|
| 15 |
+
except subprocess.CalledProcessError:
|
| 16 |
+
print(f'No process is using port {port}.')
|
| 17 |
+
except Exception as e:
|
| 18 |
+
print(f'Error: {e}')
|
| 19 |
+
|
| 20 |
+
if __name__ == '__main__':
|
| 21 |
+
kill_port_linux(5000)
|
notebooks/Data.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks/data_preparation.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks/fine-tuning-blip.ipynb
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:781b947380c5d0728c8b98e291074d40133f843fbb08db706884ce37add153e9
|
| 3 |
+
size 11496872
|
notebooks/swin-bart.ipynb
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b3a81106bb9a4bab05fae14934c820ca97edffbf53b41f82e105c614f43728af
|
| 3 |
+
size 12369262
|
notebooks/swin-bert.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask>=2.0
|
| 2 |
+
torch>=1.8
|
| 3 |
+
torchvision>=0.9
|
| 4 |
+
transformers>=4.10
|
| 5 |
+
Pillow>=8.0
|
| 6 |
+
sentencepiece>=0.1.90 # Often needed by T5Tokenizer
|
| 7 |
+
tqdm # Was used in original script, optional for webapp but good practice if model loading is slow
|
| 8 |
+
python-dotenv # To handle the HF token
|
static/style.css
ADDED
|
File without changes
|
templates/index.html
ADDED
|
@@ -0,0 +1,1466 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Chest X-ray Report Generation Application Via VLM and LLM</title>
|
| 7 |
+
<!-- Google Font: Roboto -->
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
| 11 |
+
<!-- Bootstrap CSS -->
|
| 12 |
+
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 13 |
+
<!-- Font Awesome for Icons -->
|
| 14 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css">
|
| 15 |
+
<style>
|
| 16 |
+
:root {
|
| 17 |
+
/* --- Light Mode Color Palette & Design Tokens --- */
|
| 18 |
+
--lm-primary-color: #0d6efd; /* Medical Blue */
|
| 19 |
+
--lm-primary-color-rgb: 13, 110, 253;
|
| 20 |
+
--lm-bg-primary: linear-gradient(135deg, #eef2f7 0%, #f0f5ff 100%);
|
| 21 |
+
--lm-bg-container: rgba(255, 255, 255, 0.85);
|
| 22 |
+
--lm-bg-content: #fdfdff; /* chat container, report box, form */
|
| 23 |
+
--lm-bg-input: #ffffff;
|
| 24 |
+
--lm-bg-user-msg: #dbeafe;
|
| 25 |
+
--lm-bg-bot-msg: #e9ecef;
|
| 26 |
+
--lm-bg-suggestion: #f0f2f5;
|
| 27 |
+
--lm-bg-suggestion-hover: #e2e6ea;
|
| 28 |
+
--lm-text-primary: #333;
|
| 29 |
+
--lm-text-secondary: #495057; /* dark-gray */
|
| 30 |
+
--lm-text-muted: #6c757d;
|
| 31 |
+
--lm-text-report: #212529;
|
| 32 |
+
--lm-border-primary: var(--lm-primary-color); /* Container border */
|
| 33 |
+
--lm-border-secondary: #dee2e6; /* medium-gray */
|
| 34 |
+
--lm-border-suggestion: #d5d9de;
|
| 35 |
+
--lm-border-suggestion-hover: #c8ced3;
|
| 36 |
+
--lm-shadow-soft: 0 4px 12px rgba(0, 0, 0, 0.08);
|
| 37 |
+
--lm-shadow-hover: 0 6px 16px rgba(0, 0, 0, 0.12);
|
| 38 |
+
--lm-shadow-inset: inset 0 1px 4px rgba(0,0,0,0.04);
|
| 39 |
+
--lm-highlight-finding: #dc3545; /* Red */
|
| 40 |
+
--lm-highlight-device: var(--lm-primary-color); /* Blue */
|
| 41 |
+
--lm-scrollbar-thumb: var(--lm-primary-color);
|
| 42 |
+
--lm-scrollbar-track: #f0f0f0;
|
| 43 |
+
|
| 44 |
+
/* --- Dark Mode Color Palette & Design Tokens --- */
|
| 45 |
+
--dm-primary-color: #e53935; /* Vivid Red */
|
| 46 |
+
--dm-primary-color-rgb: 229, 57, 53;
|
| 47 |
+
--dm-bg-primary: #1a1a1a; /* Very Dark Gray/Near Black */
|
| 48 |
+
--dm-bg-container: rgba(33, 33, 33, 0.9); /* Darker container with slight transparency */
|
| 49 |
+
--dm-bg-content: #2d2d2d; /* Chat container, report box, form */
|
| 50 |
+
--dm-bg-input: #3a3a3a;
|
| 51 |
+
--dm-bg-user-msg: #5e2828; /* Dark red background for user */
|
| 52 |
+
--dm-bg-bot-msg: #3f3f3f; /* Dark gray for bot */
|
| 53 |
+
--dm-bg-suggestion: #4a4a4a;
|
| 54 |
+
--dm-bg-suggestion-hover: #5a5a5a;
|
| 55 |
+
--dm-text-primary: #f5f5f5; /* Off-white */
|
| 56 |
+
--dm-text-secondary: #bdbdbd; /* Lighter Gray */
|
| 57 |
+
--dm-text-muted: #9e9e9e;
|
| 58 |
+
--dm-text-report: #f5f5f5;
|
| 59 |
+
--dm-text-input-placeholder: #a0a0a0;
|
| 60 |
+
--dm-border-primary: var(--dm-primary-color); /* Container border */
|
| 61 |
+
--dm-border-secondary: #555555; /* Darker Gray Border */
|
| 62 |
+
--dm-border-suggestion: #666666;
|
| 63 |
+
--dm-border-suggestion-hover: #777777;
|
| 64 |
+
--dm-shadow-soft: 0 4px 12px rgba(0, 0, 0, 0.4);
|
| 65 |
+
--dm-shadow-hover: 0 6px 16px rgba(0, 0, 0, 0.5);
|
| 66 |
+
--dm-shadow-inset: inset 0 1px 4px rgba(0,0,0,0.2);
|
| 67 |
+
--dm-highlight-finding: #ff7961; /* Lighter Red for contrast */
|
| 68 |
+
--dm-highlight-device: #6ec6ff; /* Lighter Blue for contrast */
|
| 69 |
+
--dm-scrollbar-thumb: var(--dm-primary-color);
|
| 70 |
+
--dm-scrollbar-track: #424242;
|
| 71 |
+
|
| 72 |
+
/* --- Universal Tokens --- */
|
| 73 |
+
--border-radius-sm: 6px;
|
| 74 |
+
--border-radius-md: 8px;
|
| 75 |
+
--border-radius-lg: 12px;
|
| 76 |
+
--transition-speed: 0.3s; /* Slightly faster transition */
|
| 77 |
+
--transition-easing: ease; /* Use standard ease */
|
| 78 |
+
|
| 79 |
+
/* --- Apply Light Mode by Default --- */
|
| 80 |
+
--primary-color: var(--lm-primary-color);
|
| 81 |
+
--primary-color-rgb: var(--lm-primary-color-rgb);
|
| 82 |
+
--bg-primary: var(--lm-bg-primary);
|
| 83 |
+
--bg-container: var(--lm-bg-container);
|
| 84 |
+
--bg-content: var(--lm-bg-content);
|
| 85 |
+
--bg-input: var(--lm-bg-input);
|
| 86 |
+
--bg-user-msg: var(--lm-bg-user-msg);
|
| 87 |
+
--bg-bot-msg: var(--lm-bg-bot-msg);
|
| 88 |
+
--bg-suggestion: var(--lm-bg-suggestion);
|
| 89 |
+
--bg-suggestion-hover: var(--lm-bg-suggestion-hover);
|
| 90 |
+
--text-primary: var(--lm-text-primary);
|
| 91 |
+
--text-secondary: var(--lm-text-secondary);
|
| 92 |
+
--text-muted: var(--lm-text-muted);
|
| 93 |
+
--text-report: var(--lm-text-report);
|
| 94 |
+
--text-input-placeholder: inherit; /* Use default */
|
| 95 |
+
--border-primary: var(--lm-border-primary);
|
| 96 |
+
--border-secondary: var(--lm-border-secondary);
|
| 97 |
+
--border-suggestion: var(--lm-border-suggestion);
|
| 98 |
+
--border-suggestion-hover: var(--lm-border-suggestion-hover);
|
| 99 |
+
--shadow-soft: var(--lm-shadow-soft);
|
| 100 |
+
--shadow-hover: var(--lm-shadow-hover);
|
| 101 |
+
--shadow-inset: var(--lm-shadow-inset);
|
| 102 |
+
--highlight-finding-color: var(--lm-highlight-finding);
|
| 103 |
+
--highlight-device-color: var(--lm-highlight-device);
|
| 104 |
+
--scrollbar-thumb: var(--lm-scrollbar-thumb);
|
| 105 |
+
--scrollbar-track: var(--lm-scrollbar-track);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/* --- Apply Dark Mode Variables --- */
|
| 109 |
+
body.dark-mode {
|
| 110 |
+
--primary-color: var(--dm-primary-color);
|
| 111 |
+
--primary-color-rgb: var(--dm-primary-color-rgb);
|
| 112 |
+
--bg-primary: var(--dm-bg-primary);
|
| 113 |
+
--bg-container: var(--dm-bg-container);
|
| 114 |
+
--bg-content: var(--dm-bg-content);
|
| 115 |
+
--bg-input: var(--dm-bg-input);
|
| 116 |
+
--bg-user-msg: var(--dm-bg-user-msg);
|
| 117 |
+
--bg-bot-msg: var(--dm-bg-bot-msg);
|
| 118 |
+
--bg-suggestion: var(--dm-bg-suggestion);
|
| 119 |
+
--bg-suggestion-hover: var(--dm-bg-suggestion-hover);
|
| 120 |
+
--text-primary: var(--dm-text-primary);
|
| 121 |
+
--text-secondary: var(--dm-text-secondary);
|
| 122 |
+
--text-muted: var(--dm-text-muted);
|
| 123 |
+
--text-report: var(--dm-text-report);
|
| 124 |
+
--text-input-placeholder: var(--dm-text-input-placeholder);
|
| 125 |
+
--border-primary: var(--dm-border-primary);
|
| 126 |
+
--border-secondary: var(--dm-border-secondary);
|
| 127 |
+
--border-suggestion: var(--dm-border-suggestion);
|
| 128 |
+
--border-suggestion-hover: var(--dm-border-suggestion-hover);
|
| 129 |
+
--shadow-soft: var(--dm-shadow-soft);
|
| 130 |
+
--shadow-hover: var(--dm-shadow-hover);
|
| 131 |
+
--shadow-inset: var(--dm-shadow-inset);
|
| 132 |
+
--highlight-finding-color: var(--dm-highlight-finding);
|
| 133 |
+
--highlight-device-color: var(--dm-highlight-device);
|
| 134 |
+
--scrollbar-thumb: var(--dm-scrollbar-thumb);
|
| 135 |
+
--scrollbar-track: var(--dm-scrollbar-track);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/* --- General Body & Container Styling --- */
|
| 139 |
+
body {
|
| 140 |
+
padding-top: 20px;
|
| 141 |
+
padding-bottom: 30px;
|
| 142 |
+
background: var(--bg-primary);
|
| 143 |
+
color: var(--text-primary);
|
| 144 |
+
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
| 145 |
+
overflow-x: hidden;
|
| 146 |
+
transition: background-color var(--transition-speed) var(--transition-easing), color var(--transition-speed) var(--transition-easing);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.container-fluid {
|
| 150 |
+
max-width: 1400px;
|
| 151 |
+
background-color: var(--bg-container);
|
| 152 |
+
backdrop-filter: blur(10px);
|
| 153 |
+
-webkit-backdrop-filter: blur(10px);
|
| 154 |
+
padding: 35px;
|
| 155 |
+
border-radius: var(--border-radius-lg);
|
| 156 |
+
box-shadow: var(--shadow-soft);
|
| 157 |
+
border: 3px solid var(--border-primary);
|
| 158 |
+
transition: background-color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing), box-shadow var(--transition-speed) var(--transition-easing);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/* Dark Mode Toggle Button Style */
|
| 162 |
+
#darkModeToggle {
|
| 163 |
+
border: 1px solid var(--border-secondary); /* Use secondary border */
|
| 164 |
+
color: var(--text-secondary);
|
| 165 |
+
background-color: transparent; /* Start transparent */
|
| 166 |
+
width: 40px;
|
| 167 |
+
height: 40px;
|
| 168 |
+
display: flex;
|
| 169 |
+
align-items: center;
|
| 170 |
+
justify-content: center;
|
| 171 |
+
padding: 0;
|
| 172 |
+
border-radius: 50%;
|
| 173 |
+
transition: background-color var(--transition-speed) var(--transition-easing), color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing), transform 0.2s ease;
|
| 174 |
+
}
|
| 175 |
+
#darkModeToggle:hover {
|
| 176 |
+
background-color: rgba(var(--primary-color-rgb), 0.1);
|
| 177 |
+
border-color: var(--primary-color);
|
| 178 |
+
color: var(--primary-color);
|
| 179 |
+
transform: scale(1.05);
|
| 180 |
+
}
|
| 181 |
+
body.dark-mode #darkModeToggle {
|
| 182 |
+
border-color: var(--dm-border-secondary); /* Dark mode border */
|
| 183 |
+
color: var(--dm-text-secondary); /* Dark mode text */
|
| 184 |
+
background-color: #444; /* Slightly lighter dark */
|
| 185 |
+
}
|
| 186 |
+
body.dark-mode #darkModeToggle:hover {
|
| 187 |
+
background-color: #555;
|
| 188 |
+
border-color: var(--dm-primary-color);
|
| 189 |
+
color: var(--dm-primary-color);
|
| 190 |
+
transform: scale(1.05);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
h1 {
|
| 194 |
+
color: var(--primary-color);
|
| 195 |
+
font-weight: 700;
|
| 196 |
+
margin-bottom: 0 !important; /* Removed margin as it's handled by the wrapper now */
|
| 197 |
+
font-size: 2.1rem;
|
| 198 |
+
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.05);
|
| 199 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 200 |
+
}
|
| 201 |
+
body.dark-mode h1 {
|
| 202 |
+
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
h2 {
|
| 206 |
+
font-size: 1.5rem;
|
| 207 |
+
font-weight: 500;
|
| 208 |
+
color: var(--text-secondary);
|
| 209 |
+
margin-bottom: 20px;
|
| 210 |
+
padding-bottom: 10px;
|
| 211 |
+
border-bottom: 3px solid var(--primary-color);
|
| 212 |
+
display: inline-block;
|
| 213 |
+
transition: color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/* --- Main Layout --- */
|
| 217 |
+
.main-row {
|
| 218 |
+
margin-top: 30px;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/* --- Chat Interface Section (Left Column) --- */
|
| 222 |
+
#chat-column {
|
| 223 |
+
border-right: 1px solid var(--border-secondary);
|
| 224 |
+
padding-right: 35px;
|
| 225 |
+
display: flex;
|
| 226 |
+
flex-direction: column;
|
| 227 |
+
height: calc(85vh - 110px);
|
| 228 |
+
min-height: 500px;
|
| 229 |
+
transition: opacity 0.5s ease, filter 0.5s ease, border-color var(--transition-speed) var(--transition-easing);
|
| 230 |
+
}
|
| 231 |
+
#chat-container {
|
| 232 |
+
flex-grow: 1;
|
| 233 |
+
display: flex;
|
| 234 |
+
flex-direction: column;
|
| 235 |
+
background-color: var(--bg-content);
|
| 236 |
+
border: 1px solid var(--border-secondary);
|
| 237 |
+
border-radius: var(--border-radius-md);
|
| 238 |
+
padding: 20px;
|
| 239 |
+
overflow: hidden;
|
| 240 |
+
box-shadow: var(--shadow-inset);
|
| 241 |
+
transition: background-color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing);
|
| 242 |
+
}
|
| 243 |
+
#chat-column.disabled {
|
| 244 |
+
opacity: 0.5;
|
| 245 |
+
filter: grayscale(50%);
|
| 246 |
+
pointer-events: none;
|
| 247 |
+
}
|
| 248 |
+
body.dark-mode #chat-column.disabled {
|
| 249 |
+
filter: grayscale(70%); /* Increase grayscale for dark */
|
| 250 |
+
}
|
| 251 |
+
#chat-column.disabled #chat-placeholder { display: block; }
|
| 252 |
+
#chat-placeholder {
|
| 253 |
+
display: none;
|
| 254 |
+
text-align: center; padding: 60px 15px;
|
| 255 |
+
color: var(--text-muted); font-style: italic; font-size: 0.9rem;
|
| 256 |
+
align-self: center; margin-top: auto; margin-bottom: auto;
|
| 257 |
+
}
|
| 258 |
+
#chat-messages {
|
| 259 |
+
flex-grow: 1; overflow-y: auto; margin-bottom: 15px; padding-right: 10px;
|
| 260 |
+
scrollbar-width: thin;
|
| 261 |
+
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
| 262 |
+
}
|
| 263 |
+
#chat-messages::-webkit-scrollbar { width: 6px; }
|
| 264 |
+
#chat-messages::-webkit-scrollbar-track { background: var(--scrollbar-track); border-radius: 3px;}
|
| 265 |
+
#chat-messages::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb); border-radius: 3px; border: 1px solid var(--scrollbar-track);}
|
| 266 |
+
|
| 267 |
+
.chat-message {
|
| 268 |
+
margin-bottom: 15px; padding: 12px 18px;
|
| 269 |
+
border-radius: var(--border-radius-lg); max-width: 85%;
|
| 270 |
+
word-wrap: break-word; line-height: 1.55;
|
| 271 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.06);
|
| 272 |
+
opacity: 0;
|
| 273 |
+
transform: translateY(10px);
|
| 274 |
+
animation: fadeSlideIn 0.4s ease forwards;
|
| 275 |
+
color: var(--text-primary); /* Default text color for messages */
|
| 276 |
+
transition: background-color var(--transition-speed) var(--transition-easing), color var(--transition-speed) var(--transition-easing);
|
| 277 |
+
}
|
| 278 |
+
@keyframes fadeSlideIn { to { opacity: 1; transform: translateY(0); } }
|
| 279 |
+
|
| 280 |
+
.user-message {
|
| 281 |
+
background-color: var(--bg-user-msg); margin-left: auto;
|
| 282 |
+
border-bottom-right-radius: var(--border-radius-sm); text-align: right;
|
| 283 |
+
}
|
| 284 |
+
.bot-message {
|
| 285 |
+
background-color: var(--bg-bot-msg); margin-right: auto;
|
| 286 |
+
border-bottom-left-radius: var(--border-radius-sm); text-align: left;
|
| 287 |
+
}
|
| 288 |
+
.bot-message.thinking {
|
| 289 |
+
font-style: italic; color: var(--text-muted); display: flex; align-items: center;
|
| 290 |
+
}
|
| 291 |
+
.bot-message.thinking::before {
|
| 292 |
+
content: ''; display: inline-block; width: 16px; height: 16px;
|
| 293 |
+
border: 2px solid var(--primary-color); border-top-color: transparent;
|
| 294 |
+
border-radius: 50%; animation: spin 1s linear infinite; margin-right: 8px;
|
| 295 |
+
}
|
| 296 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 297 |
+
body.dark-mode .chat-message { /* Ensure text is light in dark mode */
|
| 298 |
+
color: var(--dm-text-primary);
|
| 299 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
#chat-form { display: flex; margin-top: auto; gap: 10px; }
|
| 304 |
+
#chat-input {
|
| 305 |
+
flex-grow: 1; border-radius: 25px; padding: 10px 18px;
|
| 306 |
+
border: 1px solid var(--border-secondary);
|
| 307 |
+
background-color: var(--bg-input);
|
| 308 |
+
color: var(--text-primary);
|
| 309 |
+
transition: box-shadow var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing), background-color var(--transition-speed) var(--transition-easing), color var(--transition-speed) var(--transition-easing);
|
| 310 |
+
}
|
| 311 |
+
#chat-input:focus {
|
| 312 |
+
box-shadow: 0 0 0 0.2rem rgba(var(--primary-color-rgb), 0.25);
|
| 313 |
+
border-color: var(--primary-color);
|
| 314 |
+
}
|
| 315 |
+
#chat-input::placeholder {
|
| 316 |
+
color: var(--text-input-placeholder);
|
| 317 |
+
opacity: 1;
|
| 318 |
+
}
|
| 319 |
+
body.dark-mode #chat-input {
|
| 320 |
+
box-shadow: inset 0 1px 3px rgba(0,0,0,0.3);
|
| 321 |
+
}
|
| 322 |
+
body.dark-mode #chat-input:focus {
|
| 323 |
+
background-color: #4f4f4f;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
#send-button {
|
| 327 |
+
border-radius: 50%; width: 45px; height: 45px; padding: 0;
|
| 328 |
+
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
| 329 |
+
transition: background-color var(--transition-speed) var(--transition-easing), transform var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing), filter var(--transition-speed) var(--transition-easing);
|
| 330 |
+
background-color: var(--primary-color);
|
| 331 |
+
border-color: var(--primary-color);
|
| 332 |
+
color: white;
|
| 333 |
+
}
|
| 334 |
+
#send-button:hover {
|
| 335 |
+
transform: scale(1.1);
|
| 336 |
+
filter: brightness(115%); /* Use brightness for hover */
|
| 337 |
+
/* Ensure base color doesn't change on hover */
|
| 338 |
+
background-color: var(--primary-color);
|
| 339 |
+
border-color: var(--primary-color);
|
| 340 |
+
}
|
| 341 |
+
#send-button svg { width: 20px; height: 20px; fill: white; }
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
#example-questions {
|
| 345 |
+
margin-top: 12px;
|
| 346 |
+
padding-top: 10px;
|
| 347 |
+
border-top: 1px dashed var(--border-secondary);
|
| 348 |
+
text-align: left;
|
| 349 |
+
transition: border-color var(--transition-speed) var(--transition-easing);
|
| 350 |
+
}
|
| 351 |
+
#example-questions small {
|
| 352 |
+
display: block;
|
| 353 |
+
margin-bottom: 8px;
|
| 354 |
+
font-size: 0.8rem;
|
| 355 |
+
color: var(--text-secondary);
|
| 356 |
+
font-weight: 500;
|
| 357 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 358 |
+
}
|
| 359 |
+
.suggestion-btn {
|
| 360 |
+
background-color: var(--bg-suggestion);
|
| 361 |
+
border: 1px solid var(--border-suggestion);
|
| 362 |
+
color: var(--primary-color);
|
| 363 |
+
font-size: 0.75rem;
|
| 364 |
+
padding: 4px 10px;
|
| 365 |
+
border-radius: 15px;
|
| 366 |
+
margin-right: 6px;
|
| 367 |
+
margin-bottom: 6px;
|
| 368 |
+
cursor: pointer;
|
| 369 |
+
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, filter 0.2s ease;
|
| 370 |
+
text-decoration: none;
|
| 371 |
+
display: inline-block;
|
| 372 |
+
}
|
| 373 |
+
.suggestion-btn:hover {
|
| 374 |
+
background-color: var(--bg-suggestion-hover);
|
| 375 |
+
border-color: var(--border-suggestion-hover);
|
| 376 |
+
filter: brightness(110%);
|
| 377 |
+
/* Keep text color same unless specific dark mode override */
|
| 378 |
+
color: var(--primary-color);
|
| 379 |
+
}
|
| 380 |
+
body.dark-mode .suggestion-btn {
|
| 381 |
+
color: var(--dm-text-primary); /* Make text light */
|
| 382 |
+
background-color: var(--dm-bg-suggestion);
|
| 383 |
+
border-color: var(--dm-border-suggestion);
|
| 384 |
+
}
|
| 385 |
+
body.dark-mode .suggestion-btn:hover {
|
| 386 |
+
color: var(--dm-primary-color); /* Red hover text */
|
| 387 |
+
background-color: var(--dm-bg-suggestion-hover);
|
| 388 |
+
border-color: var(--dm-border-suggestion-hover);
|
| 389 |
+
filter: brightness(115%);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
/* --- Content Section (Right Column) --- */
|
| 394 |
+
#content-column {
|
| 395 |
+
padding-left: 35px;
|
| 396 |
+
max-height: calc(85vh - 110px);
|
| 397 |
+
overflow-y: auto; scrollbar-width: thin;
|
| 398 |
+
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
| 399 |
+
position: relative;
|
| 400 |
+
}
|
| 401 |
+
#content-column::-webkit-scrollbar { width: 6px; }
|
| 402 |
+
#content-column::-webkit-scrollbar-track { background: var(--scrollbar-track); border-radius: 3px;}
|
| 403 |
+
#content-column::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb); border-radius: 3px; border: 1px solid var(--scrollbar-track);}
|
| 404 |
+
|
| 405 |
+
/* --- Upload Form Styling & Animation --- */
|
| 406 |
+
#form-wrapper {
|
| 407 |
+
transition: opacity var(--transition-speed) var(--transition-easing),
|
| 408 |
+
transform var(--transition-speed) var(--transition-easing),
|
| 409 |
+
max-height 0.6s var(--transition-easing),
|
| 410 |
+
margin-bottom var(--transition-speed) var(--transition-easing),
|
| 411 |
+
padding-top var(--transition-speed) var(--transition-easing),
|
| 412 |
+
padding-bottom var(--transition-speed) var(--transition-easing),
|
| 413 |
+
border var(--transition-speed) var(--transition-easing);
|
| 414 |
+
overflow: hidden; max-height: 800px; transform: translateY(0);
|
| 415 |
+
opacity: 1; margin-bottom: 30px; padding-top: 0; padding-bottom: 0; border: none;
|
| 416 |
+
}
|
| 417 |
+
#upload-form {
|
| 418 |
+
padding: 30px;
|
| 419 |
+
border: 1px solid var(--border-secondary);
|
| 420 |
+
border-radius: var(--border-radius-md);
|
| 421 |
+
background-color: var(--bg-content); margin-bottom: 0;
|
| 422 |
+
transition: background-color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing);
|
| 423 |
+
}
|
| 424 |
+
.form-label {
|
| 425 |
+
font-weight: 500; margin-bottom: 0.6rem;
|
| 426 |
+
color: var(--text-secondary);
|
| 427 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 428 |
+
}
|
| 429 |
+
.form-control, .form-select {
|
| 430 |
+
background-color: var(--bg-input);
|
| 431 |
+
color: var(--text-primary);
|
| 432 |
+
border: 1px solid var(--border-secondary);
|
| 433 |
+
transition: background-color var(--transition-speed) var(--transition-easing), color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing), box-shadow var(--transition-speed) var(--transition-easing);
|
| 434 |
+
}
|
| 435 |
+
.form-control::placeholder {
|
| 436 |
+
color: var(--text-input-placeholder);
|
| 437 |
+
opacity: 1;
|
| 438 |
+
}
|
| 439 |
+
.form-control:focus, .form-select:focus {
|
| 440 |
+
background-color: var(--bg-input);
|
| 441 |
+
color: var(--text-primary);
|
| 442 |
+
border-color: var(--primary-color);
|
| 443 |
+
box-shadow: 0 0 0 0.2rem rgba(var(--primary-color-rgb), 0.25);
|
| 444 |
+
}
|
| 445 |
+
body.dark-mode .form-control, body.dark-mode .form-select {
|
| 446 |
+
box-shadow: inset 0 1px 3px rgba(0,0,0,0.3);
|
| 447 |
+
}
|
| 448 |
+
body.dark-mode .form-select {
|
| 449 |
+
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23f5f5f5' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
|
| 450 |
+
}
|
| 451 |
+
body.dark-mode .form-control:focus, body.dark-mode .form-select:focus {
|
| 452 |
+
background-color: #4f4f4f; /* Darker focus for inputs */
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.form-control-sm, .form-select-sm {
|
| 456 |
+
padding: 0.45rem 0.9rem; font-size: 0.9rem;
|
| 457 |
+
border-radius: var(--border-radius-sm);
|
| 458 |
+
}
|
| 459 |
+
.form-text {
|
| 460 |
+
color: var(--text-muted);
|
| 461 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
/* --- Primary Button Styling (Generate Report Button) --- */
|
| 465 |
+
.btn-primary {
|
| 466 |
+
background-color: var(--primary-color);
|
| 467 |
+
border-color: var(--primary-color);
|
| 468 |
+
padding: 12px 22px;
|
| 469 |
+
font-size: 1rem; font-weight: 500;
|
| 470 |
+
border-radius: var(--border-radius-sm);
|
| 471 |
+
color: #fff; /* Ensure text is white */
|
| 472 |
+
transition: background-color var(--transition-speed) var(--transition-easing),
|
| 473 |
+
border-color var(--transition-speed) var(--transition-easing),
|
| 474 |
+
box-shadow var(--transition-speed) var(--transition-easing),
|
| 475 |
+
filter var(--transition-speed) var(--transition-easing),
|
| 476 |
+
color var(--transition-speed) var(--transition-easing),
|
| 477 |
+
opacity var(--transition-speed) var(--transition-easing);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
/* Generic Hover/Focus/Active - Use Brightness */
|
| 481 |
+
.btn-primary:hover {
|
| 482 |
+
filter: brightness(115%);
|
| 483 |
+
/* Explicitly set colors to avoid inheriting Bootstrap's blue */
|
| 484 |
+
background-color: var(--primary-color);
|
| 485 |
+
border-color: var(--primary-color);
|
| 486 |
+
color: #fff;
|
| 487 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
| 488 |
+
}
|
| 489 |
+
.btn-primary:focus,
|
| 490 |
+
.btn-primary:active {
|
| 491 |
+
filter: brightness(110%);
|
| 492 |
+
background-color: var(--primary-color);
|
| 493 |
+
border-color: var(--primary-color);
|
| 494 |
+
color: #fff;
|
| 495 |
+
box-shadow: 0 0 0 0.2rem rgba(var(--primary-color-rgb), 0.5), 0 4px 8px rgba(0, 0, 0, 0.1);
|
| 496 |
+
}
|
| 497 |
+
.btn-primary:disabled,
|
| 498 |
+
.btn-primary.disabled {
|
| 499 |
+
filter: none;
|
| 500 |
+
background-color: var(--primary-color);
|
| 501 |
+
border-color: var(--primary-color);
|
| 502 |
+
opacity: 0.65;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
/* Specific Dark Mode Overrides for btn-primary */
|
| 506 |
+
body.dark-mode .btn-primary {
|
| 507 |
+
/* Base state already covered by variable swap, but can reiterate if needed */
|
| 508 |
+
background-color: var(--dm-primary-color);
|
| 509 |
+
border-color: var(--dm-primary-color);
|
| 510 |
+
color: #fff;
|
| 511 |
+
}
|
| 512 |
+
body.dark-mode .btn-primary:hover {
|
| 513 |
+
filter: brightness(120%); /* Slightly more brightness for dark */
|
| 514 |
+
background-color: var(--dm-primary-color); /* Keep red */
|
| 515 |
+
border-color: var(--dm-primary-color); /* Keep red */
|
| 516 |
+
color: #fff; /* Keep white */
|
| 517 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); /* Darker shadow */
|
| 518 |
+
}
|
| 519 |
+
body.dark-mode .btn-primary:focus,
|
| 520 |
+
body.dark-mode .btn-primary:active {
|
| 521 |
+
filter: brightness(110%); /* Keep red */
|
| 522 |
+
background-color: var(--dm-primary-color); /* Keep red */
|
| 523 |
+
border-color: var(--dm-primary-color); /* Keep red */
|
| 524 |
+
color: #fff; /* Keep white */
|
| 525 |
+
/* Darker focus ring and shadow */
|
| 526 |
+
box-shadow: 0 0 0 0.2rem rgba(var(--dm-primary-color-rgb), 0.5), 0 4px 8px rgba(0, 0, 0, 0.3);
|
| 527 |
+
}
|
| 528 |
+
body.dark-mode .btn-primary:disabled,
|
| 529 |
+
body.dark-mode .btn-primary.disabled {
|
| 530 |
+
filter: none;
|
| 531 |
+
background-color: var(--dm-primary-color); /* Keep red */
|
| 532 |
+
border-color: var(--dm-primary-color); /* Keep red */
|
| 533 |
+
opacity: 0.5; /* Maybe slightly more transparent in dark mode */
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
.spinner-border { display: none; margin-right: 8px; width: 1.1rem; height: 1.1rem; color: currentColor; }
|
| 537 |
+
#upload-form.processing .spinner-border { display: inline-block; }
|
| 538 |
+
#upload-form.processing button[type="submit"] { cursor: not-allowed; opacity: 0.7; } /* Keep generic processing style */
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
/* --- State When Results are Loaded --- */
|
| 542 |
+
body.results-loaded #form-wrapper {
|
| 543 |
+
opacity: 0; transform: translateY(-20px) scale(0.95);
|
| 544 |
+
max-height: 0; padding-top: 0; padding-bottom: 0;
|
| 545 |
+
margin-top: 0; margin-bottom: 0; border-width: 0;
|
| 546 |
+
pointer-events: none;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
/* --- Result Area Styling & Animation --- */
|
| 550 |
+
.result-area {
|
| 551 |
+
margin-top: 0;
|
| 552 |
+
opacity: 0;
|
| 553 |
+
transform: translateY(20px);
|
| 554 |
+
transition: opacity 0.6s ease-out 0.2s, transform 0.6s ease-out 0.2s;
|
| 555 |
+
text-align: center;
|
| 556 |
+
}
|
| 557 |
+
body.results-loaded .result-area {
|
| 558 |
+
opacity: 1;
|
| 559 |
+
transform: translateY(0);
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
/* --- Patient Info Section Styling --- */
|
| 563 |
+
#patient-info-section {
|
| 564 |
+
text-align: center;
|
| 565 |
+
margin-bottom: 25px;
|
| 566 |
+
padding-bottom: 15px;
|
| 567 |
+
border-bottom: 1px solid var(--border-secondary);
|
| 568 |
+
transition: border-color var(--transition-speed) var(--transition-easing);
|
| 569 |
+
}
|
| 570 |
+
#patient-info-section h3 {
|
| 571 |
+
font-size: 1.25rem;
|
| 572 |
+
font-weight: 500;
|
| 573 |
+
color: var(--text-secondary);
|
| 574 |
+
margin-bottom: 10px;
|
| 575 |
+
border-bottom: none;
|
| 576 |
+
display: inline-block;
|
| 577 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 578 |
+
}
|
| 579 |
+
#patient-info-data {
|
| 580 |
+
font-size: 0.95rem;
|
| 581 |
+
color: var(--text-primary);
|
| 582 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 583 |
+
}
|
| 584 |
+
#patient-info-data span {
|
| 585 |
+
margin: 0 8px;
|
| 586 |
+
color: var(--text-muted);
|
| 587 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 588 |
+
}
|
| 589 |
+
#patient-info-data strong {
|
| 590 |
+
font-weight: 500;
|
| 591 |
+
color: var(--text-secondary);
|
| 592 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
/* Adjust result columns */
|
| 596 |
+
.result-area .row .col-md-6 {
|
| 597 |
+
display: flex;
|
| 598 |
+
flex-direction: column;
|
| 599 |
+
align-items: center;
|
| 600 |
+
margin-bottom: 20px;
|
| 601 |
+
}
|
| 602 |
+
.result-area .row .col-md-6 h2 {
|
| 603 |
+
width: 100%;
|
| 604 |
+
text-align: center;
|
| 605 |
+
}
|
| 606 |
+
.img-preview-container, .report-box-container {
|
| 607 |
+
width: 100%;
|
| 608 |
+
max-width: 500px;
|
| 609 |
+
margin: 0 auto;
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
/* --- Image Preview & Zoom Styling --- */
|
| 614 |
+
#image-result-area { position: relative; width: 100%; }
|
| 615 |
+
.img-preview {
|
| 616 |
+
max-width: 100%; height: auto; border: 1px solid var(--border-secondary);
|
| 617 |
+
border-radius: var(--border-radius-md); box-shadow: var(--shadow-soft);
|
| 618 |
+
display: block;
|
| 619 |
+
margin: 0 auto;
|
| 620 |
+
transition: box-shadow var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing);
|
| 621 |
+
cursor: crosshair;
|
| 622 |
+
}
|
| 623 |
+
.img-preview:hover { box-shadow: var(--shadow-hover); }
|
| 624 |
+
|
| 625 |
+
#zoom-preview-panel {
|
| 626 |
+
position: absolute; width: 250px; height: 250px;
|
| 627 |
+
background-color: var(--bg-content);
|
| 628 |
+
border: 2px solid var(--primary-color);
|
| 629 |
+
border-radius: var(--border-radius-md); box-shadow: var(--shadow-hover);
|
| 630 |
+
background-repeat: no-repeat; background-size: 0 0; background-position: 0 0;
|
| 631 |
+
opacity: 0; visibility: hidden;
|
| 632 |
+
transition: opacity 0.2s ease-out, visibility 0s linear 0.2s, background-color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing);
|
| 633 |
+
pointer-events: none; z-index: 100; overflow: hidden;
|
| 634 |
+
top: 0; left: calc(100% + 15px);
|
| 635 |
+
}
|
| 636 |
+
/* Adjust zoom panel side */
|
| 637 |
+
@media (max-width: 1200px) and (min-width: 992px) {
|
| 638 |
+
#zoom-preview-panel { left: auto; right: calc(100% + 15px); }
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
/* --- Zoom Slider Controls Styling --- */
|
| 642 |
+
#zoom-controls {
|
| 643 |
+
max-width: 300px;
|
| 644 |
+
margin-left: auto;
|
| 645 |
+
margin-right: auto;
|
| 646 |
+
opacity: 0;
|
| 647 |
+
transition: opacity 0.5s ease-in-out;
|
| 648 |
+
display: none;
|
| 649 |
+
}
|
| 650 |
+
body.results-loaded #zoom-controls {
|
| 651 |
+
display: block;
|
| 652 |
+
opacity: 1;
|
| 653 |
+
}
|
| 654 |
+
#zoom-controls .form-label-sm {
|
| 655 |
+
font-size: 0.8rem;
|
| 656 |
+
color: var(--text-secondary);
|
| 657 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 658 |
+
}
|
| 659 |
+
#zoom-slider {
|
| 660 |
+
cursor: pointer;
|
| 661 |
+
/* Style the range input */
|
| 662 |
+
accent-color: var(--primary-color); /* Modern way to color thumb/track */
|
| 663 |
+
transition: accent-color var(--transition-speed) var(--transition-easing);
|
| 664 |
+
}
|
| 665 |
+
/* Fallback/more specific styling if needed */
|
| 666 |
+
body.dark-mode #zoom-slider::-webkit-slider-runnable-track { background-color: #555; }
|
| 667 |
+
body.dark-mode #zoom-slider::-moz-range-track { background-color: #555; }
|
| 668 |
+
/* Thumb is handled by accent-color */
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
/* Hide zoom panel & slider on medium/small screens */
|
| 672 |
+
@media (max-width: 991.98px) {
|
| 673 |
+
#zoom-preview-panel, #zoom-controls {
|
| 674 |
+
display: none !important;
|
| 675 |
+
}
|
| 676 |
+
.img-preview { cursor: default; }
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
/* --- Report Box Styling --- */
|
| 680 |
+
.report-box {
|
| 681 |
+
border: 1px solid var(--border-secondary); padding: 25px;
|
| 682 |
+
background-color: var(--bg-content); border-radius: var(--border-radius-md);
|
| 683 |
+
min-height: 250px;
|
| 684 |
+
font-family: 'Roboto', 'Consolas', 'Courier New', monospace;
|
| 685 |
+
font-size: 0.95rem;
|
| 686 |
+
color: var(--text-report); white-space: pre-wrap; word-wrap: break-word;
|
| 687 |
+
box-shadow: var(--shadow-soft); line-height: 1.7;
|
| 688 |
+
text-align: left;
|
| 689 |
+
transition: background-color var(--transition-speed) var(--transition-easing), color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing), box-shadow var(--transition-speed) var(--transition-easing);
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
/* --- Medical Term Highlighting --- */
|
| 693 |
+
.medical-term-finding {
|
| 694 |
+
font-weight: 600;
|
| 695 |
+
color: var(--highlight-finding-color);
|
| 696 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 697 |
+
}
|
| 698 |
+
.medical-term-device {
|
| 699 |
+
font-weight: 600;
|
| 700 |
+
color: var(--highlight-device-color);
|
| 701 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
/* --- Medical Icon Styling --- */
|
| 705 |
+
.medical-icon {
|
| 706 |
+
margin-left: 6px;
|
| 707 |
+
color: var(--text-secondary);
|
| 708 |
+
font-size: 0.9em;
|
| 709 |
+
cursor: help;
|
| 710 |
+
vertical-align: middle;
|
| 711 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 712 |
+
}
|
| 713 |
+
/* Bootstrap Tooltip Overrides */
|
| 714 |
+
.tooltip .tooltip-inner {
|
| 715 |
+
background-color: var(--text-secondary); /* Default tooltip bg */
|
| 716 |
+
color: var(--bg-content); /* Default tooltip text */
|
| 717 |
+
font-size: 0.8rem;
|
| 718 |
+
padding: 5px 10px;
|
| 719 |
+
border-radius: var(--border-radius-sm);
|
| 720 |
+
transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease;
|
| 721 |
+
}
|
| 722 |
+
.tooltip.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,
|
| 723 |
+
.tooltip.bs-tooltip-top .tooltip-arrow::before {
|
| 724 |
+
border-top-color: var(--text-secondary);
|
| 725 |
+
transition: border-top-color var(--transition-speed) ease;
|
| 726 |
+
}
|
| 727 |
+
/* Add other arrow directions if needed */
|
| 728 |
+
|
| 729 |
+
body.dark-mode .tooltip .tooltip-inner {
|
| 730 |
+
background-color: #f0f0f0; /* Light background for dark mode tooltips */
|
| 731 |
+
color: #333; /* Dark text */
|
| 732 |
+
}
|
| 733 |
+
body.dark-mode .tooltip.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,
|
| 734 |
+
body.dark-mode .tooltip.bs-tooltip-top .tooltip-arrow::before {
|
| 735 |
+
border-top-color: #f0f0f0;
|
| 736 |
+
}
|
| 737 |
+
/* Add other arrow directions for dark mode if needed */
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
/* --- Hidden element for report context --- */
|
| 741 |
+
#report-context-data { display: none; }
|
| 742 |
+
|
| 743 |
+
/* --- Reset Button --- */
|
| 744 |
+
#reset-button {
|
| 745 |
+
display: none;
|
| 746 |
+
margin-top: 30px;
|
| 747 |
+
margin-bottom: 0;
|
| 748 |
+
opacity: 0; transition: opacity var(--transition-speed) var(--transition-easing) 0.5s, color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing), background-color var(--transition-speed) var(--transition-easing);
|
| 749 |
+
padding: 10px 20px;
|
| 750 |
+
font-size: 0.95rem; border-radius: var(--border-radius-sm);
|
| 751 |
+
align-items: center; justify-content: center; gap: 8px;
|
| 752 |
+
color: #6c757d;
|
| 753 |
+
border: 1px solid #6c757d;
|
| 754 |
+
background-color: transparent;
|
| 755 |
+
}
|
| 756 |
+
#reset-button:hover {
|
| 757 |
+
color: #fff;
|
| 758 |
+
background-color: #5c636a; /* Slightly darker secondary */
|
| 759 |
+
border-color: #565e64;
|
| 760 |
+
}
|
| 761 |
+
body.dark-mode #reset-button {
|
| 762 |
+
color: var(--dm-text-secondary);
|
| 763 |
+
border-color: var(--dm-border-secondary); /* Use darker border */
|
| 764 |
+
}
|
| 765 |
+
body.dark-mode #reset-button:hover {
|
| 766 |
+
color: var(--dm-bg-primary);
|
| 767 |
+
background-color: var(--dm-text-secondary);
|
| 768 |
+
border-color: var(--dm-text-secondary);
|
| 769 |
+
}
|
| 770 |
+
body.results-loaded #reset-button {
|
| 771 |
+
display: inline-flex;
|
| 772 |
+
opacity: 1;
|
| 773 |
+
}
|
| 774 |
+
#reset-button svg { width: 1em; height: 1em; fill: currentColor; }
|
| 775 |
+
|
| 776 |
+
/* --- Student Notes Section Styling --- */
|
| 777 |
+
#student-notes-section {
|
| 778 |
+
margin-top: 40px;
|
| 779 |
+
padding-top: 25px;
|
| 780 |
+
border-top: 1px solid var(--border-secondary);
|
| 781 |
+
text-align: left;
|
| 782 |
+
opacity: 0;
|
| 783 |
+
display: none;
|
| 784 |
+
transition: opacity 0.6s ease-out 0.4s, border-color var(--transition-speed) var(--transition-easing);
|
| 785 |
+
}
|
| 786 |
+
body.results-loaded #student-notes-section {
|
| 787 |
+
display: block;
|
| 788 |
+
opacity: 1;
|
| 789 |
+
}
|
| 790 |
+
.notes-input-area {
|
| 791 |
+
background-color: var(--bg-content);
|
| 792 |
+
padding: 20px;
|
| 793 |
+
border-radius: var(--border-radius-md);
|
| 794 |
+
border: 1px solid var(--border-secondary);
|
| 795 |
+
box-shadow: var(--shadow-inset);
|
| 796 |
+
transition: background-color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing);
|
| 797 |
+
}
|
| 798 |
+
#student-notes-textarea,
|
| 799 |
+
#student-notes-keywords {
|
| 800 |
+
font-size: 0.9rem;
|
| 801 |
+
line-height: 1.6;
|
| 802 |
+
resize: vertical;
|
| 803 |
+
background-color: var(--bg-input);
|
| 804 |
+
color: var(--text-primary);
|
| 805 |
+
border: 1px solid var(--border-secondary);
|
| 806 |
+
transition: background-color var(--transition-speed) var(--transition-easing), color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing), box-shadow var(--transition-speed) var(--transition-easing);
|
| 807 |
+
}
|
| 808 |
+
#student-notes-textarea {
|
| 809 |
+
min-height: 150px;
|
| 810 |
+
}
|
| 811 |
+
#student-notes-textarea::placeholder,
|
| 812 |
+
#student-notes-keywords::placeholder {
|
| 813 |
+
color: var(--text-input-placeholder);
|
| 814 |
+
opacity: 1;
|
| 815 |
+
}
|
| 816 |
+
#student-notes-textarea:focus,
|
| 817 |
+
#student-notes-keywords:focus {
|
| 818 |
+
background-color: var(--bg-input);
|
| 819 |
+
color: var(--text-primary);
|
| 820 |
+
border-color: var(--primary-color);
|
| 821 |
+
box-shadow: 0 0 0 0.2rem rgba(var(--primary-color-rgb), 0.25);
|
| 822 |
+
}
|
| 823 |
+
body.dark-mode #student-notes-textarea,
|
| 824 |
+
body.dark-mode #student-notes-keywords {
|
| 825 |
+
box-shadow: inset 0 1px 3px rgba(0,0,0,0.3);
|
| 826 |
+
}
|
| 827 |
+
body.dark-mode #student-notes-textarea:focus,
|
| 828 |
+
body.dark-mode #student-notes-keywords:focus {
|
| 829 |
+
background-color: #4f4f4f;
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
|
| 833 |
+
.notes-input-area label.form-label {
|
| 834 |
+
font-size: 0.95rem;
|
| 835 |
+
color: var(--text-secondary);
|
| 836 |
+
font-weight: 500;
|
| 837 |
+
transition: color var(--transition-speed) var(--transition-easing);
|
| 838 |
+
}
|
| 839 |
+
#copy-notes-button {
|
| 840 |
+
font-weight: 500;
|
| 841 |
+
color: var(--primary-color);
|
| 842 |
+
border: 1px solid var(--primary-color); /* Use 1px border for outline */
|
| 843 |
+
background-color: transparent;
|
| 844 |
+
transition: color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing), background-color var(--transition-speed) var(--transition-easing);
|
| 845 |
+
}
|
| 846 |
+
#copy-notes-button:hover {
|
| 847 |
+
color: #fff;
|
| 848 |
+
background-color: var(--primary-color);
|
| 849 |
+
border-color: var(--primary-color);
|
| 850 |
+
}
|
| 851 |
+
body.dark-mode #copy-notes-button {
|
| 852 |
+
/* Base color/border already handled by variable swap */
|
| 853 |
+
}
|
| 854 |
+
body.dark-mode #copy-notes-button:hover {
|
| 855 |
+
color: #fff; /* Ensure text stays white on red bg */
|
| 856 |
+
background-color: var(--dm-primary-color);
|
| 857 |
+
border-color: var(--dm-primary-color);
|
| 858 |
+
}
|
| 859 |
+
/* Style for the 'Copied' state */
|
| 860 |
+
#copy-notes-button:disabled {
|
| 861 |
+
opacity: 0.8; /* Make slightly transparent when copied */
|
| 862 |
+
cursor: default;
|
| 863 |
+
/* Use green tones for success feedback */
|
| 864 |
+
background-color: #d1e7dd;
|
| 865 |
+
border-color: #badbcc;
|
| 866 |
+
color: #0f5132;
|
| 867 |
+
}
|
| 868 |
+
body.dark-mode #copy-notes-button:disabled {
|
| 869 |
+
background-color: #143625; /* Dark green */
|
| 870 |
+
border-color: #1c4a32;
|
| 871 |
+
color: #75b798; /* Lighter green text */
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
#copy-feedback {
|
| 875 |
+
font-weight: 500;
|
| 876 |
+
font-size: 0.85rem;
|
| 877 |
+
opacity: 0;
|
| 878 |
+
transition: opacity 0.5s ease, color 0.5s ease;
|
| 879 |
+
color: #198754; /* Default success color */
|
| 880 |
+
}
|
| 881 |
+
body.dark-mode #copy-feedback.text-success {
|
| 882 |
+
color: #61e7a9; /* Lighter green for dark mode */
|
| 883 |
+
}
|
| 884 |
+
body.dark-mode #copy-feedback.text-danger {
|
| 885 |
+
color: #ff8a80; /* Lighter red for dark mode */
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
/* --- Alert Styling Adaptation --- */
|
| 889 |
+
.alert {
|
| 890 |
+
/* Rely on Bootstrap defaults mostly, override where needed */
|
| 891 |
+
transition: background-color var(--transition-speed) var(--transition-easing), color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing);
|
| 892 |
+
}
|
| 893 |
+
/* Example: Warning Alert */
|
| 894 |
+
body.dark-mode .alert-warning {
|
| 895 |
+
background-color: #4d3c11;
|
| 896 |
+
border-color: #664d03;
|
| 897 |
+
color: #ffecb5;
|
| 898 |
+
}
|
| 899 |
+
/* Example: Danger Alert */
|
| 900 |
+
body.dark-mode .alert-danger {
|
| 901 |
+
background-color: #4c161c;
|
| 902 |
+
border-color: #842029;
|
| 903 |
+
color: #fcc8cb;
|
| 904 |
+
}
|
| 905 |
+
/* Add other alert types (info, success) if used and need dark overrides */
|
| 906 |
+
body.dark-mode .btn-close {
|
| 907 |
+
filter: invert(1) grayscale(100%) brightness(200%);
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
|
| 911 |
+
/* --- Responsive adjustments --- */
|
| 912 |
+
@media (max-width: 991.98px) { /* Medium screens and below */
|
| 913 |
+
#chat-column {
|
| 914 |
+
border-right: none; border-bottom: 1px solid var(--border-secondary);
|
| 915 |
+
padding-right: 0; margin-bottom: 30px;
|
| 916 |
+
height: 60vh; min-height: 450px;
|
| 917 |
+
}
|
| 918 |
+
#content-column {
|
| 919 |
+
padding-left: 0; max-height: none; overflow-y: visible;
|
| 920 |
+
}
|
| 921 |
+
.container-fluid { padding: 25px; }
|
| 922 |
+
h1 { font-size: 1.9rem; }
|
| 923 |
+
h2 { font-size: 1.4rem; }
|
| 924 |
+
.result-area .row > div[class*="col-md-"] {
|
| 925 |
+
width: 100%;
|
| 926 |
+
margin-bottom: 25px;
|
| 927 |
+
}
|
| 928 |
+
}
|
| 929 |
+
@media (max-width: 767.98px) { /* Small screens */
|
| 930 |
+
#title-and-toggle { flex-direction: column; align-items: center; gap: 10px; } /* Stack title and toggle */
|
| 931 |
+
h1 { font-size: 1.7rem; text-align: center; } /* Center title */
|
| 932 |
+
h2 { font-size: 1.3rem; }
|
| 933 |
+
.container-fluid { padding: 20px; }
|
| 934 |
+
#chat-column { height: 55vh; min-height: 400px; }
|
| 935 |
+
.btn-primary, #reset-button { font-size: 0.9rem; padding: 10px 18px;}
|
| 936 |
+
#send-button { width: 40px; height: 40px; }
|
| 937 |
+
#send-button svg { width: 18px; height: 18px; }
|
| 938 |
+
#chat-input { padding: 8px 15px; }
|
| 939 |
+
.report-box { padding: 20px; font-size: 0.9rem; min-height: 200px;}
|
| 940 |
+
#student-notes-textarea { min-height: 120px; }
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
</style>
|
| 944 |
+
</head>
|
| 945 |
+
<!-- Add 'results-loaded' class server-side if results exist -->
|
| 946 |
+
<body class="{{ 'results-loaded' if report or image_data else '' }}">
|
| 947 |
+
<div class="container-fluid">
|
| 948 |
+
<!-- MODIFIED: Wrap H1 and add Toggle Button -->
|
| 949 |
+
<div id="title-and-toggle" class="d-flex justify-content-between align-items-center mb-4">
|
| 950 |
+
<h1 class="flex-grow-1">Chest X-ray Report Generation</h1>
|
| 951 |
+
<button id="darkModeToggle" class="btn btn-sm flex-shrink-0" aria-label="Toggle dark mode" title="Toggle dark mode">
|
| 952 |
+
<i class="fas fa-moon"></i> <!-- Moon icon initially -->
|
| 953 |
+
</button>
|
| 954 |
+
</div>
|
| 955 |
+
|
| 956 |
+
<!-- Flash Messages -->
|
| 957 |
+
<div id="flash-message-container">
|
| 958 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 959 |
+
{% if messages %}
|
| 960 |
+
{% for category, message in messages %}
|
| 961 |
+
<div class="alert alert-{{ category }} alert-dismissible fade show mb-3" role="alert">
|
| 962 |
+
{{ message }}
|
| 963 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
| 964 |
+
</div>
|
| 965 |
+
{% endfor %}
|
| 966 |
+
{% endif %}
|
| 967 |
+
{% endwith %}
|
| 968 |
+
</div>
|
| 969 |
+
|
| 970 |
+
<!-- Main Row: Chat on Left, Content on Right -->
|
| 971 |
+
<div class="row main-row">
|
| 972 |
+
|
| 973 |
+
<!-- Chat Column (Left) -->
|
| 974 |
+
<div class="col-lg-5" id="chat-column" class="{{ 'disabled' if not report or not chatbot_available }}">
|
| 975 |
+
<h2><i class="fas fa-comments me-2"></i>Chat about Report</h2>
|
| 976 |
+
<small class="text-muted mb-2 d-block">Ask questions based *only* on the generated report.</small>
|
| 977 |
+
|
| 978 |
+
{% if report and not chatbot_available %}
|
| 979 |
+
<div class="alert alert-warning small p-2 mt-2">Chatbot (Llama 3.1) is not available or failed to load. Please check server logs.</div>
|
| 980 |
+
{% endif %}
|
| 981 |
+
|
| 982 |
+
<div id="chat-container">
|
| 983 |
+
<div id="chat-placeholder">Generate a report first to enable chat.</div>
|
| 984 |
+
<div id="chat-messages">
|
| 985 |
+
{% if report and chatbot_available %}
|
| 986 |
+
<div class="chat-message bot-message" style="opacity: 1; transform: none;">Welcome! Ask me about the findings, impression, or specific details in the report above.</div>
|
| 987 |
+
{% endif %}
|
| 988 |
+
</div>
|
| 989 |
+
<form id="chat-form" action="javascript:void(0);">
|
| 990 |
+
<input type="text" id="chat-input" class="form-control" placeholder="Type your question..." autocomplete="off" {% if not report or not chatbot_available %}disabled{% endif %}>
|
| 991 |
+
<button type="submit" id="send-button" class="btn btn-primary" {% if not report or not chatbot_available %}disabled{% endif %}>
|
| 992 |
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" /></svg>
|
| 993 |
+
</button>
|
| 994 |
+
</form>
|
| 995 |
+
<div id="example-questions" {% if not report or not chatbot_available %}style="display: none;"{% endif %}>
|
| 996 |
+
<small>Example Questions:</small>
|
| 997 |
+
<button class="suggestion-btn">What are the main findings?</button>
|
| 998 |
+
<button class="suggestion-btn">Are there any tubes or lines mentioned?</button>
|
| 999 |
+
<button class="suggestion-btn">Summarize the impression.</button>
|
| 1000 |
+
<button class="suggestion-btn">Is the heart size normal?</button>
|
| 1001 |
+
<button class="suggestion-btn">Any signs of pleural effusion?</button>
|
| 1002 |
+
</div>
|
| 1003 |
+
</div>
|
| 1004 |
+
</div>
|
| 1005 |
+
|
| 1006 |
+
<!-- Content Column (Right - Form, Image, Report, Notes) -->
|
| 1007 |
+
<div class="col-lg-7" id="content-column">
|
| 1008 |
+
|
| 1009 |
+
<!-- Form Section Wrapper (for animation) -->
|
| 1010 |
+
<div id="form-wrapper">
|
| 1011 |
+
<h2><i class="fas fa-upload me-2"></i>Generate Report</h2>
|
| 1012 |
+
<form id="upload-form" method="post" enctype="multipart/form-data" action="{{ url_for('predict') }}">
|
| 1013 |
+
<div class="mb-3">
|
| 1014 |
+
<label for="imageUpload" class="form-label">1. Upload Chest X-Ray Image:</label>
|
| 1015 |
+
<input class="form-control form-control-sm" type="file" id="imageUpload" name="image" accept="image/png, image/jpeg, image/jpg" required>
|
| 1016 |
+
<div class="form-text">Allowed formats: PNG, JPG, JPEG. Filename format like '...-View-Age-Gender-Ethnicity.png' helps extract patient info.</div>
|
| 1017 |
+
</div>
|
| 1018 |
+
<div class="mb-3">
|
| 1019 |
+
<label for="vlmSelect" class="form-label">2. Choose Vision-Language Model:</label>
|
| 1020 |
+
<select class="form-select form-select-sm" id="vlmSelect" name="vlm_choice">
|
| 1021 |
+
<option value="swin_t5_chexpert" selected>Swin-T5 (CheXpert Trained)</option>
|
| 1022 |
+
<!-- Add other VLM options here if available -->
|
| 1023 |
+
</select>
|
| 1024 |
+
</div>
|
| 1025 |
+
<div class="mb-3">
|
| 1026 |
+
<label for="maxLength" class="form-label">3. Max Report Length (tokens):</label>
|
| 1027 |
+
<input type="number" class="form-control form-control-sm" id="maxLength" name="max_length" value="100" min="10" max="512">
|
| 1028 |
+
<div class="form-text">Adjusts the maximum length of the generated report (default: 100).</div>
|
| 1029 |
+
</div>
|
| 1030 |
+
<button type="submit" class="btn btn-primary w-100 mt-2">
|
| 1031 |
+
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
| 1032 |
+
<i class="fas fa-file-medical-alt me-1"></i> Generate Report
|
| 1033 |
+
</button>
|
| 1034 |
+
</form>
|
| 1035 |
+
</div>
|
| 1036 |
+
<!-- End Form Section -->
|
| 1037 |
+
|
| 1038 |
+
<!-- Result Section (Only render if data exists) -->
|
| 1039 |
+
{% if report or image_data %}
|
| 1040 |
+
<div class="result-area">
|
| 1041 |
+
<!-- Patient Info -->
|
| 1042 |
+
{% if patient_info %}
|
| 1043 |
+
<div id="patient-info-section">
|
| 1044 |
+
<h3><i class="fas fa-user-md me-2"></i>Patient Information</h3>
|
| 1045 |
+
<p id="patient-info-data">
|
| 1046 |
+
<strong>View:</strong> {{ patient_info.view | e }}
|
| 1047 |
+
<span>|</span>
|
| 1048 |
+
<strong>Age:</strong> {{ patient_info.age | e }}
|
| 1049 |
+
<span>|</span>
|
| 1050 |
+
<strong>Gender:</strong> {{ patient_info.gender | e }}
|
| 1051 |
+
<span>|</span>
|
| 1052 |
+
<strong>Ethnicity:</strong> {{ patient_info.ethnicity | e }}
|
| 1053 |
+
</p>
|
| 1054 |
+
</div>
|
| 1055 |
+
{% elif image_data %}
|
| 1056 |
+
<div id="patient-info-section">
|
| 1057 |
+
<p class="text-muted small"><em>Patient information could not be parsed from the filename.</em></p>
|
| 1058 |
+
</div>
|
| 1059 |
+
{% endif %}
|
| 1060 |
+
|
| 1061 |
+
<!-- Image and Report Row -->
|
| 1062 |
+
<div class="row">
|
| 1063 |
+
<!-- Image Column -->
|
| 1064 |
+
{% if image_data %}
|
| 1065 |
+
<div class="col-md-6">
|
| 1066 |
+
<div class="img-preview-container" id="image-result-area">
|
| 1067 |
+
<h2><i class="fas fa-image me-2"></i>Uploaded Image</h2>
|
| 1068 |
+
<img id="uploaded-image" src="data:image/jpeg;base64,{{ image_data }}" alt="Uploaded Chest X-ray" class="img-preview">
|
| 1069 |
+
<div id="zoom-preview-panel"></div>
|
| 1070 |
+
<small class="d-block text-muted mt-2">Hover over image to zoom (on desktop).</small>
|
| 1071 |
+
<div id="zoom-controls" class="mt-3">
|
| 1072 |
+
<label for="zoom-slider" class="form-label form-label-sm mb-1 d-block text-center">Zoom Level: <span id="zoom-value">2.0</span>x</label>
|
| 1073 |
+
<input type="range" class="form-range" id="zoom-slider" min="1" max="5" step="0.1" value="2">
|
| 1074 |
+
</div>
|
| 1075 |
+
</div>
|
| 1076 |
+
</div>
|
| 1077 |
+
{% endif %}
|
| 1078 |
+
|
| 1079 |
+
<!-- Report Column -->
|
| 1080 |
+
{% if report %}
|
| 1081 |
+
<div class="col-md-6">
|
| 1082 |
+
<div class="report-box-container">
|
| 1083 |
+
<h2><i class="fas fa-file-alt me-2"></i>Generated Report</h2>
|
| 1084 |
+
<div class="report-box">
|
| 1085 |
+
<!-- Highlight medical terms dynamically -->
|
| 1086 |
+
{{ report | safe if report.count('<') > 0 else report | e }}
|
| 1087 |
+
</div>
|
| 1088 |
+
<div id="report-context-data" data-report="{{ report|e }}"></div>
|
| 1089 |
+
</div>
|
| 1090 |
+
</div>
|
| 1091 |
+
{% elif image_data %}
|
| 1092 |
+
<div class="col-md-6">
|
| 1093 |
+
<div class="report-box-container">
|
| 1094 |
+
<h2><i class="fas fa-file-alt me-2"></i>Generated Report</h2>
|
| 1095 |
+
<div class="alert alert-danger">Report generation failed. Please check the logs or try again.</div>
|
| 1096 |
+
</div>
|
| 1097 |
+
</div>
|
| 1098 |
+
{% endif %}
|
| 1099 |
+
</div> <!-- End Result Row -->
|
| 1100 |
+
|
| 1101 |
+
<!-- Reset Button -->
|
| 1102 |
+
<button id="reset-button" class="btn btn-outline-secondary">
|
| 1103 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16" aria-hidden="true">
|
| 1104 |
+
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
| 1105 |
+
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
| 1106 |
+
</svg>
|
| 1107 |
+
<span>Start Over / New Image</span>
|
| 1108 |
+
</button>
|
| 1109 |
+
|
| 1110 |
+
</div> <!-- End Result Area -->
|
| 1111 |
+
{% endif %}
|
| 1112 |
+
<!-- End Result Section -->
|
| 1113 |
+
|
| 1114 |
+
<!-- Student Notes Section -->
|
| 1115 |
+
{% if report or image_data %} {# Only show notes if results exist #}
|
| 1116 |
+
<div id="student-notes-section">
|
| 1117 |
+
<h3><i class="fas fa-user-graduate me-2"></i>Notes for Medical Student</h3>
|
| 1118 |
+
<div class="notes-input-area p-3 border rounded shadow-sm">
|
| 1119 |
+
<div class="mb-3">
|
| 1120 |
+
<label for="student-notes-textarea" class="form-label fw-bold">Observations & Learning Points:</label>
|
| 1121 |
+
<textarea class="form-control form-control-sm" id="student-notes-textarea" rows="8" placeholder="Enter key observations, differential diagnoses, relevant anatomy, clinical correlations, or questions for the student..."></textarea>
|
| 1122 |
+
</div>
|
| 1123 |
+
<div class="mb-3">
|
| 1124 |
+
<label for="student-notes-keywords" class="form-label fw-bold">Keywords/Tags:</label>
|
| 1125 |
+
<input type="text" class="form-control form-control-sm" id="student-notes-keywords" placeholder="e.g., Pneumonia, Cardiomegaly, Atelectasis, PICC Line Placement">
|
| 1126 |
+
</div>
|
| 1127 |
+
<button id="copy-notes-button" class="btn btn-sm btn-outline-primary w-100">
|
| 1128 |
+
<i class="fas fa-copy me-1"></i> Copy Notes & Keywords to Clipboard
|
| 1129 |
+
</button>
|
| 1130 |
+
<small id="copy-feedback" class="d-block text-center text-success mt-2">Copied!</small>
|
| 1131 |
+
</div>
|
| 1132 |
+
</div>
|
| 1133 |
+
{% endif %}
|
| 1134 |
+
<!-- End Student Notes Section -->
|
| 1135 |
+
|
| 1136 |
+
</div> <!-- End Content Column -->
|
| 1137 |
+
</div> <!-- End Main Row -->
|
| 1138 |
+
</div> <!-- End Container -->
|
| 1139 |
+
|
| 1140 |
+
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
| 1141 |
+
<script>
|
| 1142 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 1143 |
+
|
| 1144 |
+
// --- Initialize Bootstrap Tooltips ---
|
| 1145 |
+
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
| 1146 |
+
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
| 1147 |
+
// Add container: 'body' to prevent tooltip being clipped in tight spaces
|
| 1148 |
+
return new bootstrap.Tooltip(tooltipTriggerEl, { container: 'body', boundary: document.body });
|
| 1149 |
+
});
|
| 1150 |
+
|
| 1151 |
+
// --- Dark Mode Toggle Functionality ---
|
| 1152 |
+
const darkModeToggle = document.getElementById('darkModeToggle');
|
| 1153 |
+
const bodyElement = document.body;
|
| 1154 |
+
const toggleIcon = darkModeToggle?.querySelector('i');
|
| 1155 |
+
|
| 1156 |
+
const applyTheme = (theme) => {
|
| 1157 |
+
if (!toggleIcon) return;
|
| 1158 |
+
const isDark = theme === 'dark';
|
| 1159 |
+
|
| 1160 |
+
bodyElement.classList.toggle('dark-mode', isDark);
|
| 1161 |
+
toggleIcon.classList.toggle('fa-moon', !isDark);
|
| 1162 |
+
toggleIcon.classList.toggle('fa-sun', isDark);
|
| 1163 |
+
localStorage.setItem('theme', theme);
|
| 1164 |
+
const label = isDark ? 'Switch to light mode' : 'Switch to dark mode';
|
| 1165 |
+
darkModeToggle.setAttribute('aria-label', label);
|
| 1166 |
+
darkModeToggle.setAttribute('title', label); // Update tooltip title attribute
|
| 1167 |
+
|
| 1168 |
+
// Refresh the specific tooltip instance for the toggle button
|
| 1169 |
+
const toggleTooltipInstance = bootstrap.Tooltip.getInstance(darkModeToggle);
|
| 1170 |
+
if (toggleTooltipInstance) {
|
| 1171 |
+
toggleTooltipInstance.setContent({ '.tooltip-inner': label });
|
| 1172 |
+
}
|
| 1173 |
+
};
|
| 1174 |
+
|
| 1175 |
+
if (darkModeToggle && toggleIcon) {
|
| 1176 |
+
const preferredTheme = localStorage.getItem('theme') ||
|
| 1177 |
+
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
| 1178 |
+
applyTheme(preferredTheme);
|
| 1179 |
+
|
| 1180 |
+
darkModeToggle.addEventListener('click', () => {
|
| 1181 |
+
const newTheme = bodyElement.classList.contains('dark-mode') ? 'light' : 'dark';
|
| 1182 |
+
applyTheme(newTheme);
|
| 1183 |
+
});
|
| 1184 |
+
|
| 1185 |
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
|
| 1186 |
+
if (!localStorage.getItem('theme')) { // Only follow system if no manual choice
|
| 1187 |
+
applyTheme(event.matches ? 'dark' : 'light');
|
| 1188 |
+
}
|
| 1189 |
+
});
|
| 1190 |
+
}
|
| 1191 |
+
// --- End Dark Mode Toggle ---
|
| 1192 |
+
|
| 1193 |
+
|
| 1194 |
+
// --- Form Processing Indicator & Animation Control ---
|
| 1195 |
+
const form = document.getElementById('upload-form');
|
| 1196 |
+
const formWrapper = document.getElementById('form-wrapper');
|
| 1197 |
+
const submitButton = form?.querySelector('button[type="submit"]');
|
| 1198 |
+
const spinner = submitButton?.querySelector('.spinner-border');
|
| 1199 |
+
|
| 1200 |
+
if (form && submitButton && formWrapper) {
|
| 1201 |
+
form.addEventListener('submit', function(e) {
|
| 1202 |
+
const fileInput = document.getElementById('imageUpload');
|
| 1203 |
+
if (!fileInput || fileInput.files.length === 0) { return; }
|
| 1204 |
+
form.classList.add('processing');
|
| 1205 |
+
submitButton.disabled = true;
|
| 1206 |
+
if (spinner) spinner.style.display = 'inline-block';
|
| 1207 |
+
submitButton.querySelector('i')?.classList.add('d-none');
|
| 1208 |
+
});
|
| 1209 |
+
}
|
| 1210 |
+
|
| 1211 |
+
window.addEventListener('pageshow', function(event) {
|
| 1212 |
+
if (event.persisted || (window.performance && window.performance.getEntriesByType("navigation")[0].type === 'back_forward')) { // More robust check
|
| 1213 |
+
if (form && submitButton && spinner) {
|
| 1214 |
+
form.classList.remove('processing');
|
| 1215 |
+
submitButton.disabled = false;
|
| 1216 |
+
spinner.style.display = 'none';
|
| 1217 |
+
submitButton.querySelector('i')?.classList.remove('d-none');
|
| 1218 |
+
}
|
| 1219 |
+
const reportContextData = document.getElementById('report-context-data');
|
| 1220 |
+
bodyElement.classList.toggle('results-loaded', !!(reportContextData && reportContextData.dataset.report));
|
| 1221 |
+
}
|
| 1222 |
+
});
|
| 1223 |
+
|
| 1224 |
+
// --- Chat Functionality ---
|
| 1225 |
+
const chatForm = document.getElementById('chat-form');
|
| 1226 |
+
const chatInput = document.getElementById('chat-input');
|
| 1227 |
+
const chatMessages = document.getElementById('chat-messages');
|
| 1228 |
+
const sendButton = document.getElementById('send-button');
|
| 1229 |
+
const reportContextData = document.getElementById('report-context-data');
|
| 1230 |
+
const chatColumn = document.getElementById('chat-column');
|
| 1231 |
+
const chatPlaceholder = document.getElementById('chat-placeholder');
|
| 1232 |
+
const exampleQuestionsContainer = document.getElementById('example-questions');
|
| 1233 |
+
const reportContext = reportContextData ? reportContextData.dataset.report : null;
|
| 1234 |
+
const isChatbotAvailable = {{ chatbot_available | tojson }};
|
| 1235 |
+
const isChatEnabled = !!reportContext && isChatbotAvailable;
|
| 1236 |
+
|
| 1237 |
+
if (chatColumn) {
|
| 1238 |
+
const enableChatUI = (enable) => {
|
| 1239 |
+
chatColumn.classList.toggle('disabled', !enable);
|
| 1240 |
+
if (chatInput) chatInput.disabled = !enable;
|
| 1241 |
+
if (sendButton) sendButton.disabled = !enable;
|
| 1242 |
+
if (chatPlaceholder) chatPlaceholder.style.display = enable ? 'none' : 'block';
|
| 1243 |
+
if (exampleQuestionsContainer) exampleQuestionsContainer.style.display = enable ? 'block' : 'none';
|
| 1244 |
+
};
|
| 1245 |
+
enableChatUI(isChatEnabled);
|
| 1246 |
+
if (isChatEnabled && chatMessages) {
|
| 1247 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1248 |
+
}
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
function addChatMessage(message, sender, isThinking = false) {
|
| 1252 |
+
if (!chatMessages) return null;
|
| 1253 |
+
const messageDiv = document.createElement('div');
|
| 1254 |
+
messageDiv.classList.add('chat-message', sender === 'user' ? 'user-message' : 'bot-message');
|
| 1255 |
+
if (isThinking) {
|
| 1256 |
+
messageDiv.classList.add('thinking');
|
| 1257 |
+
messageDiv.innerHTML = '<span> Thinking...</span>';
|
| 1258 |
+
} else {
|
| 1259 |
+
const sanitizedMessage = message.replace(/</g, "<").replace(/>/g, ">");
|
| 1260 |
+
messageDiv.innerHTML = sanitizedMessage;
|
| 1261 |
+
}
|
| 1262 |
+
chatMessages.appendChild(messageDiv);
|
| 1263 |
+
chatMessages.scrollTo({ top: chatMessages.scrollHeight, behavior: 'smooth' });
|
| 1264 |
+
return messageDiv;
|
| 1265 |
+
}
|
| 1266 |
+
|
| 1267 |
+
if (chatForm && isChatEnabled) {
|
| 1268 |
+
chatForm.addEventListener('submit', async (e) => {
|
| 1269 |
+
e.preventDefault();
|
| 1270 |
+
if (!chatInput || !reportContext) return;
|
| 1271 |
+
const question = chatInput.value.trim();
|
| 1272 |
+
if (!question || chatInput.disabled) return; // Prevent multiple submits
|
| 1273 |
+
|
| 1274 |
+
addChatMessage(question, 'user');
|
| 1275 |
+
chatInput.value = '';
|
| 1276 |
+
chatInput.disabled = true; // Disable during processing
|
| 1277 |
+
if (sendButton) sendButton.disabled = true;
|
| 1278 |
+
const thinkingMessageElement = addChatMessage('', 'bot', true);
|
| 1279 |
+
|
| 1280 |
+
try {
|
| 1281 |
+
const response = await fetch("{{ url_for('chat') }}", {
|
| 1282 |
+
method: 'POST',
|
| 1283 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1284 |
+
body: JSON.stringify({ question: question, report_context: reportContext }),
|
| 1285 |
+
});
|
| 1286 |
+
if (thinkingMessageElement) thinkingMessageElement.remove(); // Remove thinking indicator
|
| 1287 |
+
|
| 1288 |
+
if (!response.ok) {
|
| 1289 |
+
let errorMsg = `Chat Error: ${response.status || ''} ${response.statusText}`;
|
| 1290 |
+
try {
|
| 1291 |
+
const errorData = await response.json();
|
| 1292 |
+
errorMsg = `Chat Error: ${errorData.error || response.statusText}`;
|
| 1293 |
+
} catch (e) { /* Ignore */ }
|
| 1294 |
+
// Use CSS vars for error color - find the finding highlight color
|
| 1295 |
+
addChatMessage(errorMsg, 'bot').style.color = 'var(--highlight-finding-color)';
|
| 1296 |
+
} else {
|
| 1297 |
+
const data = await response.json();
|
| 1298 |
+
addChatMessage(data.answer || "Received empty response.", 'bot');
|
| 1299 |
+
}
|
| 1300 |
+
} catch (error) {
|
| 1301 |
+
console.error("Chat fetch error:", error);
|
| 1302 |
+
if (thinkingMessageElement) thinkingMessageElement.remove();
|
| 1303 |
+
addChatMessage('Error connecting to the chat service.', 'bot').style.color = 'var(--highlight-finding-color)';
|
| 1304 |
+
} finally {
|
| 1305 |
+
if(chatInput) chatInput.disabled = false; // Re-enable input
|
| 1306 |
+
if(sendButton) sendButton.disabled = false; // Re-enable button
|
| 1307 |
+
if(chatInput) chatInput.focus();
|
| 1308 |
+
if(chatMessages) chatMessages.scrollTo({ top: chatMessages.scrollHeight, behavior: 'smooth' });
|
| 1309 |
+
}
|
| 1310 |
+
});
|
| 1311 |
+
} else if (chatForm) { chatForm.addEventListener('submit', (e) => e.preventDefault()); }
|
| 1312 |
+
|
| 1313 |
+
if (exampleQuestionsContainer && chatInput && isChatEnabled) {
|
| 1314 |
+
exampleQuestionsContainer.addEventListener('click', (e) => {
|
| 1315 |
+
if (e.target?.classList.contains('suggestion-btn')) {
|
| 1316 |
+
e.preventDefault();
|
| 1317 |
+
const questionText = e.target.textContent || e.target.innerText;
|
| 1318 |
+
if (questionText && !chatInput.disabled) { chatInput.value = questionText; chatInput.focus(); }
|
| 1319 |
+
}
|
| 1320 |
+
});
|
| 1321 |
+
}
|
| 1322 |
+
|
| 1323 |
+
// --- Image Hover Zoom Functionality (with Slider) ---
|
| 1324 |
+
const imagePreview = document.getElementById('uploaded-image');
|
| 1325 |
+
const zoomPanel = document.getElementById('zoom-preview-panel');
|
| 1326 |
+
const imageContainer = document.getElementById('image-result-area');
|
| 1327 |
+
const zoomSlider = document.getElementById('zoom-slider');
|
| 1328 |
+
const zoomValueDisplay = document.getElementById('zoom-value');
|
| 1329 |
+
const zoomControls = document.getElementById('zoom-controls');
|
| 1330 |
+
|
| 1331 |
+
if (imagePreview && zoomPanel && imageContainer && zoomSlider && zoomValueDisplay && zoomControls) {
|
| 1332 |
+
let naturalWidth = 0, naturalHeight = 0;
|
| 1333 |
+
let currentZoomLevel = parseFloat(zoomSlider.value);
|
| 1334 |
+
let lastPercX = 0.5, lastPercY = 0.5;
|
| 1335 |
+
let isZoomActive = false;
|
| 1336 |
+
|
| 1337 |
+
const updateZoomPanelBackground = () => {
|
| 1338 |
+
if (naturalWidth > 0 && isZoomActive) { // Check isZoomActive here
|
| 1339 |
+
zoomPanel.style.backgroundSize = `${naturalWidth * currentZoomLevel}px ${naturalHeight * currentZoomLevel}px`;
|
| 1340 |
+
const panelRect = zoomPanel.getBoundingClientRect();
|
| 1341 |
+
const bgX = -(lastPercX * (naturalWidth * currentZoomLevel - panelRect.width));
|
| 1342 |
+
const bgY = -(lastPercY * (naturalHeight * currentZoomLevel - panelRect.height));
|
| 1343 |
+
zoomPanel.style.backgroundPosition = `${bgX}px ${bgY}px`;
|
| 1344 |
+
}
|
| 1345 |
+
};
|
| 1346 |
+
|
| 1347 |
+
const updateImageDimensions = () => {
|
| 1348 |
+
naturalWidth = imagePreview.naturalWidth;
|
| 1349 |
+
naturalHeight = imagePreview.naturalHeight;
|
| 1350 |
+
// No need to call updateZoomPanelBackground here, it happens on mouseenter
|
| 1351 |
+
if (naturalWidth === 0 && zoomControls) {
|
| 1352 |
+
zoomControls.style.display = 'none';
|
| 1353 |
+
}
|
| 1354 |
+
};
|
| 1355 |
+
|
| 1356 |
+
if (imagePreview.complete && imagePreview.naturalWidth > 0) { updateImageDimensions(); }
|
| 1357 |
+
else { imagePreview.onload = updateImageDimensions; imagePreview.onerror = updateImageDimensions; }
|
| 1358 |
+
|
| 1359 |
+
|
| 1360 |
+
imageContainer.addEventListener('mouseenter', () => {
|
| 1361 |
+
if (naturalWidth > 0 && window.innerWidth >= 992) {
|
| 1362 |
+
isZoomActive = true; // Set flag first
|
| 1363 |
+
zoomPanel.style.backgroundImage = `url('${imagePreview.src}')`;
|
| 1364 |
+
updateZoomPanelBackground(); // Now update with current mouse pos/zoom
|
| 1365 |
+
zoomPanel.style.opacity = '1'; zoomPanel.style.visibility = 'visible';
|
| 1366 |
+
zoomPanel.style.transition = 'opacity 0.2s ease-out, visibility 0s linear 0s, background-color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing)';
|
| 1367 |
+
}
|
| 1368 |
+
});
|
| 1369 |
+
|
| 1370 |
+
imageContainer.addEventListener('mousemove', (e) => {
|
| 1371 |
+
if (!isZoomActive || naturalWidth === 0) return;
|
| 1372 |
+
const imgRect = imagePreview.getBoundingClientRect();
|
| 1373 |
+
lastPercX = Math.max(0, Math.min(1, (e.clientX - imgRect.left) / imgRect.width));
|
| 1374 |
+
lastPercY = Math.max(0, Math.min(1, (e.clientY - imgRect.top) / imgRect.height));
|
| 1375 |
+
updateZoomPanelBackground();
|
| 1376 |
+
|
| 1377 |
+
// Position calculation remains the same
|
| 1378 |
+
const containerRect = imageContainer.getBoundingClientRect();
|
| 1379 |
+
const panelRect = zoomPanel.getBoundingClientRect(); // Get updated rect if size changed
|
| 1380 |
+
const gap = 15;
|
| 1381 |
+
let panelLeft = 'auto', panelRight = 'auto';
|
| 1382 |
+
if ((window.innerWidth - containerRect.right - gap) >= panelRect.width) {
|
| 1383 |
+
panelLeft = `${imgRect.right - containerRect.left + gap}px`;
|
| 1384 |
+
} else if ((containerRect.left - gap) >= panelRect.width) {
|
| 1385 |
+
panelRight = `${containerRect.right - imgRect.left + gap}px`;
|
| 1386 |
+
} else {
|
| 1387 |
+
panelLeft = `${imgRect.right - containerRect.left + gap}px`; // Default right
|
| 1388 |
+
}
|
| 1389 |
+
zoomPanel.style.left = panelLeft;
|
| 1390 |
+
zoomPanel.style.right = panelRight;
|
| 1391 |
+
zoomPanel.style.top = `${imgRect.top - containerRect.top}px`;
|
| 1392 |
+
});
|
| 1393 |
+
|
| 1394 |
+
imageContainer.addEventListener('mouseleave', () => {
|
| 1395 |
+
isZoomActive = false; // Reset flag
|
| 1396 |
+
zoomPanel.style.opacity = '0'; zoomPanel.style.visibility = 'hidden';
|
| 1397 |
+
zoomPanel.style.transition = 'opacity 0.2s ease-out, visibility 0s linear 0.2s, background-color var(--transition-speed) var(--transition-easing), border-color var(--transition-speed) var(--transition-easing)';
|
| 1398 |
+
});
|
| 1399 |
+
|
| 1400 |
+
zoomSlider.addEventListener('input', () => {
|
| 1401 |
+
currentZoomLevel = parseFloat(zoomSlider.value);
|
| 1402 |
+
zoomValueDisplay.textContent = currentZoomLevel.toFixed(1);
|
| 1403 |
+
updateZoomPanelBackground(); // Update view immediately
|
| 1404 |
+
});
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
// --- Student Notes Functionality ---
|
| 1408 |
+
const notesTextArea = document.getElementById('student-notes-textarea');
|
| 1409 |
+
const keywordsInput = document.getElementById('student-notes-keywords');
|
| 1410 |
+
const copyNotesButton = document.getElementById('copy-notes-button');
|
| 1411 |
+
const copyFeedback = document.getElementById('copy-feedback');
|
| 1412 |
+
|
| 1413 |
+
if (copyNotesButton && notesTextArea && keywordsInput && copyFeedback) {
|
| 1414 |
+
copyNotesButton.addEventListener('click', () => {
|
| 1415 |
+
const notes = notesTextArea.value.trim();
|
| 1416 |
+
const keywords = keywordsInput.value.trim();
|
| 1417 |
+
let textToCopy = "--- Medical Student Notes ---\n\n";
|
| 1418 |
+
if (keywords) { textToCopy += `Keywords/Tags:\n${keywords}\n\n`; }
|
| 1419 |
+
if (notes) { textToCopy += `Observations & Learning Points:\n${notes}\n`; }
|
| 1420 |
+
else if (!keywords) { textToCopy += "(No notes or keywords entered)"; }
|
| 1421 |
+
|
| 1422 |
+
navigator.clipboard.writeText(textToCopy).then(() => {
|
| 1423 |
+
copyFeedback.textContent = 'Copied!';
|
| 1424 |
+
copyFeedback.classList.remove('text-danger');
|
| 1425 |
+
copyFeedback.classList.add('text-success');
|
| 1426 |
+
copyFeedback.style.opacity = '1';
|
| 1427 |
+
copyNotesButton.disabled = true;
|
| 1428 |
+
copyNotesButton.innerHTML = '<i class="fas fa-check me-1"></i> Copied!';
|
| 1429 |
+
setTimeout(() => {
|
| 1430 |
+
copyFeedback.style.opacity = '0';
|
| 1431 |
+
copyNotesButton.disabled = false;
|
| 1432 |
+
copyNotesButton.innerHTML = '<i class="fas fa-copy me-1"></i> Copy Notes & Keywords to Clipboard';
|
| 1433 |
+
}, 2000);
|
| 1434 |
+
}).catch(err => {
|
| 1435 |
+
console.error('Failed to copy notes: ', err);
|
| 1436 |
+
copyFeedback.textContent = 'Copy Failed!';
|
| 1437 |
+
copyFeedback.classList.remove('text-success');
|
| 1438 |
+
copyFeedback.classList.add('text-danger');
|
| 1439 |
+
copyFeedback.style.opacity = '1';
|
| 1440 |
+
// Don't disable the button on failure
|
| 1441 |
+
setTimeout(() => {
|
| 1442 |
+
copyFeedback.style.opacity = '0';
|
| 1443 |
+
// Reset text after a delay
|
| 1444 |
+
setTimeout(() => {
|
| 1445 |
+
if (copyFeedback.style.opacity === '0') { // Only reset if still hidden
|
| 1446 |
+
copyFeedback.textContent = 'Copied!';
|
| 1447 |
+
copyFeedback.classList.remove('text-danger');
|
| 1448 |
+
copyFeedback.classList.add('text-success');
|
| 1449 |
+
}
|
| 1450 |
+
}, 500); // Delay before resetting text allows fade out
|
| 1451 |
+
}, 2500);
|
| 1452 |
+
});
|
| 1453 |
+
});
|
| 1454 |
+
}
|
| 1455 |
+
|
| 1456 |
+
// --- Reset Button Functionality ---
|
| 1457 |
+
const resetButton = document.getElementById('reset-button');
|
| 1458 |
+
if(resetButton) {
|
| 1459 |
+
resetButton.addEventListener('click', () => { window.location.href = "{{ url_for('index') }}"; });
|
| 1460 |
+
}
|
| 1461 |
+
|
| 1462 |
+
}); // End DOMContentLoaded
|
| 1463 |
+
</script>
|
| 1464 |
+
|
| 1465 |
+
</body>
|
| 1466 |
+
</html>
|