radvisionai / ui.py
mgbam's picture
Update ui.py
d9d0463 verified
# ui.py
# --- Standard Library Imports ---
import logging
import random
# --- Third-Party Imports ---
import streamlit as st
try:
# Requires: pip install Pillow
from PIL import Image, ImageDraw
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
Image = None # Placeholder
try:
# Requires: pip install streamlit-drawable-canvas
from streamlit_drawable_canvas import st_canvas
DRAWABLE_CANVAS_AVAILABLE = True
except ImportError:
DRAWABLE_CANVAS_AVAILABLE = False
st_canvas = None # Placeholder
# --- Local Application Imports ---
import config # Import the config module to access its constants
from config import TIPS, DISEASE_OPTIONS # Can also import specific constants
# Import utils for helper functions
from utils import format_translation
# Import functions/constants from other local modules conditionally
try:
from ui_components import display_dicom_metadata, dicom_wl_sliders
UI_COMPONENTS_AVAILABLE = True
except ImportError:
UI_COMPONENTS_AVAILABLE = False
# Define simple fallbacks if ui_components is missing
def display_dicom_metadata(metadata):
st.caption("Basic DICOM Metadata Preview (ui_components missing):")
st.json({"metadata_preview": dict(list(metadata.items())[:5])})
def dicom_wl_sliders(wc, ww):
st.warning("DICOM W/L controls unavailable (ui_components missing).")
return wc, ww
try:
from translation_models import LANGUAGE_CODES, AUTO_DETECT_INDICATOR
TRANSLATION_AVAILABLE_UI = True
except ImportError:
LANGUAGE_CODES = {"English": "en"} # Minimal fallback
AUTO_DETECT_INDICATOR = "Auto-Detect"
TRANSLATION_AVAILABLE_UI = False
try:
from dicom_utils import dicom_to_image # Needed for W/L update
DICOM_UTILS_AVAILABLE_UI = True
except ImportError:
DICOM_UTILS_AVAILABLE_UI = False
dicom_to_image = None # Placeholder function
# --- Logger Setup ---
logger = logging.getLogger(__name__)
# ==============================================================================
# === Sidebar Rendering Function ===============================================
# ==============================================================================
def render_sidebar(IS_DICOM, REPORT_UTILS_AVAILABLE):
"""
Renders the sidebar controls and returns the uploaded file object.
Args:
IS_DICOM (bool): Flag indicating if the current image is DICOM.
REPORT_UTILS_AVAILABLE (bool): Flag indicating if report generation is possible.
Returns:
UploadedFile or None: The file uploaded by the user.
"""
with st.sidebar:
st.header("βš•οΈ RadVision Controls")
st.markdown("---")
# Tip of the day
st.info(f"πŸ’‘ {random.choice(TIPS)}")
st.markdown("---")
# --- Upload Section ---
st.header("Image Upload & Settings")
uploaded_file = st.file_uploader(
"Upload Image (JPG, PNG, DCM)",
type=["jpg", "jpeg", "png", "dcm", "dicom"],
key="file_uploader_widget", # Keep consistent key
help="Upload a medical image file for analysis. DICOM (.dcm) is preferred."
)
# --- Demo Mode ---
# Note: Actual demo loading logic resides in app.py or file_handler.py
st.checkbox("πŸš€ Demo Mode", value=st.session_state.get("demo_loaded", False),
help="Load a sample chest X-ray image and analysis (Functionality needs specific implementation).",
key="demo_mode_checkbox", # Add key for state management
# on_change=handle_demo_mode_change # Optional callback if needed
)
# --- ROI Control ---
if st.button("πŸ—‘οΈ Clear ROI", help="Remove the selected Region of Interest (ROI)"):
st.session_state.roi_coords = None
st.session_state.canvas_drawing = None # Clear drawing state too
st.session_state.clear_roi_feedback = True # Flag to show success message
logger.info("ROI cleared via sidebar button.")
st.rerun() # Rerun to update canvas and messages
# Display ROI cleared message if flag is set
if st.session_state.get("clear_roi_feedback"):
st.success("βœ… ROI cleared successfully!")
st.session_state.clear_roi_feedback = False # Reset flag after showing
# --- DICOM Window/Level ---
# Check necessary conditions: is DICOM, components available, and image loaded
if IS_DICOM and UI_COMPONENTS_AVAILABLE and DICOM_UTILS_AVAILABLE_UI and st.session_state.get("display_image"):
st.markdown("---")
st.subheader("DICOM Display (W/L)")
current_wc = st.session_state.get("current_display_wc", 0) # Get current value from state
current_ww = st.session_state.get("current_display_ww", 400) # Get current value from state
# Use the slider component from ui_components
new_wc, new_ww = dicom_wl_sliders(current_wc, current_ww)
# Check if values have changed
if new_wc != current_wc or new_ww != current_ww:
logger.info(f"DICOM W/L changed via UI: WC={new_wc}, WW={new_ww}")
# Update session state immediately
st.session_state.current_display_wc = new_wc
st.session_state.current_display_ww = new_ww
# Attempt to update the display image
if st.session_state.get("dicom_dataset") and dicom_to_image:
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
)
# Validate the result
if isinstance(new_display_img, Image.Image):
# Ensure it's RGB for display consistency
if new_display_img.mode != 'RGB':
new_display_img = new_display_img.convert('RGB')
# Update the display image in session state
st.session_state.display_image = new_display_img
logger.info("DICOM display image updated with new W/L.")
st.rerun() # Rerun to show the updated image
else:
st.error("Failed to update DICOM image with new W/L settings (Invalid Image returned).")
logger.error("dicom_to_image returned non-Image after W/L change.")
except Exception as e:
st.error(f"Error applying W/L settings: {e}")
logger.error(f"Error in dicom_to_image during W/L update: {e}", exc_info=True)
else:
st.warning("Cannot apply W/L: DICOM dataset or converter unavailable.")
logger.warning("Could not apply W/L change: DICOM dataset/utils missing.")
elif IS_DICOM:
# Show message if W/L cannot be adjusted
st.caption("DICOM W/L controls require `ui_components` and `dicom_utils`.")
st.markdown("---")
st.header("πŸ€– AI Analysis Actions")
# Determine if actions requiring an image should be enabled
action_disabled = not isinstance(st.session_state.get("processed_image"), Image.Image)
if action_disabled:
st.caption("Upload an image to enable AI actions.")
# --- Action Buttons ---
if st.button("▢️ Run Initial Analysis", key="analyze_btn", disabled=action_disabled,
help="Perform a general analysis of the entire image or selected ROI."):
st.session_state.last_action = "analyze"
st.rerun() # Trigger action handling in app.py
st.subheader("❓ Ask AI a Question")
# Manage text area content via session state to preserve across reruns
question_value = st.session_state.get("question_input_widget", "")
st.session_state.question_input_widget = st.text_area(
"Enter your question:",
height=100,
key="question_input_widget_area", # Unique key for the widget itself
placeholder="E.g., 'Are there any nodules in the upper right lobe?'",
value=question_value, # Read from state
disabled=action_disabled
)
if st.button("πŸ’¬ Ask Question", key="ask_btn", disabled=action_disabled):
if st.session_state.question_input_widget.strip():
st.session_state.last_action = "ask"
st.rerun() # Trigger action handling
else:
st.warning("Please enter a question before submitting.")
st.subheader("🎯 Condition-Specific Analysis")
# Manage selectbox value via session state
disease_options_with_empty = [""] + sorted(DISEASE_OPTIONS)
current_disease_selection = st.session_state.get("disease_select_widget", "")
try:
# Ensure index is valid
selected_index = disease_options_with_empty.index(current_disease_selection)
except ValueError:
selected_index = 0 # Default to empty if previous selection is invalid
st.session_state.disease_select_widget = st.selectbox(
"Select condition to focus on:",
options=disease_options_with_empty,
key="disease_select_widget_selector",
index=selected_index,
disabled=action_disabled
)
if st.button("🩺 Analyze Condition", key="disease_btn", disabled=action_disabled):
if st.session_state.disease_select_widget:
st.session_state.last_action = "disease"
st.rerun() # Trigger action handling
else:
st.warning("Please select a condition first.")
st.markdown("---")
st.header("πŸ“Š Confidence & Reporting")
# Determine if confidence estimation is possible
can_estimate = bool(
st.session_state.get("history") or
st.session_state.get("initial_analysis") or
st.session_state.get("disease_analysis")
)
confidence_disabled = not can_estimate or action_disabled
if st.button("πŸ“ˆ Estimate AI Confidence", key="confidence_btn",
disabled=confidence_disabled):
st.session_state.last_action = "confidence"
st.rerun() # Trigger action handling
elif not can_estimate and not action_disabled:
st.caption("Perform analysis or ask questions first to enable confidence estimation.")
# Report Generation Button
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):
st.session_state.last_action = "generate_report_data"
st.rerun() # Trigger action handling
elif not REPORT_UTILS_AVAILABLE:
st.caption("PDF reporting unavailable (report_utils missing or faulty).")
# PDF Download Button (appears only when data is ready)
if st.session_state.get("pdf_report_bytes"):
report_filename = f"RadVisionAI_Report_{st.session_state.get('session_id', '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."
)
elif not report_generation_disabled and not action_disabled:
# Show caption only if report generation *could* be possible but hasn't run
st.caption("Click 'Generate PDF Report Data' to create the report.")
return uploaded_file # Return the file object for processing in app.py
# ==============================================================================
# === Image Viewer Rendering Function ==========================================
# ==============================================================================
def render_image_viewer(display_img, is_dicom, dicom_metadata):
"""
Renders the image viewer, canvas for ROI, and DICOM metadata expander.
Args:
display_img (PIL.Image.Image or None): The image to display.
is_dicom (bool): Flag indicating if the image is DICOM.
dicom_metadata (dict): Extracted DICOM metadata.
"""
st.subheader("πŸ–ΌοΈ Image Viewer")
logger.debug(f"render_image_viewer called. display_img type: {type(display_img)}, is_dicom: {is_dicom}")
# Check if a valid PIL Image object is provided
if PIL_AVAILABLE and isinstance(display_img, Image.Image):
# --- Drawable Canvas for ROI ---
if DRAWABLE_CANVAS_AVAILABLE and st_canvas:
st.caption("Draw a rectangle below to define a Region of Interest (ROI). Clear using the sidebar button.")
MAX_CANVAS_WIDTH = 600
MAX_CANVAS_HEIGHT = 500
try:
img_w, img_h = display_img.size
if img_w <= 0 or img_h <= 0:
st.warning("Image has invalid dimensions (<=0). Cannot draw ROI.")
logger.warning(f"Invalid image dimensions for canvas: {img_w}x{img_h}")
# Display the image without canvas if dimensions are bad
st.image(display_img, caption="Image Preview (Invalid Dimensions)", use_container_width=True)
else:
# Calculate canvas dimensions preserving aspect ratio, fitting constraints
aspect_ratio = img_w / img_h
canvas_width = min(img_w, MAX_CANVAS_WIDTH)
canvas_height = int(canvas_width / aspect_ratio)
if canvas_height > MAX_CANVAS_HEIGHT:
canvas_height = MAX_CANVAS_HEIGHT
canvas_width = int(canvas_height * aspect_ratio)
# Ensure minimum dimensions for usability
canvas_width = max(canvas_width, 150)
canvas_height = max(canvas_height, 150)
logger.debug(f"Canvas dimensions calculated: {canvas_width}x{canvas_height}")
# Load initial drawing from session state if it exists (to redraw ROI)
initial_drawing = st.session_state.get("canvas_drawing", None)
canvas_result = st_canvas(
fill_color="rgba(255, 165, 0, 0.2)", # Orange fill
stroke_width=2,
stroke_color="rgba(239, 83, 80, 0.8)", # Red stroke
background_image=display_img, # Display the image here
update_streamlit=True, # Send updates while drawing
height=int(canvas_height),
width=int(canvas_width),
drawing_mode="rect", # Only allow rectangles
initial_drawing=initial_drawing, # Load previous ROI drawing
key="drawable_canvas" # Unique key for the component
)
# Process canvas results to update ROI state
if canvas_result.json_data and canvas_result.json_data.get("objects"):
if len(canvas_result.json_data["objects"]) > 0:
# Use the latest object as the ROI
last_object = canvas_result.json_data["objects"][-1]
if last_object["type"] == "rect":
# Extract geometry, accounting for canvas scaling internal to the object
canvas_left = int(last_object["left"])
canvas_top = int(last_object["top"])
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 from original image to canvas size
scale_x = img_w / canvas_width
scale_y = img_h / canvas_height
# Convert canvas coords back to original image coords
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)
# Clamp coordinates to image boundaries
original_left = max(0, original_left)
original_top = max(0, original_top)
if original_left + original_width > img_w:
original_width = img_w - original_left
if original_top + original_height > img_h:
original_height = img_h - original_top
# Ensure width/height are positive
original_width = max(1, original_width)
original_height = max(1, original_height)
# Create ROI dict
new_roi = {
"left": original_left, "top": original_top,
"width": original_width, "height": original_height
}
# Update session state only if ROI actually changed
# Compare dicts directly
if st.session_state.get("roi_coords") != new_roi:
st.session_state.roi_coords = new_roi
# Store the raw canvas JSON data to redraw it if page reloads
st.session_state.canvas_drawing = canvas_result.json_data
logger.info(f"New ROI selected (original coords): {new_roi}")
# Optionally show feedback - rerun might clear it quickly
# st.success(f"ROI updated: ({original_left},{original_top}) {original_width}x{original_height}", icon="🎯")
# Handle case where drawing was cleared (no objects left) but maybe state wasn't reset
elif not canvas_result.json_data.get("objects") and st.session_state.get("roi_coords"):
logger.info("Canvas cleared, resetting ROI state.")
st.session_state.roi_coords = None
st.session_state.canvas_drawing = None
except Exception as canvas_err:
st.error(f"Error initializing or processing the drawing canvas: {canvas_err}")
logger.error(f"Canvas error: {canvas_err}", exc_info=True)
# Fallback to simple image display if canvas fails
st.image(display_img, caption="Image Preview (Canvas Error)", use_container_width=True)
else:
# Fallback if canvas is not available or disabled
st.image(display_img, caption="Image Preview", use_container_width=True)
if not DRAWABLE_CANVAS_AVAILABLE:
st.caption("ROI selection disabled (`streamlit-drawable-canvas` not installed).")
# Display current ROI coordinates if set
current_roi = st.session_state.get("roi_coords")
if current_roi:
st.info(f"Active ROI: (x={current_roi['left']}, y={current_roi['top']}), W={current_roi['width']}, H={current_roi['height']}", icon="🎯")
st.markdown("---") # Separator
# --- Display DICOM Metadata Expander ---
if is_dicom and dicom_metadata:
with st.expander("πŸ“„ DICOM Metadata", expanded=False):
if UI_COMPONENTS_AVAILABLE:
# Use the dedicated function if available
display_dicom_metadata(dicom_metadata)
else:
# Simple fallback display
st.caption("Detailed DICOM metadata display requires `ui_components`.")
st.json({"metadata_preview": dict(list(dicom_metadata.items())[:10])}) # Show first 10 items
elif is_dicom:
# Case where it's DICOM but metadata extraction failed
st.caption("DICOM file loaded, but no metadata could be extracted.")
elif st.session_state.get("uploaded_file_info"): # Check if a file was uploaded but failed processing/display
st.error("Image preview failed. The file might be corrupted, in an unsupported format, or processing failed.")
logger.error("render_image_viewer called but display_img is not a valid Image object, despite file upload attempt.")
else:
# Default message when no file is uploaded yet
st.info("⬅️ Please upload an image or activate Demo Mode using the sidebar.")
# ==============================================================================
# === Results Tabs Rendering Function ==========================================
# ==============================================================================
def render_results_tabs(TRANSLATION_SERVICE_AVAILABLE, translate_function):
"""
Renders the tabs for displaying analysis results and translation.
Args:
TRANSLATION_SERVICE_AVAILABLE (bool): Flag if translation can be used.
translate_function (callable or None): The actual function to call for translation.
"""
st.subheader("πŸ“Š Analysis & Results")
tab_titles = [
"πŸ”¬ Initial Analysis", "πŸ’¬ Q&A History", "🩺 Condition Focus",
"πŸ“ˆ Confidence", "🌐 Translation"
]
tabs = st.tabs(tab_titles)
# --- Initial Analysis Tab ---
with tabs[0]:
analysis_text = st.session_state.get("initial_analysis", "")
display_text = analysis_text if analysis_text else "Run 'Initial Analysis' from the sidebar to see results here."
st.text_area(
"Overall Findings & Impressions", value=display_text, height=450,
disabled=True, key="initial_analysis_area"
)
# --- Q&A History Tab ---
with tabs[1]:
latest_answer = st.session_state.get("qa_answer", "")
display_text = latest_answer if latest_answer else "Ask a question using the sidebar to see the AI's latest response here."
st.text_area(
"Latest AI Answer", value=display_text, height=200,
disabled=True, key="qa_answer_area"
)
st.markdown("---")
history = st.session_state.get("history", [])
if history:
with st.expander("Full Conversation History", expanded=True):
for i, (q_type, message) in enumerate(reversed(history)):
# Simple formatting based on type
prefix = f"**{q_type}:**"
if q_type.lower() == "user question":
prefix = "**You:**"
elif q_type.lower() == "ai answer":
prefix = "**AI:**"
elif "[Fallback]" in q_type:
prefix = f"⚠️ **{q_type}:**" # Indicate fallback visually
st.markdown(f"{prefix}\n```text\n{message}\n```")
if i < len(history) - 1: st.markdown("---") # Separator
else:
st.caption("No questions asked yet in this session.")
# --- Condition Focus Tab ---
with tabs[2]:
disease_text = st.session_state.get("disease_analysis", "")
display_text = disease_text if disease_text else "Select a condition in the sidebar and click 'Analyze Condition' to see focused results."
st.text_area(
"Condition-Specific Analysis", value=display_text, height=450,
disabled=True, key="disease_analysis_area"
)
# --- Confidence Tab ---
with tabs[3]:
confidence_text = st.session_state.get("confidence_score", "")
display_text = confidence_text if confidence_text else "Click 'Estimate AI Confidence' in the sidebar (after performing analysis) to see results."
st.text_area(
"Estimated AI Confidence", value=display_text, height=450,
disabled=True, key="confidence_score_area"
)
# --- Translation Tab ---
with tabs[4]:
st.subheader("🌐 Translate Analysis Text")
if not TRANSLATION_SERVICE_AVAILABLE or not TRANSLATION_AVAILABLE_UI:
st.warning("Translation features are unavailable. Ensure 'deep-translator' and necessary models/utils are correctly set up.")
else:
st.caption("Select text, choose languages, then click 'Translate'.")
# Options for text selection
text_options = {
"Initial Analysis": st.session_state.get("initial_analysis", ""),
"Latest Q&A Answer": st.session_state.get("qa_answer", ""),
"Condition Analysis": st.session_state.get("disease_analysis", ""),
"Confidence Estimation": st.session_state.get("confidence_score", ""),
"(Enter Custom Text Below)": "" # Placeholder
}
# Filter out empty options, always keep custom text option
available_options = {
label: txt for label, txt in text_options.items() if (txt and str(txt).strip()) or label == "(Enter Custom Text Below)"
}
if not available_options: # Handle case where no analysis has been done yet
available_options = {"(Enter Custom Text Below)": ""}
# Select box for choosing text source
selected_label = st.selectbox(
"Select text to translate:", list(available_options.keys()),
index=0, key="translate_source_select"
)
# Determine the text based on selection
text_to_translate_input = available_options.get(selected_label, "")
# Use state for custom text area to preserve value
custom_text_value = st.session_state.get("custom_translate_text", "")
if selected_label == "(Enter Custom Text Below)":
st.session_state.custom_translate_text = st.text_area(
"Enter or paste custom text to translate:",
value=custom_text_value, height=150, key="custom_translate_text_area"
)
text_to_translate_input = st.session_state.custom_translate_text
# Display the text that will be translated (read-only)
st.text_area(
"Text selected for translation:", value=text_to_translate_input,
height=100, disabled=True, key="text_to_translate_display"
)
# Language selection
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="source_language_select"
)
with col_lang2:
target_language_options = sorted(list(LANGUAGE_CODES.keys()))
# Try to default to English or another common language
default_target_index = 0
if "English" in target_language_options:
default_target_index = target_language_options.index("English")
elif len(target_language_options) > 0:
default_target_index = 0 # Fallback to first available
target_language_name = st.selectbox(
"Translate To:", target_language_options, index=default_target_index,
key="target_language_select"
)
# 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_input or not str(text_to_translate_input).strip():
st.warning("Please select or enter some text to translate.")
st.session_state.translation_error = "Input text is empty."
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.")
st.session_state.translation_result = text_to_translate_input # Show original text
elif translate_function is None:
st.error("Translation function is not available. Cannot translate.")
st.session_state.translation_error = "Internal setup error: translate function missing."
else:
# Perform translation
with st.spinner(f"Translating from '{source_language_name}' to '{target_language_name}'..."):
try:
logger.info(f"Requesting translation: '{source_language_name}' -> '{target_language_name}'")
translation_output = translate_function(
text=text_to_translate_input,
target_language=target_language_name,
source_language=source_language_name # Pass name
)
if translation_output is not None:
st.session_state.translation_result = translation_output
st.success("Translation complete!")
logger.info("Translation successful.")
else:
st.error("Translation failed: Service returned no result. Check logs.")
st.session_state.translation_error = "Translation service returned an empty response."
logger.error("Translation function returned None.")
except Exception as e:
st.error(f"An error occurred during translation: {e}")
logger.error(f"Translation error: {e}", exc_info=True)
st.session_state.translation_error = f"Error: {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,
disabled=True, key="translation_result_area")
elif st.session_state.get("translation_error"):
st.error(f"Translation Error: {st.session_state.translation_error}")
# ==============================================================================
# === Footer Rendering Function ================================================
# ==============================================================================
def render_footer(session_id):
"""Renders the application footer."""
st.markdown("---")
st.caption(f"βš•οΈ {config.APP_TITLE} | Session ID: {session_id or 'N/A'}")
# Use FOOTER_HTML from the imported config module
st.markdown(config.FOOTER_HTML, unsafe_allow_html=True)