Sayed223 commited on
Commit
11e4a86
·
verified ·
1 Parent(s): f559680

Upload 20 files

Browse files
.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
- title: Aiproject
3
- emoji: 🔥
4
- colorFrom: green
5
- colorTo: purple
6
- sdk: docker
7
- pinned: false
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ![Interface Screenshot (Dark Mode)](images/dark.png)
6
+ ![Interface Screenshot (Light Mode)](images/light.png)
7
+ ![Notes](images/notes.png)
8
+ ![Zoom Functionality](images/zoom.png)
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
+ ![Initialization](images/llm-1.png)
101
+ ![Hindi](images/hindi.png)
102
+ ![Urdu](images/urdu.png)
103
+ ![Norwegian](images/norwegian.png)
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

  • SHA256: d57d8d0b0ccb5c17d569ee0124d7d04dd443e2cb06cd815d887571a08dddcf5d
  • Pointer size: 131 Bytes
  • Size of remote file: 535 kB
images/hindi.png ADDED

Git LFS Details

  • SHA256: f50eacc79cfbe756dc15003915a4360ff73e5a784cda575936c77a746796efa3
  • Pointer size: 131 Bytes
  • Size of remote file: 341 kB
images/light.png ADDED

Git LFS Details

  • SHA256: 07b3bba6c248f7a5b17e55e3f9a025c64b2c4351665ed2ad3324e747872ba111
  • Pointer size: 132 Bytes
  • Size of remote file: 1.33 MB
images/llm-1.png ADDED

Git LFS Details

  • SHA256: 26072dc36c0fb5817c5c4d0756f68d7a9bf143b704617c79c3636ab929d67052
  • Pointer size: 131 Bytes
  • Size of remote file: 323 kB
images/norwegian.png ADDED

Git LFS Details

  • SHA256: fcc87050c0749cfc04db9829f23572a43e58bc6d5754db11524abb146f1fa470
  • Pointer size: 131 Bytes
  • Size of remote file: 252 kB
images/notes.png ADDED

Git LFS Details

  • SHA256: 29cdf8a302c7667e73c30a93126224254edbb8ac935ee9b4f6344c9f514b7974
  • Pointer size: 131 Bytes
  • Size of remote file: 279 kB
images/urdu.png ADDED

Git LFS Details

  • SHA256: 46d44ea9da1fb55415804eb20507202df85321f5de60eeb9f3ed09fd5ff37170
  • Pointer size: 131 Bytes
  • Size of remote file: 297 kB
images/zoom.png ADDED

Git LFS Details

  • SHA256: 16157784574ab66803de4358c31bb896ce586d67e7c7456bd39feeb6335093ab
  • Pointer size: 131 Bytes
  • Size of remote file: 457 kB
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>