# -*- coding: utf-8 -*-
"""
app.py - Main Streamlit application for RadVision AI Advanced.
Handles image uploading, display, ROI selection, interaction with AI models
using an assumed agentic/structured approach for analysis and Q&A,
translation, and report generation. Focuses on responsible AI demonstration.
"""
import streamlit as st
# --- Page Configuration (MUST BE THE FIRST STREAMLIT COMMAND) ---
st.set_page_config(
page_title="RadVision AI Advanced",
layout="wide",
page_icon="⚕️",
initial_sidebar_state="expanded"
)
# --- Core Python Libraries ---
import io
import os
import uuid
import logging
import base64
import hashlib
import subprocess
import sys
from typing import Any, Dict, Optional, Tuple, List, Union
import copy
import random
import re
# --- Ensure deep-translator is installed at runtime if not present ---
try:
from deep_translator import GoogleTranslator
DEEP_TRANSLATOR_INSTALLED = True
except ImportError:
DEEP_TRANSLATOR_INSTALLED = False
try:
print("Attempting to install deep-translator...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "deep-translator"])
from deep_translator import GoogleTranslator
DEEP_TRANSLATOR_INSTALLED = True
print("deep-translator installed successfully.")
except Exception as e:
print(f"CRITICAL: Could not install deep-translator: {e}")
# Flag will remain False, handled later.
# --- Logging Setup ---
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
level=LOG_LEVEL,
format='%(asctime)s - %(levelname)s - [%(name)s:%(funcName)s:%(lineno)d] - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
logger.info("--- RadVision AI Application Start ---")
logger.info(f"Streamlit Version: {st.__version__}")
logger.info(f"Logging Level: {LOG_LEVEL}")
# --- Dependency Checks & Imports ---
# Streamlit Drawable Canvas
try:
from streamlit_drawable_canvas import st_canvas
import streamlit_drawable_canvas as st_canvas_module
CANVAS_VERSION = getattr(st_canvas_module, '__version__', 'Unknown')
logger.info(f"Streamlit Drawable Canvas Version: {CANVAS_VERSION}")
DRAWABLE_CANVAS_AVAILABLE = True
except ImportError:
logger.critical("streamlit-drawable-canvas not found. ROI functionality disabled.")
DRAWABLE_CANVAS_AVAILABLE = False
st_canvas = None # Define as None for checks later
# Pillow (PIL) - Essential
try:
from PIL import Image, ImageDraw, UnidentifiedImageError
import PIL
PIL_VERSION = getattr(PIL, '__version__', 'Unknown')
logger.info(f"Pillow (PIL) Version: {PIL_VERSION}")
PIL_AVAILABLE = True
except ImportError:
st.error("CRITICAL ERROR: Pillow (PIL) is not installed (`pip install Pillow`). Image processing disabled.")
logger.critical("Pillow (PIL) not found. App functionality severely impaired.")
PIL_AVAILABLE = False
st.stop() # Stop execution if PIL is missing
# Pydicom & related libraries
try:
import pydicom
import pydicom.errors
PYDICOM_VERSION = getattr(pydicom, '__version__', 'Unknown')
logger.info(f"Pydicom Version: {PYDICOM_VERSION}")
PYDICOM_AVAILABLE = True
# Check for optional helpers
try: import pylibjpeg; logger.info("pylibjpeg found.")
except ImportError: logger.info("pylibjpeg not found (optional).")
try: import gdcm; logger.info("python-gdcm found.")
except ImportError: logger.info("python-gdcm not found (optional).")
except ImportError:
PYDICOM_VERSION = 'Not Installed'
logger.warning("pydicom not found. DICOM functionality will be disabled.")
PYDICOM_AVAILABLE = False
# --- Custom Backend Modules (Crucial Dependencies) ---
# Assume these modules contain the actual AI interaction logic and prompts
try:
# Assumed to handle DICOM parsing, metadata, and image conversion
from dicom_utils import (
parse_dicom, extract_dicom_metadata, dicom_to_image, get_default_wl
)
DICOM_UTILS_AVAILABLE = True
logger.info("dicom_utils imported successfully.")
except ImportError as e:
logger.error(f"Failed to import dicom_utils: {e}. DICOM features disabled.")
if PYDICOM_AVAILABLE: # Only warn if pydicom *was* available
st.warning("DICOM utilities module missing. DICOM processing limited.")
DICOM_UTILS_AVAILABLE = False
try:
# **ASSUMPTION:** This module uses responsible, agentic prompts (like examples discussed)
# for analysis functions, ensuring cautious language, structure, and limitation reporting.
from llm_interactions import (
run_initial_analysis, run_multimodal_qa, run_disease_analysis, estimate_ai_confidence
)
LLM_INTERACTIONS_AVAILABLE = True
logger.info("llm_interactions imported successfully.")
except ImportError as e:
st.error(f"Core AI module (llm_interactions) failed to import: {e}. Analysis functions disabled.")
logger.critical(f"Failed to import llm_interactions: {e}", exc_info=True)
LLM_INTERACTIONS_AVAILABLE = False
st.stop() # Core functionality missing, stop the app
try:
# **ASSUMPTION:** This module includes disclaimers in the generated PDF.
from report_utils import generate_pdf_report_bytes
REPORT_UTILS_AVAILABLE = True
logger.info("report_utils imported successfully.")
except ImportError as e:
logger.error(f"Failed to import report_utils: {e}. PDF reporting disabled.")
REPORT_UTILS_AVAILABLE = False
try:
# Optional UI helpers
from ui_components import display_dicom_metadata, dicom_wl_sliders
UI_COMPONENTS_AVAILABLE = True
logger.info("ui_components imported successfully.")
except ImportError as e:
logger.warning(f"Failed to import ui_components: {e}. Using basic UI fallbacks.")
UI_COMPONENTS_AVAILABLE = False
def display_dicom_metadata(metadata): st.caption("Metadata Preview:"); st.json(dict(list(metadata.items())[:5])) # Basic fallback
def dicom_wl_sliders(wc, ww): st.caption("W/L sliders unavailable."); return wc, ww # Basic fallback
# --- HF fallback for Q&A (Optional) ---
try:
from hf_models import query_hf_vqa_inference_api, HF_VQA_MODEL_ID
HF_MODELS_AVAILABLE = True
logger.info(f"hf_models imported successfully (Fallback VQA Model: {HF_VQA_MODEL_ID}).")
except ImportError:
HF_VQA_MODEL_ID = "hf_model_unavailable"
HF_MODELS_AVAILABLE = False
def query_hf_vqa_inference_api(img: Image.Image, question: str, roi: Optional[Dict] = None) -> Tuple[str, bool]:
return "[Fallback VQA Unavailable] Module not found.", False
logger.warning("hf_models not found. Fallback VQA disabled.")
# --- Translation Setup ---
try:
# Assumes translation_models internally uses deep-translator if available
from translation_models import (
translate, detect_language, LANGUAGE_CODES, AUTO_DETECT_INDICATOR
)
# Check if the underlying library was actually installed
TRANSLATION_AVAILABLE = DEEP_TRANSLATOR_INSTALLED
if TRANSLATION_AVAILABLE:
logger.info("translation_models imported successfully. Translation is available.")
else:
logger.error("translation_models imported, but deep-translator is missing. Translation disabled.")
st.warning("Translation library (deep-translator) is missing or failed to install. Translation features disabled.")
except ImportError as e:
logger.error(f"Could not import translation_models: {e}. Translation disabled.", exc_info=True)
TRANSLATION_AVAILABLE = False
if DEEP_TRANSLATOR_INSTALLED: # If library is there but model import failed
st.warning(f"Translation module failed to load ({e}). Translation features disabled.")
# Define fallbacks if translation failed
if not TRANSLATION_AVAILABLE:
translate = None
detect_language = None
LANGUAGE_CODES = {"English": "en"} # Minimal fallback
AUTO_DETECT_INDICATOR = "Auto-Detect"
# --- Custom CSS ---
st.markdown(
"""
""",
unsafe_allow_html=True
)
# --- Display Hero Logo ---
logo_path = os.path.join("assets", "radvisionai-hero.jpeg")
if os.path.exists(logo_path):
st.image(logo_path, width=350) # Adjust width as needed
else:
logger.warning(f"Hero logo not found at: {logo_path}. Displaying text title.")
# Fallback to text if logo missing, can be removed if not desired
# st.title("⚕️ RadVision AI Advanced") # Already set elsewhere
# --- Initialize Session State Defaults ---
DEFAULT_STATE = {
"uploaded_file_info": None, "raw_image_bytes": None, "is_dicom": False,
"dicom_dataset": None, "dicom_metadata": {}, "processed_image": None,
"display_image": None, "session_id": None, "history": [],
"initial_analysis": "", "qa_answer": "", "disease_analysis": "",
"confidence_score": "", # Keeping state key, but UI label changes
"last_action": None, "pdf_report_bytes": None, "canvas_drawing": None,
"roi_coords": None, "current_display_wc": None, "current_display_ww": None,
"clear_roi_feedback": False, "demo_loaded": False,
"translation_result": None, "translation_error": None,
}
# Initialize session state
if 'initialized' not in st.session_state:
st.session_state.initialized = True
st.session_state.session_id = str(uuid.uuid4())[:8]
for key, value in DEFAULT_STATE.items():
if key not in st.session_state:
st.session_state[key] = copy.deepcopy(value) if isinstance(value, (dict, list)) else value
logger.info(f"New session initialized: {st.session_state.session_id}")
# Ensure history is always a list
if not isinstance(st.session_state.get("history"), list):
st.session_state.history = []
logger.debug(f"Session state verified for session ID: {st.session_state.session_id}")
# --- Utility Functions ---
def format_translation(translated_text: Optional[str]) -> str:
"""Applies basic formatting for readability, handles None."""
if translated_text is None: return "Translation not available or failed."
try:
text_str = str(translated_text)
# Add line breaks before numbered lists for clarity
formatted_text = re.sub(r'\s+(\d+\.)', r'\n\n\1', text_str)
return formatted_text.strip()
except Exception as e:
logger.error(f"Error formatting translation: {e}", exc_info=True)
return str(translated_text) # Return original on error
# --- Sidebar ---
with st.sidebar:
st.header("⚕️ RadVision Controls")
st.markdown("---")
# Tip of the Day
TIPS = [
"Tip: Use 'Demo Mode' for a quick look with a sample chest X-ray.",
"Tip: Draw a rectangle (ROI) on the image to focus the AI's analysis.",
"Tip: Adjust DICOM Window/Level sliders for better contrast if needed.",
"Tip: Ask specific questions about the image or findings.",
"Tip: Generate a PDF report summarizing the AI interaction.",
"Tip: Use the 'Translation' tab for analysis in other languages.",
"Tip: Click 'Clear ROI' to make the AI analyze the whole image again.",
]
st.info(f"💡 {random.choice(TIPS)}")
st.markdown("---")
# Upload Section
st.header("Image Upload & Settings")
st.caption("🔒 Ensure all images are de-identified before uploading.") # PHI Warning
uploaded_file = st.file_uploader(
"Upload De-Identified Image (JPG, PNG, DCM)",
type=["jpg", "jpeg", "png", "dcm", "dicom"],
key="file_uploader_widget",
help="Upload a de-identified medical image. DICOM (.dcm) preferred. DO NOT upload identifiable patient data unless permitted by privacy regulations."
)
# Demo Mode
demo_mode = st.checkbox("🚀 Demo Mode", value=st.session_state.get("demo_loaded", False),
help="Load a sample chest X-ray image and analysis.")
# Handle Demo Mode Activation (Example - Adapt path/logic as needed)
if demo_mode and not st.session_state.demo_loaded:
logger.info("Demo Mode activated.")
# --- Placeholder for Demo Loading Logic ---
# Example: Load a specific demo file, process it, maybe run initial analysis
try:
demo_file_path = os.path.join("assets", "demo_chest_xray.dcm") # Adjust path
if os.path.exists(demo_file_path):
with open(demo_file_path, "rb") as f:
# Simulate upload process
# This part needs to replicate the logic in "File Upload Logic" section below
# Reset state, read bytes, determine type, process, set state vars
# ... (Add demo file processing logic here) ...
st.session_state.demo_loaded = True
st.success("Demo image loaded!")
st.rerun() # Rerun to reflect loaded state
else:
st.warning("Demo file not found.")
st.session_state.demo_loaded = False # Uncheck if file missing
except Exception as e:
st.error(f"Error loading demo file: {e}")
logger.error(f"Demo load error: {e}", exc_info=True)
st.session_state.demo_loaded = False
# -----------------------------------------
elif not demo_mode and st.session_state.demo_loaded:
logger.info("Demo Mode deactivated.")
# Reset relevant state if demo is turned off
# ... (Add reset logic if needed) ...
st.session_state.demo_loaded = False
# Clear ROI
if DRAWABLE_CANVAS_AVAILABLE: # Only show if canvas is available
if st.button("🗑️ Clear ROI", help="Remove the selected ROI rectangle"):
st.session_state.roi_coords = None
st.session_state.canvas_drawing = None # Clear drawing state too
st.session_state.clear_roi_feedback = True
logger.info("ROI cleared by user.")
st.rerun()
if st.session_state.get("clear_roi_feedback"):
st.success("✅ ROI cleared!")
st.session_state.clear_roi_feedback = False # Reset feedback flag
# DICOM Window/Level Controls
# Show only if it's DICOM, utils available, and image exists
if st.session_state.is_dicom and DICOM_UTILS_AVAILABLE and UI_COMPONENTS_AVAILABLE and st.session_state.display_image:
st.markdown("---")
st.subheader("DICOM Display (W/L)")
new_wc, new_ww = dicom_wl_sliders(
st.session_state.current_display_wc,
st.session_state.current_display_ww
)
# Check if W/L values actually changed
if new_wc != st.session_state.current_display_wc or new_ww != st.session_state.current_display_ww:
logger.info(f"DICOM W/L changed via UI: WC={new_wc}, WW={new_ww}")
st.session_state.current_display_wc = new_wc
st.session_state.current_display_ww = new_ww
if st.session_state.dicom_dataset:
# Update the display image with new W/L settings
with st.spinner("Applying new Window/Level..."):
try:
new_display_img = dicom_to_image(
st.session_state.dicom_dataset, wc=new_wc, ww=new_ww
)
if isinstance(new_display_img, Image.Image):
# Ensure RGB format for display consistency
st.session_state.display_image = new_display_img.convert('RGB') if new_display_img.mode != 'RGB' else new_display_img
st.rerun() # Update the image viewer
else:
st.error("Failed to update DICOM image display.")
logger.error("dicom_to_image returned non-image for W/L update.")
except Exception as e:
st.error(f"Error applying W/L: {e}")
logger.error(f"W/L application error: {e}", exc_info=True)
else:
st.warning("DICOM dataset unavailable to update W/L.")
st.markdown("---")
st.header("🤖 AI Analysis Actions")
# Disable actions if core AI module missing or no image loaded
action_disabled = not LLM_INTERACTIONS_AVAILABLE or not isinstance(st.session_state.get("processed_image"), Image.Image)
# Initial Analysis Button
if st.button("🔬 Run Structured Initial Analysis", key="analyze_btn", disabled=action_disabled,
help="Perform a general, structured analysis (visual description, potential findings, limitations). Assumes backend uses agentic prompt."):
st.session_state.last_action = "analyze"
st.rerun()
# Q&A Section
st.subheader("❓ Ask AI a Question")
question_input = st.text_area(
"Enter your question about the image:",
height=100, key="question_input_widget",
placeholder="E.g., 'Describe the findings in the right lower lung zone.' or 'Is there evidence of cardiomegaly?'",
disabled=action_disabled
)
if st.button("💬 Ask Question", key="ask_btn", disabled=action_disabled):
if question_input.strip():
st.session_state.last_action = "ask"
st.rerun()
else:
st.warning("Please enter a question first.")
# Condition-Specific Analysis Section
st.subheader("🎯 Condition-Specific Analysis")
# Example conditions - adjust as needed
DISEASE_OPTIONS = [
"Pneumonia", "Lung Cancer", "Nodule/Mass", "Effusion", "Fracture", "Stroke",
"Appendicitis", "Bowel Obstruction", "Cardiomegaly", "Aortic Aneurysm",
"Pulmonary Embolism", "Tuberculosis", "COVID-19 Findings", "Brain Tumor", "Arthritis",
]
disease_select = st.selectbox(
"Select condition for focused analysis:",
options=[""] + sorted(DISEASE_OPTIONS),
key="disease_select_widget",
disabled=action_disabled,
help="AI will analyze the image specifically for signs related to this condition. Assumes backend uses agentic prompt."
)
if st.button("🩺 Analyze for Condition", key="disease_btn", disabled=action_disabled):
if disease_select:
st.session_state.last_action = "disease"
st.rerun()
else:
st.warning("Please select a condition.")
st.markdown("---")
st.header("📊 Reporting & Assessment")
# Experimental Confidence Score (Renamed and Warned)
can_estimate = bool(
st.session_state.history or st.session_state.initial_analysis or st.session_state.disease_analysis
)
if st.button("🧪 Estimate LLM Self-Assessment (Experimental)", key="confidence_btn",
disabled=not can_estimate or action_disabled,
help="EXPERIMENTAL: Ask the LLM to assess its own response based on input. Not a clinical confidence score."):
st.session_state.last_action = "confidence"
st.rerun()
# PDF Report Generation
report_generation_disabled = action_disabled or not REPORT_UTILS_AVAILABLE
if st.button("📄 Generate PDF Report Data", key="generate_report_data_btn",
disabled=report_generation_disabled,
help="Compile analysis results into PDF data. Download button will appear below."):
st.session_state.last_action = "generate_report_data"
st.rerun()
# PDF Download Button (appears after generation)
if st.session_state.get("pdf_report_bytes"):
report_filename = f"RadVisionAI_Report_{st.session_state.session_id or 'session'}.pdf"
st.download_button(
label="⬇️ Download PDF Report", data=st.session_state.pdf_report_bytes,
file_name=report_filename, mime="application/pdf",
key="download_pdf_button", help="Download the generated PDF report."
)
# --- File Upload Logic ---
if uploaded_file is not None and PIL_AVAILABLE: # Ensure PIL is available
try:
# Generate a unique identifier based on file content hash for change detection
uploaded_file.seek(0)
file_content_hash = hashlib.sha256(uploaded_file.read()).hexdigest()[:16]
uploaded_file.seek(0) # Reset pointer
new_file_info = f"{uploaded_file.name}-{uploaded_file.size}-{file_content_hash}"
except Exception as e:
logger.warning(f"Could not generate hash for file '{uploaded_file.name}': {e}")
new_file_info = f"{uploaded_file.name}-{uploaded_file.size}-{uuid.uuid4().hex[:8]}" # Fallback ID
# Check if a truly new file has been uploaded
if new_file_info != st.session_state.get("uploaded_file_info"):
logger.info(f"New file upload detected: {uploaded_file.name} ({uploaded_file.size} bytes)")
st.toast(f"Processing '{uploaded_file.name}'...", icon="⏳")
# --- Reset application state for the new file ---
# Preserve only essential session info
keys_to_preserve = {"session_id"} # Keep session ID across uploads
st.session_state.session_id = st.session_state.get("session_id") or str(uuid.uuid4())[:8] # Ensure ID exists
# Reset all other keys to defaults
for key, value in DEFAULT_STATE.items():
if key not in keys_to_preserve:
st.session_state[key] = copy.deepcopy(value) if isinstance(value, (dict, list)) else value
st.session_state.uploaded_file_info = new_file_info # Store new file info
st.session_state.demo_loaded = False # Turn off demo mode on new upload
# -------------------------------------------------
st.session_state.raw_image_bytes = uploaded_file.getvalue()
file_ext = os.path.splitext(uploaded_file.name)[1].lower()
# Determine if DICOM based on type/extension and availability of libraries
st.session_state.is_dicom = (
PYDICOM_AVAILABLE and DICOM_UTILS_AVAILABLE and
("dicom" in uploaded_file.type.lower() or file_ext in (".dcm", ".dicom"))
)
with st.spinner("🔬 Analyzing and preparing image..."):
temp_display_img = None
temp_processed_img = None # Image potentially pre-processed for LLM
processing_success = False
if st.session_state.is_dicom:
logger.info("Processing as DICOM...")
try:
dicom_dataset = parse_dicom(st.session_state.raw_image_bytes, filename=uploaded_file.name)
if dicom_dataset:
st.session_state.dicom_dataset = dicom_dataset
st.session_state.dicom_metadata = extract_dicom_metadata(dicom_dataset)
default_wc, default_ww = get_default_wl(dicom_dataset)
st.session_state.current_display_wc = default_wc
st.session_state.current_display_ww = default_ww
# Get image for display (with default W/L)
temp_display_img = dicom_to_image(dicom_dataset, wc=default_wc, ww=default_ww)
# Get image potentially processed for AI (e.g., normalized, no W/L)
temp_processed_img = dicom_to_image(dicom_dataset, wc=None, ww=None, normalize=True) # Adjust normalization as needed for backend
if isinstance(temp_display_img, Image.Image) and isinstance(temp_processed_img, Image.Image):
processing_success = True
logger.info("DICOM parsed and converted successfully.")
else:
st.error("Failed to convert DICOM pixel data to a displayable image format.")
logger.error("dicom_to_image returned invalid image type(s).")
else:
st.error("Could not parse DICOM file. It might be corrupted or incomplete.")
logger.error("parse_dicom returned None.")
except pydicom.errors.InvalidDicomError:
st.error("Invalid DICOM file format detected. Please upload a valid DICOM (.dcm) file.")
logger.error("InvalidDicomError during parsing.")
st.session_state.is_dicom = False # Treat as non-DICOM if parsing fails
except Exception as e:
st.error(f"An unexpected error occurred processing DICOM: {e}")
logger.error(f"DICOM processing error: {e}", exc_info=True)
st.session_state.is_dicom = False # Treat as non-DICOM on error
# Fallback or primary path for standard image formats
if not st.session_state.is_dicom and not processing_success:
logger.info("Processing as standard image (JPG/PNG)...")
try:
raw_img = Image.open(io.BytesIO(st.session_state.raw_image_bytes))
# Ensure RGB format for consistency with AI models and display
processed_img = raw_img.convert("RGB")
temp_display_img = processed_img.copy() # Use the same image for display and processing
temp_processed_img = processed_img.copy()
processing_success = True
logger.info("Standard image loaded and converted to RGB successfully.")
except UnidentifiedImageError:
st.error("Cannot identify image format. Please upload a valid JPG, PNG, or DICOM file.")
logger.error(f"UnidentifiedImageError for file: {uploaded_file.name}")
except Exception as e:
st.error(f"Error processing standard image: {e}")
logger.error(f"Standard image processing error: {e}", exc_info=True)
# Final state update after processing attempt
if processing_success and isinstance(temp_display_img, Image.Image) and isinstance(temp_processed_img, Image.Image):
# Ensure display image is RGB before storing
st.session_state.display_image = temp_display_img.convert('RGB') if temp_display_img.mode != 'RGB' else temp_display_img
st.session_state.processed_image = temp_processed_img # Store potentially different processed image
st.success(f"✅ Image '{uploaded_file.name}' loaded successfully!")
logger.info(f"Image processing complete for: {uploaded_file.name}")
st.rerun() # Rerun to update the UI with the new image and state
else:
# Clear state if processing failed entirely
st.error("Image loading failed. Please check the file format or try another image.")
logger.error(f"Image processing failed for file: {uploaded_file.name}")
st.session_state.uploaded_file_info = None # Clear file info so user can retry
st.session_state.display_image = None
st.session_state.processed_image = None
st.session_state.is_dicom = False
# No rerun here, let the error message stay
# --- Main Page Content ---
st.markdown("---")
# Moved Title and Disclaimer to the top after imports/config
col1, col2 = st.columns([2, 3], gap="large") # Adjust column ratio and gap as needed
# --- Column 1: Image Viewer & Metadata ---
with col1:
st.subheader("🖼️ Image Viewer")
display_img = st.session_state.get("display_image")
if isinstance(display_img, Image.Image):
# --- Drawable Canvas for ROI ---
if DRAWABLE_CANVAS_AVAILABLE and st_canvas:
st.caption("Draw a rectangle below to select a Region of Interest (ROI).")
# Dynamic canvas size calculation
MAX_CANVAS_WIDTH = 600 # Max width for the canvas container
MAX_CANVAS_HEIGHT = 550 # Max height
img_w, img_h = display_img.size
# Basic validation for image dimensions
if img_w <= 0 or img_h <= 0:
st.warning("Image has invalid dimensions (<= 0). Cannot display canvas.")
else:
aspect_ratio = img_w / img_h
# Calculate initial canvas size based on width constraint
canvas_width = min(img_w, MAX_CANVAS_WIDTH)
canvas_height = int(canvas_width / aspect_ratio)
# If height exceeds max, recalculate based on height constraint
if canvas_height > MAX_CANVAS_HEIGHT:
canvas_height = MAX_CANVAS_HEIGHT
canvas_width = int(canvas_height * aspect_ratio)
# Ensure minimum practical size
canvas_width = max(canvas_width, 150)
canvas_height = max(canvas_height, 150)
# Display the canvas
canvas_result = st_canvas(
fill_color="rgba(255, 165, 0, 0.2)", # Semi-transparent orange fill
stroke_width=2,
stroke_color="rgba(239, 83, 80, 0.8)", # Reddish stroke
background_image=display_img,
update_streamlit=True, # Update Streamlit dynamically on drawing
height=canvas_height,
width=canvas_width,
drawing_mode="rect", # Only allow rectangles
initial_drawing=st.session_state.get("canvas_drawing", None), # Persist drawing state
key="drawable_canvas" # Unique key
)
# Process canvas results to extract ROI coordinates
if canvas_result.json_data and canvas_result.json_data.get("objects"):
# Get the last drawn rectangle (assuming user draws one ROI)
last_object = canvas_result.json_data["objects"][-1]
if last_object["type"] == "rect":
# Extract coordinates from canvas (scaled relative to canvas size)
canvas_left = int(last_object["left"])
canvas_top = int(last_object["top"])
# Account for potential scaling within the canvas object itself
canvas_width_scaled = int(last_object["width"] * last_object.get("scaleX", 1))
canvas_height_scaled = int(last_object["height"] * last_object.get("scaleY", 1))
# Calculate scaling factors from original image to canvas size
scale_x = img_w / canvas_width
scale_y = img_h / canvas_height
# Convert canvas coordinates back to original image coordinates
original_left = int(canvas_left * scale_x)
original_top = int(canvas_top * scale_y)
original_width = int(canvas_width_scaled * scale_x)
original_height = int(canvas_height_scaled * scale_y)
# Ensure coordinates are within image bounds
original_left = max(0, original_left)
original_top = max(0, original_top)
original_width = min(img_w - original_left, original_width)
original_height = min(img_h - original_top, original_height)
# Store the ROI if it's valid and different from the last one
new_roi = {
"left": original_left, "top": original_top,
"width": original_width, "height": original_height
}
if st.session_state.roi_coords != new_roi and original_width > 0 and original_height > 0:
st.session_state.roi_coords = new_roi
st.session_state.canvas_drawing = canvas_result.json_data # Save drawing state
logger.info(f"New ROI selected (original coords): {new_roi}")
# Use st.toast for less intrusive feedback
st.toast(f"ROI set: ({original_left},{original_top}), {original_width}x{original_height}", icon="🎯")
# No rerun needed here as update_streamlit=True handles it
else: # Fallback if canvas is not available
st.image(display_img, caption="Image Preview (ROI drawing disabled)", use_container_width=True)
# Display current ROI coordinates if set
if st.session_state.roi_coords:
roi = st.session_state.roi_coords
st.caption(f"Current ROI: ({roi['left']}, {roi['top']}) Size: {roi['width']}x{roi['height']}")
else:
st.caption("No ROI selected. Analysis will cover the entire image.")
st.markdown("---") # Separator
# Display DICOM Metadata Expander
if st.session_state.is_dicom and st.session_state.dicom_metadata:
with st.expander("📄 View DICOM Metadata", expanded=False):
if UI_COMPONENTS_AVAILABLE:
display_dicom_metadata(st.session_state.dicom_metadata)
else: # Basic fallback if ui_components module failed
st.json(st.session_state.dicom_metadata)
elif st.session_state.is_dicom:
# Case where it's DICOM but metadata extraction failed or was empty
st.caption("DICOM file loaded, but metadata could not be extracted or is empty.")
elif uploaded_file is not None: # If upload happened but display_img is None
st.error("Image preview failed. The file might be corrupted or unsupported after upload.")
else: # Default message when no image is loaded
st.info("⬅️ Please upload a de-identified image or enable Demo Mode in the sidebar.")
# --- Column 2: Analysis Results & Interaction Tabs ---
with col2:
st.subheader("📊 Analysis & Interaction")
tab_titles = [
"🔬 Structured Analysis", # Renamed
"💬 Q&A History",
"🩺 Condition Focus",
"🧪 LLM Self-Assessment", # Renamed
"🌐 Translation"
]
tabs = st.tabs(tab_titles)
# Tab 1: Initial Analysis
with tabs[0]:
st.caption("Displays the AI's general structured analysis of the image/ROI.")
analysis_text = st.session_state.initial_analysis or "Run 'Structured Initial Analysis' from the sidebar."
# Use markdown to render potentially structured output from AI
st.markdown(analysis_text)
# Use a disabled text area as a container if markdown isn't enough or preferred
# st.text_area("Findings & Impressions", value=analysis_text, height=450, disabled=True, key="initial_analysis_display")
# Tab 2: Q&A History
with tabs[1]:
st.caption("Shows the latest answer and full conversation history.")
st.markdown("**Latest AI Answer:**")
latest_answer = st.session_state.qa_answer or "_Ask a question using the sidebar controls._"
st.markdown(latest_answer) # Display latest answer using markdown
# st.text_area("Latest AI Answer", value=latest_answer, height=200, disabled=True, key="qa_answer_display")
st.markdown("---")
# Display full history in an expander
if st.session_state.history:
with st.expander("Full Conversation History", expanded=True):
# Display history chronologically (newest at the bottom typical for chat)
for i, (q_type, message) in enumerate(st.session_state.history):
if q_type.lower() == "user question":
st.markdown(f"**You:** {message}")
elif q_type.lower() == "ai answer":
st.markdown(f"**AI:** {message}")
elif "[fallback]" in q_type.lower(): # Handle fallback display
st.markdown(f"**AI (Fallback):** {message.split('**')[-1]}") # Extract message part
elif q_type.lower() == "system":
st.info(f"*{message}*", icon="ℹ️")
else: # General case
st.markdown(f"**{q_type}:** {message}")
if i < len(st.session_state.history) - 1:
st.markdown("---") # Separator between messages
else:
st.caption("No questions asked in this session yet.")
# Tab 3: Condition Focus
with tabs[2]:
st.caption("Displays the AI's analysis focused on the selected condition.")
condition_text = st.session_state.disease_analysis or "Select a condition and run 'Analyze for Condition' from the sidebar."
st.markdown(condition_text) # Use markdown for display
# st.text_area("Condition-Specific Analysis", value=condition_text, height=450, disabled=True, key="disease_analysis_display")
# Tab 4: LLM Self-Assessment (Experimental)
with tabs[3]:
st.caption("EXPERIMENTAL: Displays the AI's self-assessment score. Not clinical confidence.")
st.warning("""
**⚠️ Important Note:** This score reflects the AI model's internal assessment based on its training and the current interaction context.
It is **highly experimental** and **DOES NOT represent clinical certainty or diagnostic accuracy.**
Use this score for informational insight into the AI's perspective only, and **treat it with extreme caution.**
""", icon="🧪")
confidence_text = st.session_state.confidence_score or "Run 'Estimate LLM Self-Assessment' from the sidebar after performing analysis."
st.markdown(confidence_text) # Use markdown for display
# st.text_area("LLM Self-Assessment Score (Experimental)", value=confidence_text, height=350, disabled=True, key="confidence_display")
# Tab 5: Translation
with tabs[4]:
st.subheader("🌐 Translate Analysis Text")
if not TRANSLATION_AVAILABLE:
st.warning("Translation features are unavailable. The required 'deep-translator' library might be missing or failed to install.", icon="🚫")
else:
st.caption("Select analysis text, choose languages, and click 'Translate'.")
# Populate options dynamically based on available analysis results
text_options = {
"Structured Initial Analysis": st.session_state.initial_analysis,
"Latest Q&A Answer": st.session_state.qa_answer,
"Condition Analysis": st.session_state.disease_analysis,
"LLM Self-Assessment": st.session_state.confidence_score,
"(Enter Custom Text Below)": "" # Option for manual input
}
# Filter out options with no text yet, always include custom entry
available_labels = [label for label, txt in text_options.items() if txt or label == "(Enter Custom Text Below)"]
if not available_labels: available_labels = ["(Enter Custom Text Below)"] # Ensure custom is always there
selected_label = st.selectbox(
"Select text to translate:", options=available_labels, index=0,
key="translate_source_select"
)
text_to_translate = text_options.get(selected_label, "")
# Allow user to input custom text if that option is selected
if selected_label == "(Enter Custom Text Below)":
text_to_translate = st.text_area(
"Enter text to translate:", value="", height=150,
key="translate_custom_input"
)
# Display the text that will be translated (read-only)
st.text_area(
"Text selected/entered for translation:", value=text_to_translate, height=100,
disabled=True, key="translate_preview"
)
# Language selection dropdowns
col_lang1, col_lang2 = st.columns(2)
with col_lang1:
source_language_options = [AUTO_DETECT_INDICATOR] + sorted(list(LANGUAGE_CODES.keys()))
source_language_name = st.selectbox(
"Source Language:", source_language_options, index=0,
key="translate_source_lang"
)
with col_lang2:
target_language_options = sorted(list(LANGUAGE_CODES.keys()))
# Try to default target to Spanish or English if available
default_target_index = 0
preferred_targets = ["Spanish", "English"]
for target in preferred_targets:
if target in target_language_options:
default_target_index = target_language_options.index(target)
break
target_language_name = st.selectbox(
"Translate To:", target_language_options, index=default_target_index,
key="translate_target_lang"
)
# Translate Button
if st.button("🔄 Translate Now", key="translate_button"):
st.session_state.translation_result = None # Clear previous results
st.session_state.translation_error = None
if not text_to_translate or not text_to_translate.strip():
st.warning("Please select or enter text to translate first.", icon="☝️")
st.session_state.translation_error = "Input text is empty."
# Avoid translating if source and target are identical (and not auto-detect)
elif source_language_name == target_language_name and source_language_name != AUTO_DETECT_INDICATOR:
st.info("Source and target languages are the same. No translation performed.", icon="✅")
st.session_state.translation_result = text_to_translate
else:
# Perform translation using the backend function
with st.spinner(f"Translating from '{source_language_name}' to '{target_language_name}'..."):
try:
# Call the translation utility function
translation_output = translate(
text=text_to_translate,
target_language=target_language_name,
source_language=source_language_name
)
if translation_output is not None:
st.session_state.translation_result = translation_output
st.success("Translation complete!", icon="🎉")
else:
# Handle cases where translation returns None without an exception
st.error("Translation service returned an empty result. Please check the input or try again.", icon="❓")
logger.warning("Translation function returned None.")
st.session_state.translation_error = "Translation service returned no result."
except Exception as e:
# Catch potential errors from the translation library/service
st.error(f"Translation failed: {e}", icon="❌")
logger.error(f"Translation error during execution: {e}", exc_info=True)
st.session_state.translation_error = str(e)
# Display Translation Result or Error
if st.session_state.get("translation_result"):
formatted_result = format_translation(st.session_state.translation_result)
st.text_area("Translated Text:", value=formatted_result, height=200, key="translation_output_display")
elif st.session_state.get("translation_error"):
# Show error message if translation failed
st.info(f"Translation Error: {st.session_state.translation_error}", icon="ℹ️")
# --- Button Action Handlers (Centralized Logic) ---
# This block runs *after* the main UI is drawn, responding to button clicks stored in last_action
current_action = st.session_state.get("last_action")
if current_action:
logger.info(f"Handling action: '{current_action}' for session: {st.session_state.session_id}")
# --- Pre-Action Checks ---
action_requires_image = current_action in ["analyze", "ask", "disease", "confidence", "generate_report_data"] # Confidence might use image context too
action_requires_llm = current_action in ["analyze", "ask", "disease", "confidence"]
action_requires_report_util = (current_action == "generate_report_data")
error_occurred = False
if action_requires_image and not isinstance(st.session_state.get("processed_image"), Image.Image):
st.error(f"Cannot perform '{current_action}': No valid image loaded.", icon="🖼️")
error_occurred = True
if not st.session_state.session_id:
st.error("Critical Error: Session ID is missing. Please refresh.", icon="🆔")
error_occurred = True
if action_requires_llm and not LLM_INTERACTIONS_AVAILABLE:
st.error(f"Cannot perform '{current_action}': Core AI interaction module is unavailable.", icon="🤖")
error_occurred = True
if action_requires_report_util and not REPORT_UTILS_AVAILABLE:
st.error(f"Cannot perform '{current_action}': Report generation module is unavailable.", icon="📄")
error_occurred = True
if error_occurred:
st.session_state.last_action = None # Reset action if checks fail
st.stop() # Prevent further execution in this run
# --- Execute Action ---
img_for_llm = st.session_state.processed_image
roi_coords = st.session_state.roi_coords
current_history = st.session_state.history # Assumed to be a list
try:
if current_action == "analyze":
st.toast("🔬 Performing initial structured analysis...", icon="⏳")
with st.spinner("AI analyzing image structure and findings..."):
# **ASSUMPTION:** run_initial_analysis uses a responsible, agentic prompt
analysis_result = run_initial_analysis(img_for_llm, roi=roi_coords)
st.session_state.initial_analysis = analysis_result
# Clear other analysis fields when running initial analysis
st.session_state.qa_answer = ""
st.session_state.disease_analysis = ""
logger.info("Initial analysis action completed.")
st.success("Initial structured analysis complete!", icon="✅")
elif current_action == "ask":
question_text = st.session_state.question_input_widget.strip()
if not question_text:
st.warning("Question input was empty.", icon="❓")
else:
st.toast(f"Asking AI: '{question_text[:50]}...'")
st.session_state.qa_answer = "" # Clear previous answer
with st.spinner("AI formulating answer..."):
# **ASSUMPTION:** run_multimodal_qa handles context appropriately
answer, success_flag = run_multimodal_qa(
img_for_llm, question_text, current_history, roi=roi_coords
)
if success_flag:
st.session_state.qa_answer = answer
# Append interaction to history
st.session_state.history.append(("User Question", question_text))
st.session_state.history.append(("AI Answer", answer))
st.success("AI answered your question!", icon="💬")
else:
# --- Primary AI Failure + Fallback Logic ---
primary_error_msg = f"Primary AI failed to answer: {answer}"
st.session_state.qa_answer = primary_error_msg # Show primary error first
st.error(primary_error_msg, icon="⚠️")
logger.warning(f"Primary Q&A failed: {answer}")
hf_token = os.environ.get("HF_API_TOKEN") or st.secrets.get("HF_API_TOKEN") # Check secrets too
if HF_MODELS_AVAILABLE and hf_token:
st.info(f"Attempting fallback VQA with Hugging Face model: {HF_VQA_MODEL_ID}", icon="🔄")
with st.spinner(f"Trying fallback model ({HF_VQA_MODEL_ID})..."):
try:
fallback_answer, fallback_success = query_hf_vqa_inference_api(
img_for_llm, question_text, roi=roi_coords
)
except Exception as hf_e:
fallback_success = False
fallback_answer = f"Error during fallback query: {hf_e}"
logger.error(f"Fallback VQA query error: {hf_e}", exc_info=True)
if fallback_success:
fallback_display = f"**[Fallback: {HF_VQA_MODEL_ID}]**\n\n{fallback_answer}"
st.session_state.qa_answer += f"\n\n---\n\n{fallback_display}" # Append fallback result
st.session_state.history.append(("[Fallback] User Question", question_text))
st.session_state.history.append(("[Fallback] AI Answer", fallback_display))
st.success("Fallback AI provided an answer.", icon="👍")
else:
fallback_error_msg = f"[Fallback Error - {HF_VQA_MODEL_ID}]: {fallback_answer}"
st.session_state.qa_answer += f"\n\n---\n\n{fallback_error_msg}" # Append fallback error
st.error("Fallback AI also failed.", icon="👎")
logger.warning(f"Fallback VQA failed: {fallback_answer}")
elif HF_MODELS_AVAILABLE and not hf_token:
# Only warn if module exists but token is missing
no_token_msg = "[Fallback Skipped: Hugging Face API Token (HF_API_TOKEN) not configured in environment/secrets]"
st.session_state.qa_answer += f"\n\n---\n\n{no_token_msg}"
st.warning("Hugging Face API token needed for fallback VQA.", icon="🔑")
else:
# If module itself is unavailable
no_fallback_msg = "[Fallback VQA Unavailable]"
st.session_state.qa_answer += f"\n\n---\n\n{no_fallback_msg}"
# Optional: Add a less prominent warning if desired
# st.caption("Note: Fallback Q&A model is not configured.")
# -----------------------------------------
elif current_action == "disease":
selected_disease = st.session_state.disease_select_widget
if not selected_disease:
st.warning("No condition was selected.", icon="🏷️")
else:
st.toast(f"🩺 Analyzing image specifically for '{selected_disease}'...", icon="⏳")
with st.spinner(f"AI analyzing for signs of {selected_disease}..."):
# **ASSUMPTION:** run_disease_analysis uses a responsible, agentic prompt
disease_result = run_disease_analysis(img_for_llm, selected_disease, roi=roi_coords)
st.session_state.disease_analysis = disease_result
st.session_state.qa_answer = "" # Clear Q&A answer
logger.info(f"Disease-specific analysis action for '{selected_disease}' completed.")
st.success(f"Analysis focused on '{selected_disease}' complete!", icon="✅")
elif current_action == "confidence":
# Check if there's anything to base confidence on
if not (current_history or st.session_state.initial_analysis or st.session_state.disease_analysis):
st.warning("Please perform at least one analysis (Initial, Q&A, or Condition) before estimating assessment.", icon="📊")
else:
st.toast("🧪 Estimating LLM self-assessment (Experimental)...", icon="⏳")
with st.spinner("AI assessing its previous responses..."):
# **ASSUMPTION:** estimate_ai_confidence explains its basis and limitations
confidence_result = estimate_ai_confidence(
img_for_llm, history=current_history,
initial_analysis=st.session_state.initial_analysis,
disease_analysis=st.session_state.disease_analysis,
roi=roi_coords
)
st.session_state.confidence_score = confidence_result
st.success("LLM self-assessment estimation complete!", icon="✅")
elif current_action == "generate_report_data":
st.toast("📄 Generating PDF report data...", icon="⏳")
st.session_state.pdf_report_bytes = None # Clear previous
image_for_report = st.session_state.get("display_image") # Use the display image for context
if not isinstance(image_for_report, Image.Image):
st.error("Cannot generate report: No valid image currently loaded.", icon="🖼️")
else:
# Prepare image for PDF (copy, ensure RGB, draw ROI if present)
final_image_for_pdf = image_for_report.copy().convert("RGB")
if roi_coords:
try:
draw = ImageDraw.Draw(final_image_for_pdf)
x0, y0 = roi_coords['left'], roi_coords['top']
x1, y1 = x0 + roi_coords['width'], y0 + roi_coords['height']
# Draw a noticeable rectangle
draw.rectangle(
[x0, y0, x1, y1], outline="red",
width=max(3, int(min(final_image_for_pdf.size) * 0.005)) # Scale width slightly
)
logger.info("ROI bounding box drawn onto image for PDF report.")
except Exception as draw_e:
logger.error(f"Error drawing ROI box on PDF image: {draw_e}", exc_info=True)
st.warning("Could not draw the ROI box on the report image.", icon="✏️")
# Format history for the report
formatted_history = "No Q&A interactions recorded for this session."
if current_history:
lines = []
for q_type, msg in current_history:
# Basic cleaning (remove potential HTML if accidentally included)
cleaned_msg = re.sub('<[^<]+?>', '', str(msg)).strip()
lines.append(f"[{q_type}]:\n{cleaned_msg}")
formatted_history = "\n\n---\n\n".join(lines)
# Gather all data for the report
report_data = {
"Session ID": st.session_state.session_id,
"Image Filename": (st.session_state.uploaded_file_info or "N/A").split('-')[0], # Extract original filename
"Structured Initial Analysis": st.session_state.initial_analysis or "Not Performed",
"Q&A History": formatted_history,
"Condition Specific Analysis": st.session_state.disease_analysis or "Not Performed",
"LLM Self-Assessment (Experimental)": st.session_state.confidence_score or "Not Performed",
}
# Add DICOM summary if available
if st.session_state.is_dicom and st.session_state.dicom_metadata:
# Select key DICOM tags for summary
meta_summary = {
tag: st.session_state.dicom_metadata.get(tag, "N/A")
for tag in ['PatientName', 'PatientID', 'StudyDate', 'Modality', 'StudyDescription', 'InstitutionName']
if tag in st.session_state.dicom_metadata # Check if tag exists
}
if meta_summary:
lines = [f"{k.replace('PatientName', 'Patient Name').replace('PatientID', 'Patient ID').replace('StudyDate', 'Study Date').replace('StudyDescription', 'Study Desc.')}: {v}" for k, v in meta_summary.items()]
report_data["DICOM Summary"] = "\n".join(lines)
# Generate the PDF bytes using the backend utility
with st.spinner("Compiling PDF report..."):
# **ASSUMPTION:** generate_pdf_report_bytes includes necessary disclaimers
pdf_bytes = generate_pdf_report_bytes(
session_id=st.session_state.session_id,
image=final_image_for_pdf, # Image with ROI drawn if applicable
analysis_outputs=report_data,
dicom_metadata=st.session_state.dicom_metadata if st.session_state.is_dicom else None
)
if pdf_bytes:
st.session_state.pdf_report_bytes = pdf_bytes
st.success("PDF report data generated! Download button available in the sidebar.", icon="📄")
logger.info("PDF report generation successful.")
st.balloons() # Fun feedback!
else:
st.error("Failed to generate PDF report data. Check logs for details.", icon="❌")
logger.error("PDF generation function returned None or empty bytes.")
else:
st.warning(f"Unknown action '{current_action}' was triggered. No operation performed.", icon="❓")
logger.warning(f"Unhandled action '{current_action}' encountered.")
except Exception as e:
# General catch-all for errors during action execution
st.error(f"An unexpected error occurred while processing '{current_action}': {e}", icon="💥")
logger.critical(f"Error during action '{current_action}': {e}", exc_info=True)
finally:
# --- Post-Action Cleanup ---
st.session_state.last_action = None # IMPORTANT: Reset the action trigger
logger.debug(f"Action '{current_action}' processing finished.")
# Rerun to update the UI state based on the action's results
st.rerun()
# --- Footer ---
st.markdown("---")
st.caption(f"⚕️ RadVision AI Advanced | Session ID: {st.session_state.get('session_id', 'N/A')}")
st.markdown(
"""
""",
unsafe_allow_html=True
)
logger.info(f"--- Application render cycle complete for session: {st.session_state.session_id} ---")