Spaces:
Running
Running
Commit
·
352a4b6
0
Parent(s):
Initial commit
Browse files- .dockerignore +2 -0
- .gitattributes +37 -0
- .gitignore +5 -0
- Dockerfile +56 -0
- README.md +32 -0
- app.py +66 -0
- cache_store.py +20 -0
- config.py +107 -0
- default_cache/radexplain-cache.zip +3 -0
- llm_client.py +87 -0
- requirements.txt +5 -0
- routes.py +242 -0
- run_local.sh +48 -0
- static/css/style.css +595 -0
- static/images/.gitattributes +1 -0
- static/images/CT-Tumor.jpg +3 -0
- static/images/Effusion2.jpg +3 -0
- static/images/Infection.jpg +3 -0
- static/images/Lymphadenopathy2.jpg +3 -0
- static/images/Nodule3.jpg +3 -0
- static/images/Nodule5.jpg +3 -0
- static/js/demo.js +538 -0
- static/reports/CT-Tumor.txt +17 -0
- static/reports/Effusion2.txt +11 -0
- static/reports/Infection.txt +11 -0
- static/reports/Lymphadenopathy2.txt +11 -0
- static/reports/Nodule3.txt +14 -0
- static/reports/Nodule5.txt +14 -0
- static/reports_manifest.csv +7 -0
- templates/index.html +220 -0
- tests/test_cache_store.py +64 -0
- utils.py +39 -0
.dockerignore
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
__pycache__/
|
2 |
+
env.list
|
.gitattributes
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
32 |
+
*.xz 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 |
+
static/images/*.jpg filter=lfs diff=lfs merge=lfs -text
|
37 |
+
static/images/*.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
env.list
|
2 |
+
__pycache__
|
3 |
+
**/__pycache__
|
4 |
+
langextract/__pycache__
|
5 |
+
.git
|
Dockerfile
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Copyright 2025 Google LLC
|
2 |
+
#
|
3 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4 |
+
# you may not use this file except in compliance with the License.
|
5 |
+
# You may obtain a copy of the License at
|
6 |
+
#
|
7 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8 |
+
#
|
9 |
+
# Unless required by applicable law or agreed to in writing, software
|
10 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12 |
+
# See the License for the specific language governing permissions and
|
13 |
+
# limitations under the License.
|
14 |
+
|
15 |
+
FROM python:3.10-slim
|
16 |
+
|
17 |
+
# Set an environment variable for unbuffered python output (good for logging)
|
18 |
+
# and define the cache directory path
|
19 |
+
ENV PYTHONUNBUFFERED=1
|
20 |
+
ENV CACHE_DIR=/data/cache
|
21 |
+
|
22 |
+
# Install system dependencies first, as they change less frequently
|
23 |
+
RUN apt-get update && \
|
24 |
+
apt-get install -y fonts-ocr-a fonts-ocr-b unzip --no-install-recommends && \
|
25 |
+
rm -rf /var/lib/apt/lists/*
|
26 |
+
|
27 |
+
# Set up a new user named "user" with user ID 1000
|
28 |
+
# -m creates the home directory /home/user
|
29 |
+
# -s /bin/bash sets a default shell (good practice for debugging or execing into the container)
|
30 |
+
RUN useradd -m -s /bin/bash -u 1000 user
|
31 |
+
|
32 |
+
WORKDIR /app
|
33 |
+
|
34 |
+
COPY --chown=user:user . .
|
35 |
+
|
36 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
37 |
+
|
38 |
+
# Run tests after installing dependencies
|
39 |
+
RUN python -m unittest discover tests
|
40 |
+
|
41 |
+
RUN mkdir -p $CACHE_DIR
|
42 |
+
RUN chmod -R 777 $CACHE_DIR
|
43 |
+
RUN unzip -o ./default_cache/radexplain-cache.zip -d $CACHE_DIR
|
44 |
+
|
45 |
+
USER user
|
46 |
+
|
47 |
+
EXPOSE 7860
|
48 |
+
|
49 |
+
CMD ["gunicorn", \
|
50 |
+
"--bind", "0.0.0.0:7860", \
|
51 |
+
"--timeout", "600", \
|
52 |
+
"--worker-class", "gthread", \
|
53 |
+
"--workers", "1", \
|
54 |
+
"--threads", "4", \
|
55 |
+
"--preload", \
|
56 |
+
"app:app"]
|
README.md
ADDED
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
---
|
2 |
+
title: MedGemma - Radiology Explainer Demo
|
3 |
+
emoji: 🩺
|
4 |
+
colorFrom: blue
|
5 |
+
colorTo: green
|
6 |
+
sdk: docker
|
7 |
+
app_port: 7860
|
8 |
+
pinned: false
|
9 |
+
license: apache-2.0
|
10 |
+
short_description: Radiology Image & Report Explainer Demo - Buit with MedGemma
|
11 |
+
models:
|
12 |
+
- google/medgemma-4b-it
|
13 |
+
secrets:
|
14 |
+
- HF_TOKEN
|
15 |
+
---
|
16 |
+
|
17 |
+
# Radiology Image & Report Explainer Demo - Buit with MedGemma
|
18 |
+
|
19 |
+
Consider an educational scenario where interacting with a radiology image can
|
20 |
+
substantially improve learning. This demonstration shows how MedGemma might be built upon to provide a useful tool for exploring radiology images and associated reports by translating them into simple language, with visual cues to highlight the relevant areas of the image.
|
21 |
+
|
22 |
+
Powered by AI (MedGemma-4B Multimodel), this space analyzes both a sample radiology report and its corresponding Chest X-Ray/CT image. Click on any sentence in the report, and you'll receive an AI-generated explanation tailored to that specific text and visual context. When relevant, the explanation will also pinpoint the corresponding area on the X-ray/CT image.
|
23 |
+
|
24 |
+
This demonstration is for illustrative purposes only and does not represent a finished or approved product. It is not representative of compliance to any harmonized regulations or standards for quality, safety or efficacy. Any real-world application would require additional development, training, and adaptation. The experience highlighted in this demo shows MedGemma's baseline capability for the displayed task and is intended to help developers and users explore possible applications and inspire further development.
|
25 |
+
|
26 |
+
**Note:** This space uses a HuggingFace endpoint that may scale down to zero due to inactivity. If this occurs, please allow approximately 10 minutes for the endpoint to restart. As an alternative, the model can be deployed on ModelGarden (see the link below).
|
27 |
+
|
28 |
+
# Links
|
29 |
+
* MedGemma HuggingFace - https://huggingface.co/collections/google/medgemma-release-680aade845f90bec6a3f60c4
|
30 |
+
* MedGemma DevSite - https://developers.google.com/health-ai-developer-foundations/medgemma
|
31 |
+
* MedGemma ModelGarden - https://console.cloud.google.com/vertex-ai/publishers/google/model-garden/medgemma
|
32 |
+
* HAI-DEF models - https://developers.google.com/health-ai-developer-foundations
|
app.py
ADDED
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Copyright 2025 Google LLC
|
2 |
+
#
|
3 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4 |
+
# you may not use this file except in compliance with the License.
|
5 |
+
# You may obtain a copy of the License at
|
6 |
+
#
|
7 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8 |
+
#
|
9 |
+
# Unless required by applicable law or agreed to in writing, software
|
10 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12 |
+
# See the License for the specific language governing permissions and
|
13 |
+
# limitations under the License.
|
14 |
+
|
15 |
+
import os
|
16 |
+
import logging
|
17 |
+
import sys
|
18 |
+
from flask import Flask
|
19 |
+
|
20 |
+
# Import configurations and initializers first
|
21 |
+
import config # Use relative import assuming app.py is in the rad_explain package
|
22 |
+
import llm_client
|
23 |
+
from routes import main_bp
|
24 |
+
from cache_store import cache
|
25 |
+
|
26 |
+
def create_app():
|
27 |
+
"""Creates and configures the Flask application."""
|
28 |
+
app = Flask(__name__, static_folder=config.STATIC_DIR)
|
29 |
+
|
30 |
+
# --- Configure Logging ---
|
31 |
+
# Basic config should be done before creating the app or registering blueprints
|
32 |
+
# if those modules rely on logging during import time.
|
33 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - [%(name)s] - %(message)s')
|
34 |
+
logger = logging.getLogger(__name__)
|
35 |
+
|
36 |
+
# --- Check Configuration and Initialize Services ---
|
37 |
+
if not config.HF_TOKEN:
|
38 |
+
logger.error("HF_TOKEN environment variable not set.")
|
39 |
+
sys.exit("Exiting: HF_TOKEN not set.")
|
40 |
+
if not config.MEDGEMMA_ENDPOINT_URL:
|
41 |
+
logger.error("MEDGEMMA_ENDPOINT_URL environment variable not set.")
|
42 |
+
sys.exit("Exiting: MEDGEMMA_ENDPOINT_URL not set.")
|
43 |
+
else:
|
44 |
+
logger.info(f"Using LLM API Base URL: {config.MEDGEMMA_ENDPOINT_URL}")
|
45 |
+
|
46 |
+
# Initialize LLM Client
|
47 |
+
llm_client.init_llm_client()
|
48 |
+
if not llm_client.is_initialized():
|
49 |
+
logger.warning("LLM client failed to initialize. API calls will fail.")
|
50 |
+
sys.exit("Exiting: LLM client initialization failed.")
|
51 |
+
|
52 |
+
# Register Blueprints
|
53 |
+
app.register_blueprint(main_bp)
|
54 |
+
|
55 |
+
return app
|
56 |
+
|
57 |
+
# Create the application instance using the factory function
|
58 |
+
# This makes the 'app' instance available at the module level for WSGI servers
|
59 |
+
app = create_app()
|
60 |
+
|
61 |
+
if __name__ == '__main__':
|
62 |
+
# This block now primarily focuses on running the app and pre-run checks/setup
|
63 |
+
logger = logging.getLogger(__name__)
|
64 |
+
|
65 |
+
# Run the Flask development server
|
66 |
+
app.run(host='0.0.0.0', port=7860, debug=True)
|
cache_store.py
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Copyright 2025 Google LLC
|
2 |
+
#
|
3 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4 |
+
# you may not use this file except in compliance with the License.
|
5 |
+
# You may obtain a copy of the License at
|
6 |
+
#
|
7 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8 |
+
#
|
9 |
+
# Unless required by applicable law or agreed to in writing, software
|
10 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12 |
+
# See the License for the specific language governing permissions and
|
13 |
+
# limitations under the License.
|
14 |
+
|
15 |
+
import diskcache
|
16 |
+
import os
|
17 |
+
|
18 |
+
# Use the CACHE_DIR environment variable, defaulting to /app/cache_dir if not set.
|
19 |
+
cache_directory = os.getenv('CACHE_DIR', '/app/cache_dir')
|
20 |
+
cache = diskcache.Cache(cache_directory)
|
config.py
ADDED
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Copyright 2025 Google LLC
|
2 |
+
#
|
3 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4 |
+
# you may not use this file except in compliance with the License.
|
5 |
+
# You may obtain a copy of the License at
|
6 |
+
#
|
7 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8 |
+
#
|
9 |
+
# Unless required by applicable law or agreed to in writing, software
|
10 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12 |
+
# See the License for the specific language governing permissions and
|
13 |
+
# limitations under the License.
|
14 |
+
|
15 |
+
import os
|
16 |
+
from pathlib import Path
|
17 |
+
import csv # Import the csv module
|
18 |
+
import logging
|
19 |
+
|
20 |
+
# --- Configuration ---
|
21 |
+
# Configure basic logging (optional, adjust as needed)
|
22 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
23 |
+
logger = logging.getLogger(__name__)
|
24 |
+
|
25 |
+
# API Configuration
|
26 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", None)
|
27 |
+
MEDGEMMA_ENDPOINT_URL = os.environ.get("MEDGEMMA_ENDPOINT_URL", None)
|
28 |
+
|
29 |
+
# --- Paths using pathlib ---
|
30 |
+
# Determine the base directory of the application
|
31 |
+
BASE_DIR = Path(__file__).parent.resolve() # Use resolve() for absolute path
|
32 |
+
STATIC_DIR = BASE_DIR / 'static'
|
33 |
+
|
34 |
+
# --- Load available report/image pairs from CSV ---
|
35 |
+
AVAILABLE_REPORTS = []
|
36 |
+
MANIFEST_CSV_PATH = STATIC_DIR / 'reports_manifest.csv'
|
37 |
+
|
38 |
+
if MANIFEST_CSV_PATH.is_file():
|
39 |
+
try:
|
40 |
+
with open(MANIFEST_CSV_PATH, mode='r', encoding='utf-8') as csvfile:
|
41 |
+
reader = csv.DictReader(csvfile)
|
42 |
+
# Expected CSV headers: 'image_type', 'case_display_name', 'image_path', 'report_path'
|
43 |
+
required_headers = {'case_display_name', 'image_path', 'report_path'}
|
44 |
+
if not required_headers.issubset(reader.fieldnames):
|
45 |
+
logger.error(
|
46 |
+
f"CSV file {MANIFEST_CSV_PATH} is missing one or more required headers: {required_headers - set(reader.fieldnames)}"
|
47 |
+
)
|
48 |
+
else:
|
49 |
+
for row in reader:
|
50 |
+
case_name = row['case_display_name']
|
51 |
+
image_path_from_csv = row['image_path'] # e.g., static/images/report1.jpg
|
52 |
+
report_path_from_csv = row['report_path'] # e.g., static/reports/report1.txt or empty
|
53 |
+
|
54 |
+
# Validate image_path_from_csv (must not be empty)
|
55 |
+
if not image_path_from_csv:
|
56 |
+
logger.warning(f"Empty image_path in CSV for case '{case_name}'. Skipping this entry.")
|
57 |
+
continue
|
58 |
+
|
59 |
+
# Construct absolute path for image file validation (paths from CSV are relative to BASE_DIR)
|
60 |
+
abs_image_path_to_check = BASE_DIR / image_path_from_csv
|
61 |
+
if not abs_image_path_to_check.is_file():
|
62 |
+
logger.warning(f"Image file not found for case '{case_name}' at '{abs_image_path_to_check}'. Skipping this entry.")
|
63 |
+
continue
|
64 |
+
|
65 |
+
|
66 |
+
image_file_for_config = image_path_from_csv
|
67 |
+
|
68 |
+
report_file_for_config = "" # Default to empty if no report or error
|
69 |
+
if report_path_from_csv: # Report path is optional
|
70 |
+
# Construct absolute path for report file validation (paths from CSV are relative to BASE_DIR)
|
71 |
+
abs_report_path_to_check = BASE_DIR / report_path_from_csv
|
72 |
+
if not abs_report_path_to_check.is_file():
|
73 |
+
logger.warning(
|
74 |
+
f"Report file specified for case '{case_name}' at '{abs_report_path_to_check}' not found. "
|
75 |
+
f"Proceeding without report file for this entry."
|
76 |
+
)
|
77 |
+
# report_file_for_config remains ""
|
78 |
+
else:
|
79 |
+
# The file (BASE_DIR / report_path_from_csv) exists.
|
80 |
+
# Now, ensure report_path_from_csv string itself starts with "static/"
|
81 |
+
# as per the assumption about CSV content.
|
82 |
+
if report_path_from_csv.startswith('static/') or report_path_from_csv.startswith('static\\'):
|
83 |
+
# Path is well-formed (starts with static/) and file exists.
|
84 |
+
# Store the path as is (e.g., "static/reports/report1.txt").
|
85 |
+
report_file_for_config = report_path_from_csv
|
86 |
+
else:
|
87 |
+
logger.warning(
|
88 |
+
f"Report path '{report_path_from_csv}' for case '{case_name}' in CSV "
|
89 |
+
f"is malformed (does not start with 'static/'). Treating as if no report path was specified."
|
90 |
+
)
|
91 |
+
# report_file_for_config remains ""
|
92 |
+
AVAILABLE_REPORTS.append({
|
93 |
+
"name": case_name,
|
94 |
+
"image_file": image_file_for_config, # static/images/report1.jpg
|
95 |
+
"report_file": report_file_for_config, # static/reports/report1.txt or ""
|
96 |
+
"image_type": row['image_type']
|
97 |
+
})
|
98 |
+
AVAILABLE_REPORTS.sort(key=lambda x: x['name'])
|
99 |
+
logger.info(f"Loaded {len(AVAILABLE_REPORTS)} report/image pairs from CSV.")
|
100 |
+
|
101 |
+
except Exception as e:
|
102 |
+
logger.error(f"Error reading or processing CSV file {MANIFEST_CSV_PATH}: {e}", exc_info=True)
|
103 |
+
else:
|
104 |
+
logger.warning(f"Manifest CSV file not found at {MANIFEST_CSV_PATH}. AVAILABLE_REPORTS will be empty.")
|
105 |
+
|
106 |
+
# --- Optional: Define a default report if needed ---
|
107 |
+
DEFAULT_REPORT_INFO = AVAILABLE_REPORTS[0] if AVAILABLE_REPORTS else None
|
default_cache/radexplain-cache.zip
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
version https://git-lfs.github.com/spec/v1
|
2 |
+
oid sha256:7afdbd67d67fa2b4eaa505842758a9eaf99b47b5f07940a3c711cef844ff56d6
|
3 |
+
size 28427
|
llm_client.py
ADDED
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Copyright 2025 Google LLC
|
2 |
+
#
|
3 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4 |
+
# you may not use this file except in compliance with the License.
|
5 |
+
# You may obtain a copy of the License at
|
6 |
+
#
|
7 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8 |
+
#
|
9 |
+
# Unless required by applicable law or agreed to in writing, software
|
10 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12 |
+
# See the License for the specific language governing permissions and
|
13 |
+
# limitations under the License.
|
14 |
+
|
15 |
+
import logging
|
16 |
+
import requests
|
17 |
+
import config
|
18 |
+
|
19 |
+
logger = logging.getLogger(__name__)
|
20 |
+
|
21 |
+
_api_key = None
|
22 |
+
_endpoint_url = None
|
23 |
+
_initialized = False
|
24 |
+
|
25 |
+
def init_llm_client():
|
26 |
+
"""Initializes LLM client configuration by loading from config."""
|
27 |
+
global _api_key, _endpoint_url, _initialized
|
28 |
+
if config.HF_TOKEN and config.MEDGEMMA_ENDPOINT_URL:
|
29 |
+
_api_key = config.HF_TOKEN
|
30 |
+
_endpoint_url = config.MEDGEMMA_ENDPOINT_URL
|
31 |
+
_initialized = True
|
32 |
+
logger.info("LLM client configured successfully.")
|
33 |
+
else:
|
34 |
+
_api_key = None
|
35 |
+
_endpoint_url = None
|
36 |
+
logger.error("LLM client could not be configured due to missing API key or endpoint URL.")
|
37 |
+
|
38 |
+
def is_initialized():
|
39 |
+
return _initialized
|
40 |
+
|
41 |
+
def make_chat_completion_request(
|
42 |
+
model: str,
|
43 |
+
messages: list,
|
44 |
+
temperature: float,
|
45 |
+
max_tokens: int,
|
46 |
+
stream: bool,
|
47 |
+
top_p: float | None = None,
|
48 |
+
seed: int | None = None,
|
49 |
+
stop: list[str] | str | None = None,
|
50 |
+
frequency_penalty: float | None = None,
|
51 |
+
presence_penalty: float | None = None
|
52 |
+
):
|
53 |
+
"""
|
54 |
+
Makes a chat completion request to the configured LLM API.
|
55 |
+
"""
|
56 |
+
if not _initialized:
|
57 |
+
logger.error("LLM client not initialized.")
|
58 |
+
raise RuntimeError("LLM client not initialized.")
|
59 |
+
|
60 |
+
headers = {
|
61 |
+
"Authorization": f"Bearer {_api_key}",
|
62 |
+
"Content-Type": "application/json",
|
63 |
+
}
|
64 |
+
payload = {
|
65 |
+
"model": model,
|
66 |
+
"messages": messages,
|
67 |
+
"temperature": temperature,
|
68 |
+
"max_tokens": max_tokens,
|
69 |
+
"stream": stream,
|
70 |
+
}
|
71 |
+
if top_p is not None: payload["top_p"] = top_p
|
72 |
+
if seed is not None: payload["seed"] = seed
|
73 |
+
if stop is not None: payload["stop"] = stop
|
74 |
+
if frequency_penalty is not None: payload["frequency_penalty"] = frequency_penalty
|
75 |
+
if presence_penalty is not None: payload["presence_penalty"] = presence_penalty
|
76 |
+
|
77 |
+
temp_url = _endpoint_url.rstrip('/')
|
78 |
+
if temp_url.endswith("/v1/chat/completions"):
|
79 |
+
full_url = temp_url
|
80 |
+
elif temp_url.endswith("/v1"):
|
81 |
+
full_url = temp_url + "/chat/completions"
|
82 |
+
else:
|
83 |
+
full_url = temp_url + "/v1/chat/completions"
|
84 |
+
|
85 |
+
response = requests.post(full_url, headers=headers, json=payload, stream=stream, timeout=60)
|
86 |
+
response.raise_for_status()
|
87 |
+
return response
|
requirements.txt
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
flask
|
2 |
+
gunicorn
|
3 |
+
Pillow
|
4 |
+
diskcache
|
5 |
+
requests
|
routes.py
ADDED
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Copyright 2025 Google LLC
|
2 |
+
#
|
3 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4 |
+
# you may not use this file except in compliance with the License.
|
5 |
+
# You may obtain a copy of the License at
|
6 |
+
#
|
7 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8 |
+
#
|
9 |
+
# Unless required by applicable law or agreed to in writing, software
|
10 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12 |
+
# See the License for the specific language governing permissions and
|
13 |
+
# limitations under the License.
|
14 |
+
|
15 |
+
import logging
|
16 |
+
from flask import Blueprint, render_template, request, jsonify, send_from_directory
|
17 |
+
from pathlib import Path
|
18 |
+
import shutil # For zipping the cache directory
|
19 |
+
import json # For parsing streamed JSON data
|
20 |
+
|
21 |
+
import os
|
22 |
+
import config
|
23 |
+
import utils
|
24 |
+
from llm_client import make_chat_completion_request, is_initialized as llm_is_initialized
|
25 |
+
from cache_store import cache
|
26 |
+
from cache_store import cache_directory
|
27 |
+
import requests
|
28 |
+
|
29 |
+
logger = logging.getLogger(__name__)
|
30 |
+
|
31 |
+
main_bp = Blueprint('main', __name__)
|
32 |
+
|
33 |
+
# LLM client is initialized in app.py create_app()
|
34 |
+
|
35 |
+
# --- Serve the cache directory as a zip file ---
|
36 |
+
@main_bp.route('/download_cache')
|
37 |
+
def download_cache_zip():
|
38 |
+
"""Zips the cache directory and serves it for download."""
|
39 |
+
zip_filename = "radexplain-cache.zip"
|
40 |
+
# Create the zip file in a temporary directory
|
41 |
+
# Using /tmp is common in containerized environments
|
42 |
+
temp_dir = "/tmp"
|
43 |
+
zip_base_path = os.path.join(temp_dir, "radexplain-cache") # shutil adds .zip
|
44 |
+
zip_filepath = zip_base_path + ".zip"
|
45 |
+
|
46 |
+
# Ensure the cache directory exists before trying to zip it
|
47 |
+
if not os.path.isdir(cache_directory):
|
48 |
+
logger.error(f"Cache directory not found at {cache_directory}")
|
49 |
+
return jsonify({"error": f"Cache directory not found on server: {cache_directory}"}), 500
|
50 |
+
|
51 |
+
try:
|
52 |
+
logger.info(f"Creating zip archive of cache directory: {cache_directory} to {zip_filepath}")
|
53 |
+
shutil.make_archive(
|
54 |
+
zip_base_path, # This is the base name, shutil adds the .zip extension
|
55 |
+
"zip",
|
56 |
+
cache_directory, # This is the root directory to archive
|
57 |
+
)
|
58 |
+
logger.info("Zip archive created successfully.")
|
59 |
+
# Send the file and then clean it up
|
60 |
+
return send_from_directory(temp_dir, zip_filename, as_attachment=True)
|
61 |
+
except Exception as e:
|
62 |
+
logger.error(f"Error creating or sending zip archive of cache directory: {e}", exc_info=True)
|
63 |
+
return jsonify({"error": f"Error creating or sending zip archive: {e}"}), 500
|
64 |
+
@main_bp.route('/')
|
65 |
+
def index():
|
66 |
+
"""Serves the main HTML page."""
|
67 |
+
# The backend now only provides the list of available reports.
|
68 |
+
# The frontend will be responsible for selecting a report,
|
69 |
+
# fetching its details (text, image path), and managing the current state.
|
70 |
+
if not config.AVAILABLE_REPORTS:
|
71 |
+
logger.warning("No reports found in config. AVAILABLE_REPORTS is empty.")
|
72 |
+
|
73 |
+
return render_template(
|
74 |
+
'index.html',
|
75 |
+
available_reports=config.AVAILABLE_REPORTS
|
76 |
+
)
|
77 |
+
|
78 |
+
@main_bp.route('/get_report_details/<report_name>')
|
79 |
+
def get_report_details(report_name):
|
80 |
+
"""Fetches the text content and image path for a given report name."""
|
81 |
+
selected_report_info = next((item for item in config.AVAILABLE_REPORTS if item['name'] == report_name), None)
|
82 |
+
|
83 |
+
if not selected_report_info:
|
84 |
+
logger.error(f"Report '{report_name}' not found when fetching details.")
|
85 |
+
return jsonify({"error": f"Report '{report_name}' not found."}), 404
|
86 |
+
|
87 |
+
report_file = selected_report_info.get('report_file')
|
88 |
+
image_file = selected_report_info.get('image_file')
|
89 |
+
|
90 |
+
report_text_content = "" # Default to empty if no report file is configured.
|
91 |
+
|
92 |
+
if report_file:
|
93 |
+
actual_server_report_path = config.BASE_DIR / report_file
|
94 |
+
|
95 |
+
try:
|
96 |
+
report_text_content = actual_server_report_path.read_text(encoding='utf-8').strip()
|
97 |
+
except Exception as e:
|
98 |
+
logger.error(f"Error reading report file {actual_server_report_path} for report '{report_name}': {e}", exc_info=True)
|
99 |
+
return jsonify({"error": "Error reading report file."}), 500
|
100 |
+
# If report_file was empty, report_text_content remains "".
|
101 |
+
|
102 |
+
image_type_from_config = selected_report_info.get('image_type')
|
103 |
+
display_image_type = 'Chest X-Ray' if image_type_from_config == 'CXR' else ('CT' if image_type_from_config == 'CT' else 'Medical Image')
|
104 |
+
|
105 |
+
return jsonify({"text": report_text_content, "image_file": image_file, "image_type": display_image_type})
|
106 |
+
|
107 |
+
|
108 |
+
|
109 |
+
@main_bp.route('/explain', methods=['POST'])
|
110 |
+
def explain_sentence():
|
111 |
+
"""Handles the explanation request using LLM API with base64 encoded image."""
|
112 |
+
if not llm_is_initialized():
|
113 |
+
logger.error("LLM client (REST API) not initialized. Cannot process request.")
|
114 |
+
return jsonify({"error": "LLM client (REST API) not initialized. Check API key and base URL."}), 500
|
115 |
+
|
116 |
+
data = request.get_json()
|
117 |
+
if not data or 'sentence' not in data or 'report_name' not in data:
|
118 |
+
logger.warning("Missing 'sentence' or 'report_name' in request payload.")
|
119 |
+
return jsonify({"error": "Missing 'sentence' or 'report_name' in request"}), 400
|
120 |
+
|
121 |
+
selected_sentence = data['sentence']
|
122 |
+
report_name = data['report_name']
|
123 |
+
logger.info(f"Received request to explain: '{selected_sentence}' for report: '{report_name}'")
|
124 |
+
|
125 |
+
# --- Find the selected report info ---
|
126 |
+
selected_report_info = next((item for item in config.AVAILABLE_REPORTS if item['name'] == report_name), None)
|
127 |
+
|
128 |
+
if not selected_report_info:
|
129 |
+
logger.error(f"Report '{report_name}' not found in available reports.")
|
130 |
+
return jsonify({"error": f"Report '{report_name}' not found."}), 404
|
131 |
+
|
132 |
+
image_file = selected_report_info.get('image_file')
|
133 |
+
report_file = selected_report_info.get('report_file')
|
134 |
+
image_type = selected_report_info.get('image_type')
|
135 |
+
|
136 |
+
if not image_file:
|
137 |
+
logger.error(f"Image or report file path (relative to static) missing in config for report '{report_name}'.")
|
138 |
+
return jsonify({"error": f"File configuration missing for report '{report_name}'."}), 500
|
139 |
+
|
140 |
+
# Construct absolute server paths using BASE_DIR as image_file and report_file include "static/"
|
141 |
+
server_image_path = config.BASE_DIR / image_file
|
142 |
+
|
143 |
+
|
144 |
+
# --- Prepare Base64 Image for API ---
|
145 |
+
if not server_image_path.is_file():
|
146 |
+
logger.error(f"Image file not found at {server_image_path}")
|
147 |
+
return jsonify({"error": f"Image file for report '{report_name}' not found on server."}), 500
|
148 |
+
|
149 |
+
base64_image_data_url = utils.image_to_base64_data_url(str(server_image_path))
|
150 |
+
if not base64_image_data_url:
|
151 |
+
logger.error("Failed to encode image to base64.")
|
152 |
+
return jsonify({"error": "Could not encode image for API request"}), 500
|
153 |
+
|
154 |
+
logger.info("Image successfully encoded to base64 data URL for API.")
|
155 |
+
|
156 |
+
full_report_text = ""
|
157 |
+
if report_file: # Only attempt to read if a report file is configured
|
158 |
+
server_report_path = config.BASE_DIR / report_file
|
159 |
+
try:
|
160 |
+
full_report_text = server_report_path.read_text(encoding='utf-8')
|
161 |
+
except FileNotFoundError:
|
162 |
+
logger.error(f"Report file not found at {server_report_path}")
|
163 |
+
return jsonify({"error": f"Report file for '{report_name}' not found on server."}), 500
|
164 |
+
except Exception as e:
|
165 |
+
logger.error(f"Error reading report file {server_report_path}: {e}", exc_info=True)
|
166 |
+
return jsonify({"error": "Error reading report file."}), 500
|
167 |
+
else: # If report_file is not configured (e.g. empty string from selected_report_info)
|
168 |
+
logger.info(f"No report file configured for report '{report_name}'. Proceeding without full report text for system prompt.")
|
169 |
+
|
170 |
+
system_prompt = (
|
171 |
+
"You are a public-facing clinician. "
|
172 |
+
f"A learning user has provided a sentence from a radiology report and is viewing the accompanying {image_type} image. "
|
173 |
+
"Your task is to explain the meaning of ONLY the provided sentence in simple, clear terms. Explain terminology and abbriviations. Keep it concise. "
|
174 |
+
"Directly address the meaning of the sentence. Do not use introductory phrases like 'Okay' or refer to the sentence itself or the report itself (e.g., 'This sentence means...'). " # noqa: E501
|
175 |
+
f"{f'Crucially, since the user is looking at their {image_type} image, provide guidance on where to look on the image to understand your explanation, if applicable. ' if image_type != 'CT' else ''}"
|
176 |
+
"Do not discuss any other part of the report or any sentences not explicitly provided by the user. Stick to facts in the text. Do not infer anything. \n"
|
177 |
+
"===\n"
|
178 |
+
f"For context, the full REPORT is:\n{full_report_text}"
|
179 |
+
)
|
180 |
+
user_prompt_text = f"Explain this sentence from the radiology report: '{selected_sentence}'"
|
181 |
+
|
182 |
+
messages_for_api = [
|
183 |
+
{"role": "system", "content": system_prompt},
|
184 |
+
{
|
185 |
+
"role": "user",
|
186 |
+
"content": [
|
187 |
+
{"type": "text", "text": user_prompt_text}
|
188 |
+
]
|
189 |
+
}
|
190 |
+
]
|
191 |
+
|
192 |
+
cache_key = f"explain::{report_name}::{selected_sentence}"
|
193 |
+
cached_result = cache.get(cache_key)
|
194 |
+
if cached_result:
|
195 |
+
logger.info("Returning cached explanation.")
|
196 |
+
return jsonify({"explanation": cached_result})
|
197 |
+
|
198 |
+
try:
|
199 |
+
logger.info("Sending request to LLM API (REST) with base64 image...")
|
200 |
+
response = make_chat_completion_request(
|
201 |
+
model="tgi",
|
202 |
+
messages=messages_for_api,
|
203 |
+
top_p=None,
|
204 |
+
temperature=0,
|
205 |
+
max_tokens=250,
|
206 |
+
stream=True,
|
207 |
+
seed=None,
|
208 |
+
stop=None,
|
209 |
+
frequency_penalty=None,
|
210 |
+
presence_penalty=None
|
211 |
+
)
|
212 |
+
logger.info("Received response stream from LLM API (REST).")
|
213 |
+
|
214 |
+
explanation_parts = []
|
215 |
+
for line in response.iter_lines():
|
216 |
+
if line:
|
217 |
+
decoded_line = line.decode('utf-8')
|
218 |
+
if decoded_line.startswith('data: '):
|
219 |
+
json_data_str = decoded_line[len('data: '):].strip()
|
220 |
+
if json_data_str == "[DONE]":
|
221 |
+
break
|
222 |
+
try:
|
223 |
+
chunk = json.loads(json_data_str)
|
224 |
+
if chunk.get("choices") and chunk["choices"][0].get("delta") and chunk["choices"][0]["delta"].get("content"):
|
225 |
+
explanation_parts.append(chunk["choices"][0]["delta"]["content"])
|
226 |
+
except json.JSONDecodeError:
|
227 |
+
logger.warning(f"Could not decode JSON from stream chunk: {json_data_str}")
|
228 |
+
# Depending on API, might need to handle partial JSON or other errors
|
229 |
+
elif decoded_line.strip() == "[DONE]": # Some APIs might send [DONE] without "data: "
|
230 |
+
break
|
231 |
+
|
232 |
+
explanation = "".join(explanation_parts).strip()
|
233 |
+
if explanation:
|
234 |
+
cache.set(cache_key, explanation, expire=None)
|
235 |
+
|
236 |
+
logger.info("Explanation generated successfully." if explanation else "Empty explanation from API.")
|
237 |
+
return jsonify({"explanation": explanation or "No explanation content received from the API."})
|
238 |
+
except requests.exceptions.RequestException as e:
|
239 |
+
logger.error(f"Error during LLM API (REST) call: {e}", exc_info=True)
|
240 |
+
user_error_message = ("Failed to generate explanation. The service might be temporarily unavailable "
|
241 |
+
"and is now likely starting up. Please try again in a few moments.")
|
242 |
+
return jsonify({"error": user_error_message}), 500
|
run_local.sh
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/bin/bash
|
2 |
+
# Copyright 2025 Google LLC
|
3 |
+
#
|
4 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
# you may not use this file except in compliance with the License.
|
6 |
+
# You may obtain a copy of the License at
|
7 |
+
#
|
8 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
#
|
10 |
+
# Unless required by applicable law or agreed to in writing, software
|
11 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
# See the License for the specific language governing permissions and
|
14 |
+
# limitations under the License.
|
15 |
+
|
16 |
+
set -e
|
17 |
+
APP_NAME="radiology-report-explain"
|
18 |
+
HOST_APP_DIR="$(pwd)"
|
19 |
+
CONTAINER_APP_DIR="/app"
|
20 |
+
|
21 |
+
docker rm $APP_NAME || true
|
22 |
+
|
23 |
+
# Build the Docker image
|
24 |
+
echo "Building Docker image..."
|
25 |
+
docker build -t radiology-report-explain .
|
26 |
+
|
27 |
+
# Check if the build was successful
|
28 |
+
if [ $? -ne 0 ]; then
|
29 |
+
echo "Docker build failed!"
|
30 |
+
exit 1
|
31 |
+
fi
|
32 |
+
|
33 |
+
# Run the Docker container with a volume
|
34 |
+
echo "Running Docker container with volume (attached)..."
|
35 |
+
docker run \
|
36 |
+
-p 7860:7860 \
|
37 |
+
--env-file env.list \
|
38 |
+
--name "$APP_NAME" \
|
39 |
+
-v "$HOST_APP_DIR:$CONTAINER_APP_DIR" \
|
40 |
+
-u $(id -u):$(id -g) \
|
41 |
+
radiology-report-explain
|
42 |
+
|
43 |
+
# The script will block here, showing the container's output.
|
44 |
+
# To stop the container, press Ctrl+C in this terminal.
|
45 |
+
|
46 |
+
# The following code will only run after the container exits (e.g., Ctrl+C)
|
47 |
+
echo "Docker container has exited."
|
48 |
+
echo "To remove the container, run: docker rm $APP_NAME"
|
static/css/style.css
ADDED
@@ -0,0 +1,595 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2025 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
/* Basic Reset & Body Style */
|
18 |
+
html {
|
19 |
+
height: 100%;
|
20 |
+
--image-fixed-width: 280px;
|
21 |
+
}
|
22 |
+
|
23 |
+
body {
|
24 |
+
font-family: sans-serif;
|
25 |
+
line-height: 1.6;
|
26 |
+
background-color: #f4f4f4;
|
27 |
+
color: #333;
|
28 |
+
margin: 0;
|
29 |
+
display: flex;
|
30 |
+
flex-direction: column;
|
31 |
+
user-select: none;
|
32 |
+
}
|
33 |
+
|
34 |
+
.main {
|
35 |
+
display: none; /*display: grid;*/
|
36 |
+
grid-template-columns: 1fr minmax(500px, 800px) minmax(300px, 1fr);
|
37 |
+
grid-template-rows: auto;
|
38 |
+
column-gap: 20px;
|
39 |
+
row-gap: 10px;
|
40 |
+
padding: 30px;
|
41 |
+
}
|
42 |
+
|
43 |
+
h1,
|
44 |
+
h2 {
|
45 |
+
color: #333;
|
46 |
+
}
|
47 |
+
|
48 |
+
.page-header h1 {
|
49 |
+
margin: 0;
|
50 |
+
}
|
51 |
+
|
52 |
+
.container {
|
53 |
+
display: flex;
|
54 |
+
flex-wrap: wrap;
|
55 |
+
gap: 25px;
|
56 |
+
padding: 20px;
|
57 |
+
flex-grow: 1;
|
58 |
+
justify-content: center;
|
59 |
+
}
|
60 |
+
|
61 |
+
/* Top Navigation Styles */
|
62 |
+
.top-navigation {
|
63 |
+
display: flex;
|
64 |
+
align-items: center;
|
65 |
+
padding-top: 30px;
|
66 |
+
gap: 15px; /* Space between back button and case tabs */
|
67 |
+
border-bottom: 1px solid #E0E0E0; /* Optional: a subtle separator */
|
68 |
+
margin-bottom: 10px;
|
69 |
+
align-items: flex-start;
|
70 |
+
justify-content: center;
|
71 |
+
}
|
72 |
+
|
73 |
+
.nav-button {
|
74 |
+
display: inline-flex; /* Changed from inline-flex on parent to allow direct styling */
|
75 |
+
justify-content: center;
|
76 |
+
align-items: center;
|
77 |
+
cursor: pointer;
|
78 |
+
user-select: none;
|
79 |
+
}
|
80 |
+
|
81 |
+
.nav-button-inner {
|
82 |
+
overflow: hidden;
|
83 |
+
border-radius: 100px; /* Very rounded corners */
|
84 |
+
outline: 1px solid #C4C7C5; /* Outline color */
|
85 |
+
outline-offset: -1px; /* Outline inside */
|
86 |
+
display: flex;
|
87 |
+
justify-content: center;
|
88 |
+
align-items: center;
|
89 |
+
padding: 6px 12px;
|
90 |
+
gap: 8px; /* Increased gap slightly for better visual separation */
|
91 |
+
transition: background-color 0.2s ease;
|
92 |
+
}
|
93 |
+
|
94 |
+
.nav-button-icon {
|
95 |
+
font-size: 20px; /* Icon size */
|
96 |
+
color: #444746; /* Icon color */
|
97 |
+
display: flex;
|
98 |
+
align-items: center;
|
99 |
+
justify-content: center;
|
100 |
+
}
|
101 |
+
|
102 |
+
.nav-button-text {
|
103 |
+
color: #444746; /* Text color */
|
104 |
+
font-size: 14px;
|
105 |
+
font-family: 'Google Sans Text', sans-serif;
|
106 |
+
font-weight: 500;
|
107 |
+
line-height: 20px;
|
108 |
+
white-space: nowrap;
|
109 |
+
}
|
110 |
+
|
111 |
+
.nav-button-case.active .nav-button-inner,
|
112 |
+
.nav-button:hover .nav-button-inner {
|
113 |
+
background-color: rgba(68, 71, 70, 0.10); /* Focused/hover background */
|
114 |
+
}
|
115 |
+
|
116 |
+
.nav-button-info {
|
117 |
+
color: #004A77;
|
118 |
+
margin: 0 20px;
|
119 |
+
}
|
120 |
+
|
121 |
+
|
122 |
+
/* Column Styles */
|
123 |
+
.report-section,
|
124 |
+
.image-section,
|
125 |
+
.explanation-section {
|
126 |
+
position: relative;
|
127 |
+
float: right;
|
128 |
+
}
|
129 |
+
|
130 |
+
.image-section {
|
131 |
+
justify-content: center;
|
132 |
+
display: flex;
|
133 |
+
flex-direction: column;
|
134 |
+
justify-self: end;
|
135 |
+
}
|
136 |
+
|
137 |
+
.report-section {
|
138 |
+
flex: 1;
|
139 |
+
max-width: 800px;
|
140 |
+
}
|
141 |
+
|
142 |
+
.image-header {
|
143 |
+
text-align: center;
|
144 |
+
color: black;
|
145 |
+
font-size: 22px;
|
146 |
+
font-family: Google Sans;
|
147 |
+
font-weight: 400;
|
148 |
+
line-height: 28px;
|
149 |
+
word-wrap: break-word;
|
150 |
+
margin: 5px;
|
151 |
+
}
|
152 |
+
|
153 |
+
.case-selector-tabs-container {
|
154 |
+
display: grid;
|
155 |
+
justify-content: center;
|
156 |
+
align-items: center;
|
157 |
+
grid-template-columns: min-content auto;
|
158 |
+
column-gap: 20px;
|
159 |
+
row-gap: 10px;
|
160 |
+
padding-left: 20px;
|
161 |
+
white-space: nowrap;
|
162 |
+
}
|
163 |
+
|
164 |
+
.case-selector-tabs-modality-container {
|
165 |
+
display: flex;
|
166 |
+
align-items: center;
|
167 |
+
width: 100%;
|
168 |
+
justify-content: flex-start;
|
169 |
+
}
|
170 |
+
|
171 |
+
.case-selector-tabs {
|
172 |
+
display: flex;
|
173 |
+
flex-wrap: wrap;
|
174 |
+
}
|
175 |
+
|
176 |
+
/* Report Text & Sentences */
|
177 |
+
.report-text-area {
|
178 |
+
padding: 30px;
|
179 |
+
overflow-y: auto;
|
180 |
+
background-color: #fdfdfd;
|
181 |
+
border-radius: 28px;
|
182 |
+
border: 2px solid #E9E9E9;
|
183 |
+
}
|
184 |
+
|
185 |
+
/* Styles for clickable sentences within the report text area */
|
186 |
+
.report-sentence {
|
187 |
+
cursor: pointer;
|
188 |
+
padding: 2px 0;
|
189 |
+
transition: background-color 0.2s ease;
|
190 |
+
border-radius: 14.272px;
|
191 |
+
border: 1.359px solid #F1E161;
|
192 |
+
}
|
193 |
+
|
194 |
+
.report-sentence:hover {
|
195 |
+
background: #F1E161;
|
196 |
+
mix-blend-mode: multiply;
|
197 |
+
}
|
198 |
+
|
199 |
+
.report-sentence.selected-sentence {
|
200 |
+
background: #F1E161;
|
201 |
+
mix-blend-mode: multiply;
|
202 |
+
}
|
203 |
+
|
204 |
+
/* Image Section */
|
205 |
+
.image-container {
|
206 |
+
position: relative;
|
207 |
+
border: 1px solid #ccc;
|
208 |
+
min-height: 100px;
|
209 |
+
display: flex;
|
210 |
+
flex-direction: column;
|
211 |
+
background-color: #f0f0f0;
|
212 |
+
margin: 0 auto; /* Keep auto margin for centering if desired */
|
213 |
+
width: var(--image-fixed-width);
|
214 |
+
}
|
215 |
+
|
216 |
+
.image-container img {
|
217 |
+
display: block;
|
218 |
+
max-width: 100%;
|
219 |
+
height: auto;
|
220 |
+
}
|
221 |
+
|
222 |
+
#report-image {
|
223 |
+
width: var(--image-fixed-width);
|
224 |
+
min-width: var(--image-fixed-width); /* Ensure min-width also uses the variable if it's meant to be the same */
|
225 |
+
}
|
226 |
+
|
227 |
+
#image-loading {
|
228 |
+
width: var(--image-fixed-width);
|
229 |
+
}
|
230 |
+
|
231 |
+
/* Note specific to CT images, displayed under the image */
|
232 |
+
#ct-image-note {
|
233 |
+
display: none; /* Initially hidden, controlled by JavaScript */
|
234 |
+
text-align: center;
|
235 |
+
margin-top: 10px;
|
236 |
+
font-size: 0.9em;
|
237 |
+
color: #555;
|
238 |
+
padding: 0 10px; /* Add some padding so text doesn't touch edges if it wraps */
|
239 |
+
word-wrap: break-word; /* Ensures text wraps to prevent overflow */
|
240 |
+
width: var(--image-fixed-width); /* Same width as the image */
|
241 |
+
}
|
242 |
+
|
243 |
+
.marker {
|
244 |
+
position: absolute;
|
245 |
+
transform: translate(-50%, -50%);
|
246 |
+
pointer-events: none;
|
247 |
+
display: none;
|
248 |
+
transition: top 0.3s ease-in-out, left 0.3s ease-in-out;
|
249 |
+
width: max-content;
|
250 |
+
}
|
251 |
+
|
252 |
+
/* Explanation Section */
|
253 |
+
.explanation-box {
|
254 |
+
border: 1px solid #eee;
|
255 |
+
min-height: 50px;
|
256 |
+
background-color: #F1E161;
|
257 |
+
box-shadow: 0px 5px 5px 0px rgba(0, 0, 0, 0.25);
|
258 |
+
position: absolute;
|
259 |
+
left: -20px;
|
260 |
+
top: 0px;
|
261 |
+
width: 300px;
|
262 |
+
transition: top 0.3s ease-in-out, background-color 0.3s ease, height 0.3s ease;
|
263 |
+
}
|
264 |
+
|
265 |
+
.explanation-header {
|
266 |
+
font-size: 22px;
|
267 |
+
font-style: normal;
|
268 |
+
font-weight: 400;
|
269 |
+
padding: 10px 10px 0;
|
270 |
+
}
|
271 |
+
|
272 |
+
.loading .explanation-header {
|
273 |
+
display: none;
|
274 |
+
}
|
275 |
+
|
276 |
+
/* New style for the content wrapper inside explanation-box */
|
277 |
+
#explanation-content {
|
278 |
+
padding: 10px;
|
279 |
+
overflow-y: auto;
|
280 |
+
height: 100%;
|
281 |
+
box-sizing: border-box;
|
282 |
+
}
|
283 |
+
|
284 |
+
/* Style for when the box is loading */
|
285 |
+
.explanation-box.loading {
|
286 |
+
background-color: #cfcfcf;
|
287 |
+
}
|
288 |
+
|
289 |
+
/* Adjust #explanation-loading (the div element) to be an overlay */
|
290 |
+
#explanation-loading {
|
291 |
+
display: none;
|
292 |
+
font-style: italic;
|
293 |
+
color: #777;
|
294 |
+
text-align: center;
|
295 |
+
padding: 10px;
|
296 |
+
}
|
297 |
+
|
298 |
+
/* Show #explanation-loading when its parent #explanation-output (which is .explanation-box) has class 'loading' */
|
299 |
+
#explanation-output.loading > #explanation-loading {
|
300 |
+
display: flex;
|
301 |
+
}
|
302 |
+
|
303 |
+
/* Utility Classes */
|
304 |
+
.error-message {
|
305 |
+
color: #d9534f;
|
306 |
+
background-color: #f2dede;
|
307 |
+
border: 1px solid #ebccd1;
|
308 |
+
padding: 10px;
|
309 |
+
border-radius: 4px;
|
310 |
+
margin-top: 10px;
|
311 |
+
text-align: center;
|
312 |
+
}
|
313 |
+
|
314 |
+
/* Responsive Adjustments (Optional) */
|
315 |
+
@media (max-width: 768px) {
|
316 |
+
.container {
|
317 |
+
flex-direction: column;
|
318 |
+
}
|
319 |
+
}
|
320 |
+
|
321 |
+
/* Info Page Styles */
|
322 |
+
.info {
|
323 |
+
flex-grow: 1;
|
324 |
+
display: flex;
|
325 |
+
justify-content: center;
|
326 |
+
align-items: center;
|
327 |
+
}
|
328 |
+
|
329 |
+
.info-page-container {
|
330 |
+
display: flex;
|
331 |
+
flex-direction: row-reverse;
|
332 |
+
flex-wrap: wrap;
|
333 |
+
align-items: center;
|
334 |
+
justify-content: center;
|
335 |
+
gap: 40px;
|
336 |
+
padding: 40px;
|
337 |
+
background-color: #fff;
|
338 |
+
max-width: 1200px;
|
339 |
+
margin: 20px auto;
|
340 |
+
border-radius: 8px;
|
341 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
342 |
+
box-sizing: border-box;
|
343 |
+
}
|
344 |
+
|
345 |
+
.info-content {
|
346 |
+
flex: 1;
|
347 |
+
min-width: 350px;
|
348 |
+
max-width: 600px;
|
349 |
+
display: flex;
|
350 |
+
flex-direction: column;
|
351 |
+
gap: 20px;
|
352 |
+
font-size: 18px;
|
353 |
+
}
|
354 |
+
|
355 |
+
.info-header {
|
356 |
+
margin-bottom: 10px;
|
357 |
+
}
|
358 |
+
|
359 |
+
.info-title-med,
|
360 |
+
.info-title-demo {
|
361 |
+
font-family: 'Google Sans', sans-serif;
|
362 |
+
font-size: 48px;
|
363 |
+
font-weight: 400;
|
364 |
+
}
|
365 |
+
|
366 |
+
#info-button .nav-button-inner{
|
367 |
+
background-color: #C2E7FF;
|
368 |
+
}
|
369 |
+
|
370 |
+
.info-title-demo {
|
371 |
+
color: #969696;
|
372 |
+
margin-left: 10px;
|
373 |
+
}
|
374 |
+
|
375 |
+
.info-subtitle-demo {
|
376 |
+
color: #969696;
|
377 |
+
font-size: 28px;
|
378 |
+
}
|
379 |
+
|
380 |
+
.info-text {
|
381 |
+
font-family: 'Google Sans', sans-serif;
|
382 |
+
line-height: 1.6;
|
383 |
+
color: #333;
|
384 |
+
}
|
385 |
+
|
386 |
+
.info-button {
|
387 |
+
font-family: 'Google Sans Text', sans-serif;
|
388 |
+
background-color: #0B57D0;
|
389 |
+
color: white;
|
390 |
+
font-size: 14px;
|
391 |
+
font-weight: 500;
|
392 |
+
padding: 12px 24px;
|
393 |
+
border: none;
|
394 |
+
border-radius: 100px;
|
395 |
+
cursor: pointer;
|
396 |
+
text-align: center;
|
397 |
+
transition: background-color 0.3s ease;
|
398 |
+
align-self: flex-start;
|
399 |
+
}
|
400 |
+
|
401 |
+
/* Hover state for the button */
|
402 |
+
.info-button:hover {
|
403 |
+
background-color: #0a4db8;
|
404 |
+
/* Darker blue on hover */
|
405 |
+
}
|
406 |
+
|
407 |
+
.info-disclaimer-box {
|
408 |
+
border: 1px solid #CDCDCD;
|
409 |
+
border-radius: 15px;
|
410 |
+
padding: 15px 20px;
|
411 |
+
margin-top: 20px;
|
412 |
+
}
|
413 |
+
|
414 |
+
.info-disclaimer-text {
|
415 |
+
font-family: 'Google Sans', sans-serif;
|
416 |
+
color: #333;
|
417 |
+
line-height: 1.5;
|
418 |
+
margin: 0;
|
419 |
+
font-size: 14px;
|
420 |
+
}
|
421 |
+
|
422 |
+
.info-disclaimer-title {
|
423 |
+
border-radius: 14.272px;
|
424 |
+
border: 1.359px solid #F1E161;
|
425 |
+
background: #F1E161;
|
426 |
+
mix-blend-mode: multiply;
|
427 |
+
}
|
428 |
+
|
429 |
+
.info-disclaimer-link {
|
430 |
+
color: #0B57D0;
|
431 |
+
font-weight: 500;
|
432 |
+
text-decoration: none;
|
433 |
+
}
|
434 |
+
|
435 |
+
.info-disclaimer-link:hover {
|
436 |
+
text-decoration: underline;
|
437 |
+
}
|
438 |
+
|
439 |
+
.info-image-container {
|
440 |
+
flex: 1;
|
441 |
+
min-width: 300px;
|
442 |
+
max-width: 500px;
|
443 |
+
display: flex;
|
444 |
+
justify-content: center;
|
445 |
+
align-items: center;
|
446 |
+
}
|
447 |
+
|
448 |
+
.info-image {
|
449 |
+
max-width: 100%;
|
450 |
+
height: auto;
|
451 |
+
border-radius: 8px;
|
452 |
+
}
|
453 |
+
|
454 |
+
/* Responsive adjustments for info page */
|
455 |
+
@media (max-width: 768px) {
|
456 |
+
.info-page-container {
|
457 |
+
flex-direction: column;
|
458 |
+
padding: 20px;
|
459 |
+
margin: 10px;
|
460 |
+
}
|
461 |
+
|
462 |
+
.info-content {
|
463 |
+
max-width: 100%;
|
464 |
+
align-items: center;
|
465 |
+
text-align: center;
|
466 |
+
}
|
467 |
+
|
468 |
+
.info-button {
|
469 |
+
align-self: center;
|
470 |
+
}
|
471 |
+
|
472 |
+
.info-header {
|
473 |
+
text-align: center;
|
474 |
+
}
|
475 |
+
|
476 |
+
.info-title-med,
|
477 |
+
.info-title-demo {
|
478 |
+
font-size: 36px;
|
479 |
+
}
|
480 |
+
|
481 |
+
.info-text {
|
482 |
+
font-size: 16px;
|
483 |
+
}
|
484 |
+
}
|
485 |
+
|
486 |
+
|
487 |
+
/* --- Immersive Info Dialog Styles --- */
|
488 |
+
.dialog-overlay {
|
489 |
+
position: fixed;
|
490 |
+
top: 0;
|
491 |
+
left: 0;
|
492 |
+
width: 100%;
|
493 |
+
height: 100%;
|
494 |
+
background-color: rgba(0, 0, 0, 0.6); /* Semi-transparent backdrop */
|
495 |
+
display: flex;
|
496 |
+
align-items: center;
|
497 |
+
justify-content: center;
|
498 |
+
z-index: 1000; /* Ensure it's on top */
|
499 |
+
opacity: 0;
|
500 |
+
visibility: hidden;
|
501 |
+
transition: opacity 0.3s ease, visibility 0.3s ease;
|
502 |
+
}
|
503 |
+
|
504 |
+
.dialog-overlay.active {
|
505 |
+
opacity: 1;
|
506 |
+
visibility: visible;
|
507 |
+
}
|
508 |
+
|
509 |
+
.dialog-box {
|
510 |
+
background: #fff;
|
511 |
+
border-radius: 12px;
|
512 |
+
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
|
513 |
+
width: 85%;
|
514 |
+
max-width: 800px; /* Adjust for desired largeness */
|
515 |
+
max-height: 85vh; /* Ensure it fits vertically and allows scrolling */
|
516 |
+
position: relative;
|
517 |
+
display: flex;
|
518 |
+
flex-direction: column;
|
519 |
+
overflow: hidden; /* Important for border-radius with scrollable content */
|
520 |
+
transform: scale(0.95);
|
521 |
+
transition: transform 0.3s ease;
|
522 |
+
}
|
523 |
+
|
524 |
+
.dialog-overlay.active .dialog-box {
|
525 |
+
transform: scale(1);
|
526 |
+
}
|
527 |
+
|
528 |
+
.dialog-title-text {
|
529 |
+
padding: 24px 24px 16px 24px;
|
530 |
+
margin: 0;
|
531 |
+
font-size: 1.6rem; /* Adjust as needed */
|
532 |
+
font-weight: 500; /* Using Google Sans weight */
|
533 |
+
color: #202124; /* Dark grey text color */
|
534 |
+
border-bottom: 1px solid #e0e0e0; /* Subtle separator */
|
535 |
+
flex-shrink: 0; /* Prevent title from shrinking */
|
536 |
+
}
|
537 |
+
|
538 |
+
.dialog-body-scrollable {
|
539 |
+
padding: 16px 24px 24px 24px;
|
540 |
+
overflow-y: auto; /* Enable scrolling for TBD content */
|
541 |
+
flex-grow: 1; /* Allow body to take available space */
|
542 |
+
color: #3c4043; /* Standard body text color */
|
543 |
+
line-height: 1.6;
|
544 |
+
}
|
545 |
+
|
546 |
+
.dialog-close-btn {
|
547 |
+
position: absolute;
|
548 |
+
top: 12px;
|
549 |
+
right: 12px;
|
550 |
+
background: transparent;
|
551 |
+
border: none;
|
552 |
+
cursor: pointer;
|
553 |
+
padding: 10px; /* Increase clickable area */
|
554 |
+
line-height: 0; /* Helps align icon if it's just an icon */
|
555 |
+
border-radius: 50%;
|
556 |
+
transition: background-color 0.2s ease-in-out;
|
557 |
+
z-index: 10; /* Ensure close button is above title/body */
|
558 |
+
}
|
559 |
+
|
560 |
+
.dialog-close-btn:hover {
|
561 |
+
background-color: rgba(0,0,0,0.08);
|
562 |
+
}
|
563 |
+
|
564 |
+
.dialog-close-btn .material-symbols-outlined {
|
565 |
+
font-size: 24px; /* Standard Material Icon size */
|
566 |
+
color: #5f6368; /* Standard icon color */
|
567 |
+
display: block; /* Better for layout */
|
568 |
+
}
|
569 |
+
|
570 |
+
.hf-logo {
|
571 |
+
vertical-align: middle;
|
572 |
+
width: 30px;
|
573 |
+
}
|
574 |
+
|
575 |
+
.floating-disclaimer {
|
576 |
+
padding: 5px 10px;
|
577 |
+
margin-top: 10px;
|
578 |
+
color: #333;
|
579 |
+
display: flex;
|
580 |
+
align-items: center;
|
581 |
+
font-size: 12px;
|
582 |
+
background-color: lightcyan;
|
583 |
+
text-align: justify;
|
584 |
+
}
|
585 |
+
|
586 |
+
.disclaimer-icon {
|
587 |
+
vertical-align: middle;
|
588 |
+
margin-right: 10px;
|
589 |
+
font-weight: 300 !important;
|
590 |
+
}
|
591 |
+
|
592 |
+
.nav-button-back {
|
593 |
+
/*justify-self: start;
|
594 |
+
margin-left: 30px;*/
|
595 |
+
}
|
static/images/.gitattributes
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
static/images/CT-Tumor.jpg
ADDED
![]() |
Git LFS Details
|
static/images/Effusion2.jpg
ADDED
![]() |
Git LFS Details
|
static/images/Infection.jpg
ADDED
![]() |
Git LFS Details
|
static/images/Lymphadenopathy2.jpg
ADDED
![]() |
Git LFS Details
|
static/images/Nodule3.jpg
ADDED
![]() |
Git LFS Details
|
static/images/Nodule5.jpg
ADDED
![]() |
Git LFS Details
|
static/js/demo.js
ADDED
@@ -0,0 +1,538 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Copyright 2025 Google LLC
|
3 |
+
*
|
4 |
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
5 |
+
* you may not use this file except in compliance with the License.
|
6 |
+
* You may obtain a copy of the License at
|
7 |
+
*
|
8 |
+
* http://www.apache.org/licenses/LICENSE-2.0
|
9 |
+
*
|
10 |
+
* Unless required by applicable law or agreed to in writing, software
|
11 |
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
12 |
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 |
+
* See the License for the specific language governing permissions and
|
14 |
+
* limitations under the License.
|
15 |
+
*/
|
16 |
+
|
17 |
+
document.addEventListener('DOMContentLoaded', () => {
|
18 |
+
const infoDiv = document.querySelector('.info');
|
19 |
+
const mainDiv = document.querySelector('.main');
|
20 |
+
const reportSectionDiv = document.querySelector('.report-section');
|
21 |
+
const viewDemoButton = document.getElementById('view-demo-button');
|
22 |
+
const backToInfoButton = document.getElementById('back-to-info-button');
|
23 |
+
const caseSelectorTabsContainer = document.getElementById('case-selector-tabs-container');
|
24 |
+
const reportTextDisplay = document.getElementById('report-text-display');
|
25 |
+
const explanationOutput = document.getElementById('explanation-output');
|
26 |
+
const explanationContent = document.getElementById('explanation-content');
|
27 |
+
const explanationError = document.getElementById('explanation-error');
|
28 |
+
const imageContainer = document.getElementById('image-container');
|
29 |
+
const reportImage = document.getElementById('report-image');
|
30 |
+
const imageLoading = document.getElementById('image-loading');
|
31 |
+
const imageError = document.getElementById('image-error');
|
32 |
+
const imageModalityHeader = document.getElementById('image-modality-header'); // Get reference to the header
|
33 |
+
const ctImageNote = document.getElementById('ct-image-note');
|
34 |
+
const appLoading = document.getElementById('app-loading');
|
35 |
+
const appError = document.getElementById('app-error');
|
36 |
+
|
37 |
+
let availableReports = [];
|
38 |
+
let currentReportName = null;
|
39 |
+
let currentReportDetails = null;
|
40 |
+
|
41 |
+
let explainAbortController = null;
|
42 |
+
let reportLoadAbortController = null;
|
43 |
+
let appLoadingTimeout = null;
|
44 |
+
let explanationLoadingTimer = null;
|
45 |
+
|
46 |
+
function initialize() {
|
47 |
+
try {
|
48 |
+
const reportsDataElement = document.getElementById('reports-data');
|
49 |
+
if (reportsDataElement) {
|
50 |
+
availableReports = JSON.parse(reportsDataElement.textContent);
|
51 |
+
} else {
|
52 |
+
displayAppError("Failed to load report list.");
|
53 |
+
return;
|
54 |
+
}
|
55 |
+
} catch (e) {
|
56 |
+
displayAppError("Failed to parse report list.");
|
57 |
+
return;
|
58 |
+
}
|
59 |
+
|
60 |
+
if (availableReports.length === 0) {
|
61 |
+
displayAppError("No reports available.");
|
62 |
+
return;
|
63 |
+
}
|
64 |
+
|
65 |
+
if (viewDemoButton && infoDiv && mainDiv) {
|
66 |
+
viewDemoButton.addEventListener('click', () => {
|
67 |
+
infoDiv.style.display = 'none';
|
68 |
+
mainDiv.style.display = 'grid';
|
69 |
+
if (currentReportName) {
|
70 |
+
loadReportDetails(currentReportName);
|
71 |
+
}
|
72 |
+
});
|
73 |
+
}
|
74 |
+
|
75 |
+
if (backToInfoButton && infoDiv && mainDiv) {
|
76 |
+
backToInfoButton.addEventListener('click', () => {
|
77 |
+
abortOngoingRequests();
|
78 |
+
mainDiv.style.display = 'none';
|
79 |
+
infoDiv.style.display = 'flex';
|
80 |
+
clearAllOutputs();
|
81 |
+
currentReportDetails = null;
|
82 |
+
reportImage.src = '';
|
83 |
+
document.title = "Radiology Report Explainer";
|
84 |
+
});
|
85 |
+
}
|
86 |
+
|
87 |
+
if (caseSelectorTabsContainer) {
|
88 |
+
caseSelectorTabsContainer.addEventListener('click', handleCaseSelectionClick);
|
89 |
+
}
|
90 |
+
|
91 |
+
reportTextDisplay.addEventListener('click', handleSentenceClick);
|
92 |
+
|
93 |
+
const firstCaseButton = caseSelectorTabsContainer?.querySelector('.nav-button-case');
|
94 |
+
if (firstCaseButton) {
|
95 |
+
currentReportName = firstCaseButton.dataset.reportName;
|
96 |
+
setActiveCaseButton(firstCaseButton);
|
97 |
+
loadReportDetails(currentReportName);
|
98 |
+
} else {
|
99 |
+
displayAppError("No cases found to load initially.");
|
100 |
+
}
|
101 |
+
}
|
102 |
+
|
103 |
+
function handleCaseSelectionClick(event) {
|
104 |
+
const clickedButton = event.target.closest('.nav-button-case');
|
105 |
+
if (!clickedButton) return;
|
106 |
+
|
107 |
+
const selectedName = clickedButton.dataset.reportName;
|
108 |
+
if (selectedName && selectedName !== currentReportName) {
|
109 |
+
abortOngoingRequests();
|
110 |
+
currentReportName = selectedName;
|
111 |
+
setActiveCaseButton(clickedButton);
|
112 |
+
loadReportDetails(currentReportName);
|
113 |
+
}
|
114 |
+
}
|
115 |
+
|
116 |
+
async function handleSentenceClick(event) {
|
117 |
+
const clickedElement = event.target;
|
118 |
+
if (!clickedElement.classList.contains('report-sentence') || clickedElement.tagName !== 'SPAN') return;
|
119 |
+
|
120 |
+
const sentenceText = clickedElement.dataset.sentence;
|
121 |
+
if (!sentenceText || !currentReportName) return;
|
122 |
+
|
123 |
+
abortOngoingRequests(['report']);
|
124 |
+
explainAbortController = new AbortController();
|
125 |
+
|
126 |
+
document.querySelectorAll('#report-text-display .selected-sentence').forEach(el => el.classList.remove('selected-sentence'));
|
127 |
+
clickedElement.classList.add('selected-sentence');
|
128 |
+
|
129 |
+
adjustExplanationPosition(clickedElement);
|
130 |
+
|
131 |
+
try {
|
132 |
+
await Promise.all([
|
133 |
+
fetchExplanation(sentenceText, explainAbortController.signal),
|
134 |
+
]);
|
135 |
+
} catch (error) {
|
136 |
+
if (error.name !== 'AbortError') {
|
137 |
+
console.error("Error during sentence processing:", error);
|
138 |
+
}
|
139 |
+
}
|
140 |
+
}
|
141 |
+
|
142 |
+
async function loadReportDetails(reportName) {
|
143 |
+
abortOngoingRequests();
|
144 |
+
reportLoadAbortController = new AbortController();
|
145 |
+
const signal = reportLoadAbortController.signal;
|
146 |
+
|
147 |
+
setLoadingState(true, 'report');
|
148 |
+
clearAllOutputs(true);
|
149 |
+
|
150 |
+
try {
|
151 |
+
const response = await fetch(`/get_report_details/${encodeURIComponent(reportName)}`, { signal });
|
152 |
+
if (signal.aborted) return;
|
153 |
+
|
154 |
+
if (!response.ok) {
|
155 |
+
const errorData = await response.json().catch(() => ({ error: `HTTP error ${response.status}` }));
|
156 |
+
throw new Error(errorData.error || `HTTP error ${response.status}`);
|
157 |
+
}
|
158 |
+
|
159 |
+
currentReportDetails = await response.json();
|
160 |
+
if (signal.aborted) return;
|
161 |
+
|
162 |
+
document.title = `${reportName} - Radiology Explainer`;
|
163 |
+
|
164 |
+
// Update the image modality header
|
165 |
+
if (imageModalityHeader && currentReportDetails.image_type) {
|
166 |
+
imageModalityHeader.textContent = currentReportDetails.image_type;
|
167 |
+
}
|
168 |
+
|
169 |
+
// Show/hide CT note based on image_type and image_file presence
|
170 |
+
if (ctImageNote) {
|
171 |
+
if (currentReportDetails.image_type === 'CT' && currentReportDetails.image_file) {
|
172 |
+
ctImageNote.style.display = 'block';
|
173 |
+
} else {
|
174 |
+
ctImageNote.style.display = 'none';
|
175 |
+
}
|
176 |
+
}
|
177 |
+
|
178 |
+
|
179 |
+
if (currentReportDetails.image_file) {
|
180 |
+
const imageUrl = `${currentReportDetails.image_file}`;
|
181 |
+
|
182 |
+
reportImage.onload = null;
|
183 |
+
reportImage.onerror = null;
|
184 |
+
|
185 |
+
reportImage.onload = () => {
|
186 |
+
imageLoading.style.display = 'none';
|
187 |
+
reportImage.style.display = 'block';
|
188 |
+
imageError.style.display = 'none';
|
189 |
+
};
|
190 |
+
reportImage.onerror = () => {
|
191 |
+
imageLoading.style.display = 'none';
|
192 |
+
reportImage.style.display = 'none';
|
193 |
+
displayImageError("Failed to load image file.");
|
194 |
+
};
|
195 |
+
|
196 |
+
reportImage.src = imageUrl;
|
197 |
+
reportImage.alt = `Radiology Image for ${reportName}`;
|
198 |
+
} else {
|
199 |
+
displayImageError("Image path not configured for this report.");
|
200 |
+
if (ctImageNote) ctImageNote.style.display = 'none'; // Ensure note is hidden if no image path
|
201 |
+
}
|
202 |
+
|
203 |
+
renderReportTextWithLineBreaks(currentReportDetails.text || '');
|
204 |
+
} catch (error) {
|
205 |
+
if (error.name !== 'AbortError') {
|
206 |
+
displayReportTextError(`Failed to load report: ${error.message}`);
|
207 |
+
// Clean up UI elements on report load error.
|
208 |
+
if (reportImage) {
|
209 |
+
reportImage.style.display = 'none';
|
210 |
+
reportImage.src = '';
|
211 |
+
reportImage.onload = null;
|
212 |
+
reportImage.onerror = null;
|
213 |
+
}
|
214 |
+
if (imageLoading) imageLoading.style.display = 'none';
|
215 |
+
if (imageError) imageError.style.display = 'none';
|
216 |
+
if (ctImageNote) ctImageNote.style.display = 'none';
|
217 |
+
clearExplanationAndLocationUI();
|
218 |
+
}
|
219 |
+
} finally {
|
220 |
+
if (reportLoadAbortController?.signal === signal) {
|
221 |
+
reportLoadAbortController = null;
|
222 |
+
}
|
223 |
+
if (!signal.aborted) {
|
224 |
+
setLoadingState(false, 'report');
|
225 |
+
}
|
226 |
+
}
|
227 |
+
}
|
228 |
+
|
229 |
+
function renderReportTextWithLineBreaks(text) {
|
230 |
+
reportTextDisplay.innerHTML = '';
|
231 |
+
reportTextDisplay.classList.remove('loading', 'error');
|
232 |
+
|
233 |
+
const lines = text.split('\n');
|
234 |
+
if (lines.length === 0 || (lines.length === 1 && !lines[0].trim())) {
|
235 |
+
reportTextDisplay.textContent = 'Report text is empty or could not be processed.';
|
236 |
+
return;
|
237 |
+
}
|
238 |
+
|
239 |
+
lines.forEach((line, index) => {
|
240 |
+
const trimmedLine = line.trim();
|
241 |
+
if (trimmedLine !== '') {
|
242 |
+
const sentences = splitSentences(trimmedLine);
|
243 |
+
sentences.forEach(sentence => {
|
244 |
+
if (sentence) {
|
245 |
+
const span = document.createElement('span');
|
246 |
+
span.textContent = sentence + ' ';
|
247 |
+
if (!sentence.includes('Image source: ') && sentence.includes(' ') || !sentence.includes(':')) {
|
248 |
+
span.classList.add('report-sentence');
|
249 |
+
}
|
250 |
+
span.dataset.sentence = sentence;
|
251 |
+
reportTextDisplay.appendChild(span);
|
252 |
+
}
|
253 |
+
});
|
254 |
+
}
|
255 |
+
if (index < lines.length - 1) {
|
256 |
+
reportTextDisplay.appendChild(document.createElement('br'));
|
257 |
+
}
|
258 |
+
});
|
259 |
+
}
|
260 |
+
|
261 |
+
async function fetchExplanation(sentence, signal) {
|
262 |
+
explanationError.style.display = 'none';
|
263 |
+
if (!currentReportName) {
|
264 |
+
displayExplanationError("No report selected.");
|
265 |
+
return;
|
266 |
+
}
|
267 |
+
|
268 |
+
if (explanationLoadingTimer) {
|
269 |
+
clearTimeout(explanationLoadingTimer);
|
270 |
+
}
|
271 |
+
|
272 |
+
explanationLoadingTimer = setTimeout(() => {
|
273 |
+
if (!signal.aborted) { // Only add if not already aborted
|
274 |
+
explanationOutput.classList.add('loading');
|
275 |
+
explanationContent.textContent = '';
|
276 |
+
}
|
277 |
+
explanationLoadingTimer = null; // Timer has done its job or been cleared
|
278 |
+
}, 150); // 150ms delay, adjust as needed
|
279 |
+
|
280 |
+
try {
|
281 |
+
const response = await fetch('/explain', {
|
282 |
+
method: 'POST',
|
283 |
+
headers: { 'Content-Type': 'application/json' }, // prettier-ignore
|
284 |
+
body: JSON.stringify({ sentence, report_name: currentReportName }),
|
285 |
+
signal
|
286 |
+
});
|
287 |
+
|
288 |
+
if (signal.aborted) return;
|
289 |
+
|
290 |
+
if (!response.ok) {
|
291 |
+
if (explanationLoadingTimer) clearTimeout(explanationLoadingTimer);
|
292 |
+
explanationLoadingTimer = null;
|
293 |
+
explanationOutput.classList.remove('loading');
|
294 |
+
const errorData = await response.json().catch(() => ({ error: `HTTP error ${response.status}` }));
|
295 |
+
throw new Error(errorData.error || `HTTP error ${response.status}`);
|
296 |
+
}
|
297 |
+
|
298 |
+
const data = await response.json();
|
299 |
+
if (signal.aborted) return;
|
300 |
+
|
301 |
+
if (explanationLoadingTimer) clearTimeout(explanationLoadingTimer);
|
302 |
+
explanationLoadingTimer = null;
|
303 |
+
explanationOutput.classList.remove('loading');
|
304 |
+
requestAnimationFrame(() => {
|
305 |
+
explanationContent.textContent = data.explanation || "No explanation content received.";
|
306 |
+
adjustExplanationPosition();
|
307 |
+
});
|
308 |
+
} catch (error) {
|
309 |
+
if (explanationLoadingTimer) clearTimeout(explanationLoadingTimer);
|
310 |
+
explanationLoadingTimer = null;
|
311 |
+
explanationOutput.classList.remove('loading');
|
312 |
+
if (error.name !== 'AbortError') {
|
313 |
+
displayExplanationError(`Explanation Error: ${error.message}`);
|
314 |
+
}
|
315 |
+
}
|
316 |
+
}
|
317 |
+
|
318 |
+
function setLoadingState(isLoading, type = 'all') {
|
319 |
+
if (type === 'all' || type === 'report') {
|
320 |
+
if (isLoading) {
|
321 |
+
if (appLoadingTimeout) {
|
322 |
+
clearTimeout(appLoadingTimeout);
|
323 |
+
appLoadingTimeout = null;
|
324 |
+
}
|
325 |
+
// Cleanup immediate error/content states
|
326 |
+
if (appError) appError.style.display = 'none';
|
327 |
+
if (reportImage) reportImage.style.display = 'none';
|
328 |
+
if (imageError) imageError.style.display = 'none';
|
329 |
+
if (ctImageNote) ctImageNote.style.display = 'none';
|
330 |
+
clearExplanationAndLocationUI();
|
331 |
+
|
332 |
+
appLoadingTimeout = setTimeout(() => {
|
333 |
+
if (appLoading) appLoading.style.display = 'block';
|
334 |
+
|
335 |
+
// Show content-specific loaders only if timeout fires
|
336 |
+
if (reportTextDisplay) {
|
337 |
+
reportTextDisplay.innerHTML = 'Loading...';
|
338 |
+
reportTextDisplay.classList.add('loading');
|
339 |
+
reportTextDisplay.classList.remove('error');
|
340 |
+
}
|
341 |
+
if (imageLoading) imageLoading.style.display = 'block';
|
342 |
+
}, 200); // Delay for app and content loading indicators (e.g., 200ms)
|
343 |
+
|
344 |
+
} else {
|
345 |
+
if (appLoadingTimeout) {
|
346 |
+
clearTimeout(appLoadingTimeout);
|
347 |
+
appLoadingTimeout = null;
|
348 |
+
}
|
349 |
+
if (appLoading) appLoading.style.display = 'none';
|
350 |
+
}
|
351 |
+
}
|
352 |
+
}
|
353 |
+
function clearAllOutputs(keepReportTextLoading = false) {
|
354 |
+
if (!keepReportTextLoading && reportTextDisplay) {
|
355 |
+
reportTextDisplay.innerHTML = 'Select a report to view its text.';
|
356 |
+
reportTextDisplay.classList.remove('loading', 'error');
|
357 |
+
}
|
358 |
+
if (reportImage) {
|
359 |
+
reportImage.style.display = 'none';
|
360 |
+
reportImage.onload = null;
|
361 |
+
reportImage.onerror = null;
|
362 |
+
reportImage.src = '';
|
363 |
+
}
|
364 |
+
if (imageError) imageError.style.display = 'none';
|
365 |
+
if (ctImageNote) ctImageNote.style.display = 'none';
|
366 |
+
clearExplanationAndLocationUI();
|
367 |
+
if (appError) appError.style.display = 'none';
|
368 |
+
// Reset image modality header on clear
|
369 |
+
if (imageModalityHeader) {
|
370 |
+
imageModalityHeader.textContent = 'Medical Image'; // Reset to default
|
371 |
+
}
|
372 |
+
}
|
373 |
+
|
374 |
+
function clearExplanationAndLocationUI() {
|
375 |
+
if (explanationContent) {
|
376 |
+
explanationContent.textContent = 'Click a sentence to see the explanation here.';
|
377 |
+
}
|
378 |
+
if (explanationLoadingTimer) { // Clear any pending explanation loading timer
|
379 |
+
clearTimeout(explanationLoadingTimer);
|
380 |
+
explanationLoadingTimer = null;
|
381 |
+
}
|
382 |
+
explanationOutput.classList.remove('loading');
|
383 |
+
explanationError.style.display = 'none';
|
384 |
+
document.querySelectorAll('#report-text-display .selected-sentence').forEach(el => el.classList.remove('selected-sentence'));
|
385 |
+
}
|
386 |
+
|
387 |
+
function displayReportTextError(message) {
|
388 |
+
reportTextDisplay.innerHTML = `<span class="error-message">${message}</span>`;
|
389 |
+
reportTextDisplay.classList.add('error');
|
390 |
+
reportTextDisplay.classList.remove('loading');
|
391 |
+
}
|
392 |
+
|
393 |
+
function displayAppError(message) {
|
394 |
+
appError.textContent = `Error: ${message}`;
|
395 |
+
appError.style.display = 'block';
|
396 |
+
appLoading.style.display = 'none';
|
397 |
+
}
|
398 |
+
|
399 |
+
function displayImageError(message) {
|
400 |
+
imageError.textContent = message;
|
401 |
+
imageError.style.display = 'block';
|
402 |
+
imageLoading.style.display = 'none';
|
403 |
+
reportImage.style.display = 'none';
|
404 |
+
if (ctImageNote) ctImageNote.style.display = 'none';
|
405 |
+
}
|
406 |
+
|
407 |
+
function displayExplanationError(message) {
|
408 |
+
explanationError.textContent = message;
|
409 |
+
explanationError.style.display = 'block';
|
410 |
+
explanationOutput.classList.remove('loading');
|
411 |
+
if (explanationContent) explanationContent.textContent = '';
|
412 |
+
}
|
413 |
+
|
414 |
+
function setActiveCaseButton(activeButton) {
|
415 |
+
if (!caseSelectorTabsContainer) return;
|
416 |
+
caseSelectorTabsContainer.querySelectorAll('.nav-button-case').forEach(btn => btn.classList.remove('active'));
|
417 |
+
if (activeButton) activeButton.classList.add('active');
|
418 |
+
}
|
419 |
+
|
420 |
+
function splitSentences(text) {
|
421 |
+
if (!text) return [];
|
422 |
+
try {
|
423 |
+
if (typeof nlp !== 'function') {
|
424 |
+
const basicSentences = text.match(/[^.?!]+[.?!]['"]?(\s+|$)/g);
|
425 |
+
return basicSentences ? basicSentences.map(s => s.trim()).filter(s => s.length > 0) : [];
|
426 |
+
}
|
427 |
+
const doc = nlp(text);
|
428 |
+
return doc.sentences().out('array').map(s => s.trim()).filter(s => s.length > 0);
|
429 |
+
} catch (e) {
|
430 |
+
const basicSentences = text.match(/[^.?!]+[.?!]['"]?(\s+|$)/g);
|
431 |
+
return basicSentences ? basicSentences.map(s => s.trim()).filter(s => s.length > 0) : [];
|
432 |
+
}
|
433 |
+
}
|
434 |
+
|
435 |
+
function adjustExplanationPosition(clickedSentenceElement) {
|
436 |
+
const targetSentenceElement = clickedSentenceElement || document.querySelector('#report-text-display .selected-sentence');
|
437 |
+
if (!targetSentenceElement) return;
|
438 |
+
|
439 |
+
const explanationSection = explanationOutput.closest('.explanation-section');
|
440 |
+
|
441 |
+
if (explanationOutput && explanationSection && reportSectionDiv) {
|
442 |
+
const sentenceRect = targetSentenceElement.getBoundingClientRect();
|
443 |
+
const explanationSectionRect = explanationSection.getBoundingClientRect();
|
444 |
+
|
445 |
+
// Use actual offsetHeight, fallback if not rendered. Accurate after rAF.
|
446 |
+
const explanationHeight = explanationOutput.offsetHeight || 200;
|
447 |
+
|
448 |
+
// Initial top: align with sentence, relative to explanationSection.
|
449 |
+
let newTop = sentenceRect.top - explanationSectionRect.top;
|
450 |
+
|
451 |
+
// Absolute bottom of explanation box if placed at newTop.
|
452 |
+
const explanationBoxAbsoluteBottom = explanationSectionRect.top + newTop + explanationHeight + 15; // 15px margin
|
453 |
+
|
454 |
+
const viewportHeight = window.innerHeight;
|
455 |
+
const pageBottomOverflow = explanationBoxAbsoluteBottom - viewportHeight;
|
456 |
+
|
457 |
+
if (pageBottomOverflow > 0) {
|
458 |
+
// Adjust newTop upwards if overflowing viewport bottom.
|
459 |
+
newTop -= pageBottomOverflow;
|
460 |
+
}
|
461 |
+
|
462 |
+
// Prevent top from being negative (relative to its container).
|
463 |
+
newTop = Math.max(0, newTop);
|
464 |
+
|
465 |
+
explanationOutput.style.top = `${newTop}px`;
|
466 |
+
}
|
467 |
+
}
|
468 |
+
|
469 |
+
function abortOngoingRequests(excludeTypes = []) {
|
470 |
+
if (!excludeTypes.includes('report') && reportLoadAbortController) {
|
471 |
+
reportLoadAbortController.abort();
|
472 |
+
reportLoadAbortController = null;
|
473 |
+
}
|
474 |
+
if (!excludeTypes.includes('explain') && explainAbortController) {
|
475 |
+
explainAbortController.abort();
|
476 |
+
explainAbortController = null;
|
477 |
+
}
|
478 |
+
}
|
479 |
+
|
480 |
+
initialize();
|
481 |
+
});
|
482 |
+
|
483 |
+
|
484 |
+
// Make sure this is within your existing DOMContentLoaded listener,
|
485 |
+
// or wrap it in one if demo.js doesn't have a global one.
|
486 |
+
document.addEventListener('DOMContentLoaded', () => {
|
487 |
+
|
488 |
+
// ... (any existing JavaScript code in demo.js)
|
489 |
+
|
490 |
+
// --- BEGIN: Immersive Info Dialog Logic ---
|
491 |
+
const infoButton = document.getElementById('info-button');
|
492 |
+
const immersiveDialogOverlay = document.getElementById('immersive-info-dialog');
|
493 |
+
const dialogCloseButton = document.getElementById('dialog-close-button');
|
494 |
+
|
495 |
+
if (infoButton && immersiveDialogOverlay && dialogCloseButton) {
|
496 |
+
const openDialog = () => {
|
497 |
+
immersiveDialogOverlay.style.display = 'flex'; // Use flex as per CSS
|
498 |
+
// Timeout to allow display:flex to apply before triggering transition
|
499 |
+
setTimeout(() => {
|
500 |
+
immersiveDialogOverlay.classList.add('active');
|
501 |
+
}, 10); // Small delay
|
502 |
+
document.body.style.overflow = 'hidden'; // Prevent background scroll
|
503 |
+
};
|
504 |
+
|
505 |
+
const closeDialog = () => {
|
506 |
+
immersiveDialogOverlay.classList.remove('active');
|
507 |
+
// Wait for opacity transition to finish before setting display to none
|
508 |
+
setTimeout(() => {
|
509 |
+
immersiveDialogOverlay.style.display = 'none';
|
510 |
+
}, 300); // Must match CSS transition duration
|
511 |
+
document.body.style.overflow = ''; // Restore background scroll
|
512 |
+
};
|
513 |
+
|
514 |
+
infoButton.addEventListener('click', openDialog);
|
515 |
+
dialogCloseButton.addEventListener('click', closeDialog);
|
516 |
+
|
517 |
+
// Dismissible: Close when clicking on the overlay (backdrop)
|
518 |
+
immersiveDialogOverlay.addEventListener('click', (event) => {
|
519 |
+
if (event.target === immersiveDialogOverlay) {
|
520 |
+
closeDialog();
|
521 |
+
}
|
522 |
+
});
|
523 |
+
|
524 |
+
// Dismissible: Close with Escape key
|
525 |
+
document.addEventListener('keydown', (event) => {
|
526 |
+
if (event.key === 'Escape' && immersiveDialogOverlay.classList.contains('active')) {
|
527 |
+
closeDialog();
|
528 |
+
}
|
529 |
+
});
|
530 |
+
} else {
|
531 |
+
// Log errors if elements are not found, helps in debugging
|
532 |
+
if (!infoButton) console.error('Dialog trigger button (#info-button) not found.');
|
533 |
+
if (!immersiveDialogOverlay) console.error('Immersive dialog (#immersive-info-dialog) not found.');
|
534 |
+
if (!dialogCloseButton) console.error('Dialog close button (#dialog-close-button) not found.');
|
535 |
+
}
|
536 |
+
// --- END: Immersive Info Dialog Logic ---
|
537 |
+
|
538 |
+
});
|
static/reports/CT-Tumor.txt
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FINDINGS:
|
2 |
+
Mass effect and endobronchial tumor invasion result in severe focal stenosis of right middle lobar bronchus. Subsegmental mucus plugging in lateral segment right middle lobe, peripheral to central right middle lobe lung mass (described below).
|
3 |
+
No pneumothorax. No pleural effusion.
|
4 |
+
Right middle lobe hyperlucency, compatible with lobar air trapping. Solid, well-circumscribed, enhancing 33 x 25 mm central right middle lobe lung mass. No consolidation elsewhere. Very mild dependent atelectasis in lower lobes. Subsolid 3 mm nodule in posteromedial right upper lobe.
|
5 |
+
Enlargement of right heart chambers. No pericardial effusion. No thoracic aortic aneurysm. Mild thymic hyperplasia.
|
6 |
+
No bulky thoracic lymphadenopathy.
|
7 |
+
Cholelithiasis.
|
8 |
+
No aggressive destructive skeletal focus. Mild thoracic spine degenerative disk disease.
|
9 |
+
|
10 |
+
|
11 |
+
IMPRESSION:
|
12 |
+
1. Solid, enhancing 33 x 25 mm central right middle lobe lung mass with apparent endobronchial invasion and associated right middle lobe air trapping; imaging features favor carcinoid tumor.
|
13 |
+
2. No bulky thoracic lymphadenopathy.
|
14 |
+
3. Nonspecific 3 mm right upper lobe lung nodule.
|
15 |
+
|
16 |
+
RECOMMENDATION:
|
17 |
+
>> Pulmonary consultation.
|
static/reports/Effusion2.txt
ADDED
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FINDINGS:
|
2 |
+
No pneumothorax. Small to medium right pleural effusion with adjacent right basilar atelectasis. No substantial left pleural effusion.
|
3 |
+
|
4 |
+
Normal heart size. Mediastinal shadow within normal limits.
|
5 |
+
|
6 |
+
No acute skeletal abnormality is apparent.
|
7 |
+
|
8 |
+
IMPRESSION:
|
9 |
+
Small to medium right pleural effusion.
|
10 |
+
|
11 |
+
Image source: LIDC-IDRI
|
static/reports/Infection.txt
ADDED
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FINDINGS:
|
2 |
+
No pneumothorax. Small right pleural effusion. No substantial left pleural effusion. Heterogeneous opacities with mixed interstitial opacities are present in mid and lower lungs bilateral, most pronounced in lower right lung.
|
3 |
+
|
4 |
+
Normal heart size. Mediastinal shadow within normal limits.
|
5 |
+
|
6 |
+
No acute skeletal abnormality is apparent.
|
7 |
+
|
8 |
+
IMPRESSION:
|
9 |
+
Bilateral heterogeneous lung opacities, most likely lung infection, most pronounced in lower right lung. Small right pleural effusion.
|
10 |
+
|
11 |
+
Image source: NIH Chest X-ray14 database
|
static/reports/Lymphadenopathy2.txt
ADDED
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FINDINGS:
|
2 |
+
No pneumothorax. No substantial pleural effusion. No consolidation.
|
3 |
+
|
4 |
+
Normal heart size. Soft tissue bulge near aortopulmonary window. Multiple calcified, enlarged mediastinal lymph nodes.
|
5 |
+
|
6 |
+
No acute skeletal abnormality is apparent.
|
7 |
+
|
8 |
+
IMPRESSION:
|
9 |
+
Soft tissue bulge near aortopulmonary window; bulky lymphadenopathy suspected. Multiple calcified, enlarged mediastinal lymph nodes may correspond to either treated malignancy or sequelae of remote endemic fungal infection.
|
10 |
+
|
11 |
+
Image source: NIH Chest X-ray14 database
|
static/reports/Nodule3.txt
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FINDINGS:
|
2 |
+
No pneumothorax. No substantial pleural effusion. No consolidation. Approximately 1.5 cm nodule in lateral upper/mid left lung.
|
3 |
+
|
4 |
+
Normal heart size. Slightly unfolded thoracic aorta. Mediastinal shadow otherwise within normal limits.
|
5 |
+
|
6 |
+
No acute skeletal abnormality is apparent.
|
7 |
+
|
8 |
+
IMPRESSION:
|
9 |
+
Approximately 1.5 cm lateral upper/mid left lung nodule; malignancy needs to be excluded.
|
10 |
+
|
11 |
+
RECOMMENDATION:
|
12 |
+
Chest CT without contrast.
|
13 |
+
|
14 |
+
Image source: LIDC-IDRI
|
static/reports/Nodule5.txt
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FINDINGS:
|
2 |
+
No pneumothorax. No substantial pleural effusion. Well-circumscribed, approximately 2 cm round nodule in medial mid right lung. No consolidation otherwise.
|
3 |
+
|
4 |
+
Normal heart size. Mediastinal shadow within normal limits.
|
5 |
+
|
6 |
+
Skeletal structures unremarkable.
|
7 |
+
|
8 |
+
IMPRESSION:
|
9 |
+
Well circumscribed, approximately 2 cm medial mid right lung nodule; malignancy needs to be excluded.
|
10 |
+
|
11 |
+
RECOMMENDATION:
|
12 |
+
Chest CT without contrast.
|
13 |
+
|
14 |
+
Image source: LIDC-IDRI
|
static/reports_manifest.csv
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
image_type,case_display_name,image_path,report_path
|
2 |
+
CT,Tumor,static/images/CT-Tumor.jpg,static/reports/CT-Tumor.txt
|
3 |
+
CXR,Effusion,static/images/Effusion2.jpg,static/reports/Effusion2.txt
|
4 |
+
CXR,Infection,static/images/Infection.jpg,static/reports/Infection.txt
|
5 |
+
CXR,Lymphadenopathy,static/images/Lymphadenopathy2.jpg,static/reports/Lymphadenopathy2.txt
|
6 |
+
CXR,Nodule A,static/images/Nodule3.jpg,static/reports/Nodule3.txt
|
7 |
+
CXR,Nodule B,static/images/Nodule5.jpg,static/reports/Nodule5.txt
|
templates/index.html
ADDED
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<!--
|
3 |
+
Copyright 2025 Google LLC
|
4 |
+
|
5 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
6 |
+
you may not use this file except in compliance with the License.
|
7 |
+
You may obtain a copy of the License at
|
8 |
+
|
9 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
10 |
+
|
11 |
+
Unless required by applicable law or agreed to in writing, software
|
12 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
13 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14 |
+
See the License for the specific language governing permissions and
|
15 |
+
limitations under the License.
|
16 |
+
-->
|
17 |
+
|
18 |
+
<html lang="en">
|
19 |
+
|
20 |
+
<head>
|
21 |
+
<meta charset="UTF-8">
|
22 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
23 |
+
<title>Radiology Report Explainer</title>
|
24 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
25 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
26 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
27 |
+
<link
|
28 |
+
href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500&family=Google+Sans+Text:wght@500&display=swap"
|
29 |
+
rel="stylesheet">
|
30 |
+
<link
|
31 |
+
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],100..700,0..1,-50..200"
|
32 |
+
rel="stylesheet">
|
33 |
+
<!-- Link to the correct JS file -->
|
34 |
+
<script src="https://unpkg.com/compromise" defer></script>
|
35 |
+
<script src="{{ url_for('static', filename='js/demo.js') }}" defer></script>
|
36 |
+
</head>
|
37 |
+
|
38 |
+
<body>
|
39 |
+
<div class="info">
|
40 |
+
<!-- New Info Page Content -->
|
41 |
+
<div class="info-page-container">
|
42 |
+
<div class="info-content">
|
43 |
+
<div class="info-header">
|
44 |
+
<span class="info-title-demo">Radiology Explainer Demo</span><br>
|
45 |
+
<span class="info-title-demo info-subtitle-demo">Built with</span>
|
46 |
+
<span class="info-title-med info-subtitle-demo">MedGemma</span>
|
47 |
+
</div>
|
48 |
+
<div class="info-text">Consider an educational scenario where interacting with a radiology image can
|
49 |
+
substantially improve learning. This demonstration shows how MedGemma might be built upon to provide
|
50 |
+
a useful tool for exploring radiology images and associated reports by translating them into
|
51 |
+
simple language, with visual cues to highlight the relevant areas of the image.</div>
|
52 |
+
<div class="info-disclaimer-text"><span class="info-disclaimer-title">Disclaimer</span> This
|
53 |
+
demonstration is for illustrative purposes only and does not represent a finished or approved
|
54 |
+
product. It is not representative of compliance to any regulations or standards for
|
55 |
+
quality, safety or efficacy. Any real-world application would require additional development,
|
56 |
+
training, and adaptation. The experience highlighted in this demo shows MedGemma's baseline
|
57 |
+
capability for the displayed task and is intended to help developers and users explore possible
|
58 |
+
applications and inspire further development.</div>
|
59 |
+
<button class="info-button" id="view-demo-button">View Demo</button>
|
60 |
+
</div>
|
61 |
+
</div>
|
62 |
+
</div>
|
63 |
+
<div class="main">
|
64 |
+
<div class="nav-button nav-button-back" id="back-to-info-button">
|
65 |
+
<div class="nav-button-inner">
|
66 |
+
<span class="material-symbols-outlined nav-button-icon">keyboard_arrow_left</span>
|
67 |
+
<span class="nav-button-text">Back</span>
|
68 |
+
</div>
|
69 |
+
</div>
|
70 |
+
<div class="case-selector-tabs-container" id="case-selector-tabs-container">
|
71 |
+
<div>X-Ray</div>
|
72 |
+
<div class="case-selector-tabs" id="case-selector-tabs">
|
73 |
+
{% if available_reports %}
|
74 |
+
{% for report in available_reports %}
|
75 |
+
{% if report.image_type == 'CXR' %}
|
76 |
+
<div class="nav-button nav-button-case" data-report-name="{{ report.name }}">
|
77 |
+
<div class="nav-button-inner">
|
78 |
+
<span class="nav-button-text">{{ report.name }}</span>
|
79 |
+
</div>
|
80 |
+
</div>
|
81 |
+
{% endif %}
|
82 |
+
{% endfor %}
|
83 |
+
{% else %}
|
84 |
+
<span class="no-cases-available">No cases available</span>
|
85 |
+
{% endif %}
|
86 |
+
</div>
|
87 |
+
|
88 |
+
<div>CT</div>
|
89 |
+
<div class="case-selector-tabs" id="case-selector-tabs2">
|
90 |
+
{% if available_reports %}
|
91 |
+
{% for report in available_reports %}
|
92 |
+
{% if report.image_type == 'CT' %}
|
93 |
+
<div class="nav-button nav-button-case" data-report-name="{{ report.name }}">
|
94 |
+
<div class="nav-button-inner">
|
95 |
+
<span class="nav-button-text">{{ report.name }}</span>
|
96 |
+
</div>
|
97 |
+
</div>
|
98 |
+
{% endif %}
|
99 |
+
{% endfor %}
|
100 |
+
{% else %}
|
101 |
+
<span class="no-cases-available">No cases available</span>
|
102 |
+
{% endif %}
|
103 |
+
</div>
|
104 |
+
|
105 |
+
</div>
|
106 |
+
<div class="nav-button nav-button-info" id="info-button">
|
107 |
+
<div class="nav-button-inner">
|
108 |
+
<span class="material-symbols-outlined nav-button-icon">code_blocks</span>
|
109 |
+
<span class="nav-button-text">Details about this Demo</span>
|
110 |
+
</div>
|
111 |
+
</div>
|
112 |
+
|
113 |
+
|
114 |
+
|
115 |
+
<div class="image-section">
|
116 |
+
<div id="image-modality-header" class="image-header">{{ image_type | default('Medical Image', true) }}
|
117 |
+
</div> <!-- Updated: ID added, initial text changed -->
|
118 |
+
<div id="image-container" class="image-container"> <!-- Container for relative positioning -->
|
119 |
+
<img id="report-image" src="" alt="Medical Image" style="display: none;">
|
120 |
+
<div id="image-loading" class="loading" style="display: none;">Loading image...</div>
|
121 |
+
<div id="image-error" class="error-message" style="display: none;"></div>
|
122 |
+
</div>
|
123 |
+
<div id="ct-image-note" class="image-note">
|
124 |
+
This shows a single slice of the CT. Not all elements in the report can be visualized.
|
125 |
+
</div>
|
126 |
+
</div>
|
127 |
+
|
128 |
+
<div class="report-section">
|
129 |
+
<div id="app-loading" class="loading" style="display: none;">Loading report details...</div>
|
130 |
+
<div id="app-error" class="error-message" style="display: none;"></div>
|
131 |
+
<div class="explanation-section">
|
132 |
+
<div id="explanation-output" class="explanation-box">
|
133 |
+
<div id="explanation-loading" class="loading">Generating explanation... Please wait.</div>
|
134 |
+
<div class="explanation-header">What this means
|
135 |
+
</div>
|
136 |
+
<div id="explanation-content">
|
137 |
+
Click a sentence to see the explanation here.
|
138 |
+
</div>
|
139 |
+
</div>
|
140 |
+
</div>
|
141 |
+
<div class="report-text-area">
|
142 |
+
<div id="report-text-display" >
|
143 |
+
<!-- Report text will be loaded here -->
|
144 |
+
Select a report to view its text.
|
145 |
+
</div>
|
146 |
+
<div class="floating-disclaimer">
|
147 |
+
<span class="material-symbols-outlined disclaimer-icon">warning</span>
|
148 |
+
<span class="disclaimer-text">This demonstration is for illustrative purposes of MedGemma's baseline
|
149 |
+
capability only. It does not represent a finished or approved product, is not intended to
|
150 |
+
diagnose or suggest treatment of any disease or condition, and should not be used for medical
|
151 |
+
advice.</span>
|
152 |
+
</div>
|
153 |
+
</div>
|
154 |
+
</div>
|
155 |
+
<!-- Embed available reports data for JavaScript -->
|
156 |
+
<script id="reports-data" type="application/json">
|
157 |
+
{{ available_reports | tojson | safe }}
|
158 |
+
</script>
|
159 |
+
<div id="explanation-error" class="error-message" style="display: none;"></div>
|
160 |
+
</div>
|
161 |
+
|
162 |
+
<!-- Immersive Info Dialog -->
|
163 |
+
<div id="immersive-info-dialog" class="dialog-overlay" style="display: none;" role="dialog" aria-modal="true"
|
164 |
+
aria-labelledby="dialog-title">
|
165 |
+
<div class="dialog-box">
|
166 |
+
<button id="dialog-close-button" class="dialog-close-btn" aria-label="Close dialog">
|
167 |
+
<span class="material-symbols-outlined">close</span>
|
168 |
+
</button>
|
169 |
+
<h2 id="dialog-title" class="dialog-title-text">Details About This Demo</h2>
|
170 |
+
<div class="dialog-body-scrollable">
|
171 |
+
<p><b>The Model:</b> This demo exclusively features Google's MedGemma-4B, a Gemma 3-based model
|
172 |
+
fine-tuned for
|
173 |
+
comprehending medical text and images, such as chest X-rays. It demonstrates MedGemma's ability to
|
174 |
+
accelerate the development of AI-powered healthcare applications by offering advanced
|
175 |
+
interpretation of medical data.</p>
|
176 |
+
<p><b>Accessing and Using the Model:</b> Google's MedGemma-4B is available on <a
|
177 |
+
href="https://huggingface.co/google/medgemma-4b-it" target="_blank">HuggingFace<img
|
178 |
+
class="hf-logo"
|
179 |
+
src="https://huggingface.co/datasets/huggingface/brand-assets/resolve/main/hf-logo.svg">
|
180 |
+
</a> and
|
181 |
+
<a href="https://console.cloud.google.com/vertex-ai/publishers/google/model-garden/medgemma" target="_blank">Model
|
182 |
+
Garden <img class="hf-logo"
|
183 |
+
src="https://www.gstatic.com/cloud/images/icons/apple-icon.png"></a>.
|
184 |
+
Learn more about using the model and its limitations on the <a
|
185 |
+
href="https://developers.google.com/health-ai-developer-foundations?referral=rad_explain"
|
186 |
+
target="_blank">HAI-DEF
|
187 |
+
developer site</a>.
|
188 |
+
</p>
|
189 |
+
<p><b>Health AI Developer Foundations (HAI-DEF)</b> provides a collection of open-weight models and
|
190 |
+
companion resources to empower developers in building AI models for healthcare.</p>
|
191 |
+
<p><b>Enjoying the Demo?</b> We'd love your feedback! If you found this demo helpful, please show
|
192 |
+
your appreciation by clicking the ♡ button on the HuggingFace page, linked at the top.</p>
|
193 |
+
<p><b>Explore More Demos:</b> Discover additional demonstrations on HuggingFace Spaces or via Colabs:
|
194 |
+
</p>
|
195 |
+
<ul>
|
196 |
+
<li><a href="https://huggingface.co/spaces/google/cxr-foundation-demo?referral=rad_explain"
|
197 |
+
target="_blank">
|
198 |
+
CXR Foundations Demo <img class="hf-logo"
|
199 |
+
src="https://huggingface.co/datasets/huggingface/brand-assets/resolve/main/hf-logo.svg"></a>
|
200 |
+
-
|
201 |
+
Showcases on-browser, data-efficient, and zero-shot classification of CXR images.</li>
|
202 |
+
<li><a href="https://huggingface.co/spaces/google/path-foundation-demo?referral=rad_explain"
|
203 |
+
target="_blank">
|
204 |
+
Path Foundations Demo <img class="hf-logo"
|
205 |
+
src="https://huggingface.co/datasets/huggingface/brand-assets/resolve/main/hf-logo.svg"></a>
|
206 |
+
-
|
207 |
+
Highlights on-browser, data-efficient classification and outlier detection within pathology
|
208 |
+
slides.</li>
|
209 |
+
<li><a href="https://github.com/Google-Health/medgemma/tree/main/notebooks/fine_tune_with_hugging_face.ipynb" target="_blank">
|
210 |
+
Finetune MedGemma Colab <img class="hf-logo"
|
211 |
+
src="https://upload.wikimedia.org/wikipedia/commons/d/d0/Google_Colaboratory_SVG_Logo.svg"></a>
|
212 |
+
-
|
213 |
+
See an example of how to fine-tune this model.</li>
|
214 |
+
</ul>
|
215 |
+
</div>
|
216 |
+
</div>
|
217 |
+
</div>
|
218 |
+
</body>
|
219 |
+
|
220 |
+
</html>
|
tests/test_cache_store.py
ADDED
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Copyright 2025 Google LLC
|
2 |
+
#
|
3 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4 |
+
# you may not use this file except in compliance with the License.
|
5 |
+
# You may obtain a copy of the License at
|
6 |
+
#
|
7 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8 |
+
#
|
9 |
+
# Unless required by applicable law or agreed to in writing, software
|
10 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12 |
+
# See the License for the specific language governing permissions and
|
13 |
+
# limitations under the License.
|
14 |
+
|
15 |
+
import unittest
|
16 |
+
from unittest.mock import patch
|
17 |
+
import importlib
|
18 |
+
import os
|
19 |
+
# Import module under test. Initial load uses actual or globally patched dependencies.
|
20 |
+
import cache_store
|
21 |
+
|
22 |
+
class TestCacheStore(unittest.TestCase):
|
23 |
+
|
24 |
+
@patch('diskcache.Cache')
|
25 |
+
@patch('os.getenv')
|
26 |
+
def test_cache_directory_env_var_set(self, mock_os_getenv, mock_DiskCache_class):
|
27 |
+
expected_dir = '/custom/cache/path'
|
28 |
+
|
29 |
+
# Mock os.getenv for this test.
|
30 |
+
def getenv_side_effect_set(key, default=None):
|
31 |
+
if key == 'CACHE_DIR':
|
32 |
+
return expected_dir
|
33 |
+
return os.environ.get(key, default) # Fallback for other env var calls.
|
34 |
+
mock_os_getenv.side_effect = getenv_side_effect_set
|
35 |
+
|
36 |
+
# Reload cache_store to apply method-specific mocks.
|
37 |
+
importlib.reload(cache_store)
|
38 |
+
|
39 |
+
# Check os.getenv call by cache_store.py.
|
40 |
+
mock_os_getenv.assert_any_call('CACHE_DIR', '/app/cache_dir')
|
41 |
+
# Check diskcache.Cache initialization with the expected directory.
|
42 |
+
mock_DiskCache_class.assert_called_once_with(expected_dir)
|
43 |
+
|
44 |
+
@patch('diskcache.Cache')
|
45 |
+
@patch('os.getenv')
|
46 |
+
def test_cache_directory_env_var_not_set(self, mock_os_getenv, mock_DiskCache_class):
|
47 |
+
# Mock os.getenv: simulate 'CACHE_DIR' not set, so it returns the default.
|
48 |
+
def getenv_side_effect_not_set(key, default=None):
|
49 |
+
if key == 'CACHE_DIR':
|
50 |
+
# 'default' will be '/app/cache_dir' from cache_store.py's call
|
51 |
+
return default
|
52 |
+
return os.environ.get(key, default) # Fallback for other env vars
|
53 |
+
mock_os_getenv.side_effect = getenv_side_effect_not_set
|
54 |
+
|
55 |
+
# Reload cache_store to apply method-specific mocks.
|
56 |
+
importlib.reload(cache_store)
|
57 |
+
|
58 |
+
# Check os.getenv call by cache_store.py.
|
59 |
+
mock_os_getenv.assert_any_call('CACHE_DIR', '/app/cache_dir')
|
60 |
+
# Check diskcache.Cache initialization with the default directory.
|
61 |
+
mock_DiskCache_class.assert_called_once_with('/app/cache_dir')
|
62 |
+
|
63 |
+
if __name__ == '__main__':
|
64 |
+
unittest.main()
|
utils.py
ADDED
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Copyright 2025 Google LLC
|
2 |
+
#
|
3 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4 |
+
# you may not use this file except in compliance with the License.
|
5 |
+
# You may obtain a copy of the License at
|
6 |
+
#
|
7 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8 |
+
#
|
9 |
+
# Unless required by applicable law or agreed to in writing, software
|
10 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12 |
+
# See the License for the specific language governing permissions and
|
13 |
+
# limitations under the License.
|
14 |
+
|
15 |
+
import base64
|
16 |
+
import mimetypes
|
17 |
+
import logging # Used for logging
|
18 |
+
|
19 |
+
logger = logging.getLogger(__name__)
|
20 |
+
|
21 |
+
# --- Helper Function for Base64 Encoding ---
|
22 |
+
def image_to_base64_data_url(image_path):
|
23 |
+
"""Reads an image file, encodes it to base64, and returns a data URL."""
|
24 |
+
try:
|
25 |
+
mime_type, _ = mimetypes.guess_type(image_path)
|
26 |
+
if not mime_type:
|
27 |
+
# Fallback or raise error if MIME type can't be guessed
|
28 |
+
mime_type = "image/jpeg" # Defaulting to image/jpeg if MIME type detection fails
|
29 |
+
logger.warning(f"Could not determine MIME type for {image_path}, defaulting to {mime_type}")
|
30 |
+
|
31 |
+
with open(image_path, "rb") as image_file:
|
32 |
+
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
33 |
+
return f"data:{mime_type};base64,{encoded_string}"
|
34 |
+
except FileNotFoundError:
|
35 |
+
logger.error(f"Image file not found at {image_path} for base64 encoding.")
|
36 |
+
return None
|
37 |
+
except Exception as e:
|
38 |
+
logger.error(f"Error encoding image to base64: {e}", exc_info=True)
|
39 |
+
return None
|