from __future__ import annotations import numpy as np import streamlit as st from metrics import ( format_metric, gray_levels_used, gray_levels_used_from_image, mean_squared_error, peak_signal_to_noise_ratio, shannon_entropy, shannon_entropy_from_image, ) from processing import ( apply_window, bounds_to_level_width, denormalize_image, normalize_with_range, quantize_normalized, resize_for_display, ) from utils import ( BuiltinSample, create_histogram_figure, create_mse_figure, create_visualization_svg_html, load_builtin_sample, load_uploaded_file, list_builtin_samples, ) st.set_page_config( page_title="Image Quantization and CT Windowing Explorer", layout="wide", ) st.markdown( """ """, unsafe_allow_html=True, ) VISUALIZATION_COMPONENT = st.components.v2.component( "quantization_windowing_visualization", html="""
""", js=""" export default function(component) { const { data, parentElement, setTriggerValue } = component; const root = parentElement.querySelector("#viz-root"); if (!root) return; root.innerHTML = (data && data.html) || ""; const clickable = root.querySelectorAll("[data-compare]"); clickable.forEach((node) => { node.onclick = (event) => { event.preventDefault(); const compare = node.getAttribute("data-compare"); if (compare) { setTriggerValue("selected_compare", compare); } }; }); } """, ) WINDOW_PRESETS = { "Manual": None, "Lung [-1350, 150]": (-1350.0, 150.0), "Mediastinal [-160, 240]": (-160.0, 240.0), "Bone [250, 1750]": (250.0, 1750.0), } ROTATION_OPTIONS = { "0°": 0, "90° clockwise": -1, "180°": 2, "90° counterclockwise": 1, } METRIC_COMPARISONS = { "Original vs Quantized + Windowed": ("original", "quantized_windowed"), "Original vs Quantized": ("original", "quantized"), "Windowed vs Quantized + Windowed": ("windowed", "quantized_windowed"), "Original vs Windowed": ("original", "windowed"), "Quantized vs Quantized + Windowed": ("quantized", "quantized_windowed"), "Windowed vs Quantized": ("windowed", "quantized"), } def set_bit_depth_from_slider() -> None: value = int(st.session_state["bit_depth_slider"]) st.session_state["bit_depth"] = value st.session_state["bit_depth_input"] = value def set_bit_depth_from_input() -> None: value = int(st.session_state["bit_depth_input"]) max_bits = int(st.session_state.get("bit_depth_max", 12)) value = min(max(value, 1), max_bits) st.session_state["bit_depth"] = value st.session_state["bit_depth_slider"] = value st.session_state["bit_depth_input"] = value def sync_window_from_slider() -> None: low, high = st.session_state["window_slider"] st.session_state["window_low"] = float(low) st.session_state["window_high"] = float(high) st.session_state["window_preset"] = "Manual" def sync_window_from_inputs(min_value: float, max_value: float, step: float) -> None: low = float(st.session_state["window_low"]) high = float(st.session_state["window_high"]) low = min(max(low, min_value), max_value - step) high = max(min(high, max_value), low + step) st.session_state["window_low"] = float(low) st.session_state["window_high"] = float(high) st.session_state["window_slider"] = (float(low), float(high)) st.session_state["window_preset"] = "Manual" def maybe_reset_controls(loaded_key: str, default_low: float, default_high: float, default_slice_index: int | None) -> None: if st.session_state.get("loaded_key") == loaded_key: return max_bits = int(st.session_state.get("bit_depth_max", 12)) bit_depth = min(6, max_bits) st.session_state["loaded_key"] = loaded_key st.session_state["bit_depth"] = bit_depth st.session_state["bit_depth_slider"] = bit_depth st.session_state["bit_depth_input"] = bit_depth st.session_state["window_preset"] = "Mediastinal [-160, 240]" st.session_state["window_low"] = float(default_low) st.session_state["window_high"] = float(default_high) st.session_state["window_slider"] = (float(default_low), float(default_high)) st.session_state["rotation_choice"] = "0°" st.session_state["metric_mode"] = "Original vs Quantized + Windowed" if default_slice_index is not None: st.session_state["slice_index"] = int(default_slice_index) def apply_selected_preset(min_value: float, max_value: float) -> None: preset_name = st.session_state["window_preset"] if preset_name == "Manual": return if preset_name.startswith("Scanner default"): low, high = st.session_state["scanner_default_bounds"] else: low, high = WINDOW_PRESETS[preset_name] low = max(float(min_value), float(low)) high = min(float(max_value), float(high)) if high <= low: high = min(float(max_value), low + max((max_value - min_value) / 200.0, 1e-3)) st.session_state["window_low"] = float(low) st.session_state["window_high"] = float(high) st.session_state["window_slider"] = (float(low), float(high)) def section_header(title: str, note: str) -> None: title_col, note_col = st.columns([20, 1]) with title_col: st.markdown(f"## {title}") with note_col: with st.popover("ⓘ"): st.caption(note) def format_pair(left: float | int, right: float | int, digits: int = 3) -> str: if isinstance(left, int) and isinstance(right, int): return f"{left} / {right}" return f"{format_metric(float(left), digits)} / {format_metric(float(right), digits)}" def is_ct_like_image(minimum: float, maximum: float) -> bool: return minimum < -500.0 and maximum > 500.0 def get_metric_reference_pair(mode_label: str, images: dict[str, np.ndarray]) -> tuple[np.ndarray, np.ndarray]: left_key, right_key = METRIC_COMPARISONS[mode_label] return images[left_key], images[right_key] def get_metric_reference_labels(mode_label: str) -> tuple[str, str]: left_key, right_key = METRIC_COMPARISONS[mode_label] label_map = { "original": "original", "quantized": "quantized", "windowed": "windowed", "quantized_windowed": "quantized+windowed", } return label_map[left_key], label_map[right_key] def get_metric_reference_keys(mode_label: str) -> tuple[str, str]: return METRIC_COMPARISONS[mode_label] def metric_explainer(title: str, latex: str, text: str) -> None: with st.expander(f"About {title}"): st.latex(latex) st.caption(text) def sync_metric_mode_to_query_params() -> None: st.query_params["compare"] = st.session_state["metric_mode"] def noop_callback() -> None: return None def build_builtin_note(sample_label: str, sample: BuiltinSample | None) -> None: if sample is None: return st.caption(f"Built-in sample: **{sample_label}**") st.caption(f"Dataset source: [{sample.dataset_name}]({sample.dataset_url})") st.caption(f"Original data type: {sample.input_type_label}") st.caption(sample.dataset_summary) def control_note(text: str) -> None: st.caption(text) def show_user_error(prefix: str, exc: Exception) -> None: message = str(exc).strip() or exc.__class__.__name__ st.error(f"{prefix} {message}") def select_current_slice(loaded, source_mode: str): current_image = loaded.image current_slice_index = loaded.default_slice_index if loaded.volume is not None: slice_count = int(loaded.volume.shape[0]) default_index = loaded.default_slice_index if loaded.default_slice_index is not None else slice_count // 2 if "slice_index" not in st.session_state or not (0 <= int(st.session_state["slice_index"]) < slice_count): st.session_state["slice_index"] = int(default_index) st.slider( "Slice index", min_value=0, max_value=slice_count - 1, key="slice_index", help="For volumetric files, smaller indices are usually closer to the head and larger indices move toward the feet or hips.", ) control_note("For 3D volumes, move through the stack one axial slice at a time. The app always analyzes the currently selected slice.") current_slice_index = int(st.session_state["slice_index"]) current_image = loaded.volume[current_slice_index] if source_mode == "Upload a file" and loaded.source_kind in {"dicom"}: st.radio( "Rotate uploaded image", options=list(ROTATION_OPTIONS.keys()), key="rotation_choice", horizontal=False, ) control_note( "If an uploaded DICOM slice looks sideways, use rotation to fix its viewing orientation before comparing outputs." ) rotation_k = ROTATION_OPTIONS[st.session_state["rotation_choice"]] if rotation_k: current_image = np.rot90(current_image, k=rotation_k) return current_image, current_slice_index def main() -> None: st.title("Image Quantization and CT Windowing Explorer") st.markdown( """ This app teaches two different intensity-processing ideas. **Quantization** reduces the number of representable gray levels, while **CT windowing** changes how a selected intensity interval is displayed. """ ) builtin_samples = list_builtin_samples() compare_from_query = st.query_params.get("compare") if isinstance(compare_from_query, list): compare_from_query = compare_from_query[0] if compare_from_query else None if compare_from_query in METRIC_COMPARISONS: st.session_state["metric_mode"] = compare_from_query with st.sidebar: st.markdown("## Input and Controls") control_note("Use the controls below from top to bottom: choose an input, pick a slice if needed, then adjust quantization and CT window settings.") source_mode = st.radio("Input source", ["Built-in sample", "Upload a file"]) control_note("Choose **Built-in sample** to explore prepared examples, or **Upload a file** to test your own grayscale, DICOM data.") selected_builtin = None loaded = None if source_mode == "Built-in sample": sample_label = st.selectbox("Built-in sample", list(builtin_samples.keys())) control_note("Each built-in sample comes from a different CT-oriented dataset so you can compare chest CT, CTPA, and volume-style data.") selected_builtin = builtin_samples[sample_label] try: loaded = load_builtin_sample(sample_label) except Exception as exc: show_user_error("Could not load the selected built-in sample.", exc) return build_builtin_note(sample_label, selected_builtin) else: uploaded = st.file_uploader( "Upload a file", type=["png", "jpg", "jpeg", "dcm"], help=( "Upload a 2D PNG/JPG image or a single-slice DICOM (.dcm) file. " "Large 3D NIfTI volumes are intentionally not supported in this online demo." ), ) st.caption( "Accepted examples: PNG/JPG display images or single-slice DICOM (.dcm). " "NIfTI (.nii/.nii.gz) volumes are not supported for reliability on Hugging Face Spaces." ) control_note( "Uploaded DICOM files can expose scanner-like intensity values, while PNG/JPG files are usually already display-ready 8-bit images." ) if uploaded is not None: try: loaded = load_uploaded_file(uploaded) except Exception as exc: show_user_error("Could not read the uploaded file.", exc) return if loaded is None: st.info("Choose a built-in sample or upload a file to begin.") return lung_low, lung_high = WINDOW_PRESETS["Mediastinal [-160, 240]"] try: preview_image = loaded.image preview_range_image = resize_for_display(preview_image) _, preview_range = normalize_with_range(preview_range_image) except Exception as exc: show_user_error("Could not inspect the input intensity range.", exc) return default_low = max(float(preview_range.minimum), float(lung_low)) default_high = min(float(preview_range.maximum), float(lung_high)) if default_high <= default_low: default_low = float(preview_range.minimum) default_high = float(preview_range.maximum) maybe_reset_controls( loaded_key=f"{source_mode}:{loaded.source_name}", default_low=default_low, default_high=default_high, default_slice_index=loaded.default_slice_index, ) try: current_image, current_slice_index = select_current_slice(loaded, source_mode) raw_image = resize_for_display(current_image) normalized_image, value_range = normalize_with_range(raw_image) except Exception as exc: show_user_error("Could not prepare the selected image or slice.", exc) return st.session_state["bit_depth_max"] = int(max(1, loaded.original_bit_depth)) if st.session_state.get("bit_depth", 1) > st.session_state["bit_depth_max"]: st.session_state["bit_depth"] = st.session_state["bit_depth_max"] st.session_state["bit_depth_slider"] = st.session_state["bit_depth_max"] st.session_state["bit_depth_input"] = st.session_state["bit_depth_max"] bit_depth_col, bit_depth_input_col = st.columns([2, 1]) with bit_depth_col: st.slider( "Quantization bit depth", min_value=1, max_value=int(st.session_state["bit_depth_max"]), key="bit_depth_slider", on_change=set_bit_depth_from_slider, help="Example: 12 bits preserves many levels, while 4 bits can create visible banding.", ) with bit_depth_input_col: st.number_input( " ", min_value=1, max_value=int(st.session_state["bit_depth_max"]), step=1, key="bit_depth_input", on_change=set_bit_depth_from_input, label_visibility="collapsed", help="e.g. 4, 8, or 12", ) control_note("Lower bit depth means fewer representable gray levels. Use the slider for quick changes or type an exact bit depth on the right.") bit_depth = int(st.session_state["bit_depth"]) st.caption( f"Current quantization: {bit_depth} bits = {2 ** bit_depth} representable gray levels. " f"Original max bit depth: {int(st.session_state['bit_depth_max'])}." ) min_value = float(value_range.minimum) max_value = float(value_range.maximum) window_step = max((max_value - min_value) / 200.0, 1e-3) preset_options = ["Manual", "Lung [-1350, 150]"] if loaded.suggested_window is not None: scanner_low, scanner_high = loaded.suggested_window scanner_label = f"Scanner default [{scanner_low:.0f}, {scanner_high:.0f}]" st.session_state["scanner_default_bounds"] = (scanner_low, scanner_high) preset_options.append(scanner_label) if is_ct_like_image(min_value, max_value): preset_options.extend(["Mediastinal [-160, 240]", "Bone [250, 1750]"]) current_preset = st.session_state.get("window_preset", "Lung [-1350, 150]") if current_preset not in preset_options: st.session_state["window_preset"] = "Lung [-1350, 150]" metric_mode = st.session_state.get("metric_mode", "Original vs Quantized + Windowed") st.selectbox( "CT window preset", options=preset_options, key="window_preset", on_change=apply_selected_preset, args=(min_value, max_value), help="Examples: Lung [-1350, 150] and Mediastinal [-160, 240]. The app uses lower and upper bounds directly.", ) control_note("Presets are shortcuts for common clinical-style display ranges. Choosing **Manual** lets you fine-tune the lower and upper bounds yourself.") st.caption("Window bounds are shown as lower and upper intensity values, not only as WL/WW.") st.slider( "Window lower / upper bounds", min_value=min_value, max_value=max_value, key="window_slider", on_change=sync_window_from_slider, help="Drag both handles or type values directly below.", ) control_note("The window slider controls which intensity interval is mapped into visible grayscale. Values outside the interval are clipped to black or white.") low_col, high_col = st.columns(2) with low_col: st.number_input( "Lower bound", min_value=min_value, max_value=max_value, step=window_step, key="window_low", on_change=sync_window_from_inputs, args=(min_value, max_value, window_step), help="e.g. -160 for a mediastinal-style lower bound", ) with high_col: st.number_input( "Upper bound", min_value=min_value, max_value=max_value, step=window_step, key="window_high", on_change=sync_window_from_inputs, args=(min_value, max_value, window_step), help="e.g. 240 for a mediastinal-style upper bound", ) control_note("Use the numeric lower and upper inputs when you want an exact CT-style window instead of dragging the slider handles.") try: window_low = float(st.session_state["window_low"]) window_high = float(st.session_state["window_high"]) level, width = bounds_to_level_width(window_low, window_high) quantized_norm, level_indices = quantize_normalized(normalized_image, bit_depth) quantized_raw = denormalize_image(quantized_norm, value_range) windowed_display, low, high = apply_window(raw_image, level=level, width=width) quantized_windowed_display, _, _ = apply_window(quantized_raw, level=level, width=width) except Exception as exc: show_user_error("Could not compute quantization or windowed displays.", exc) return section_header( "Input Summary", "This section names the current source, the original raw data type, and any notes about how the app interpreted the file.", ) left_info, right_info = st.columns([1.3, 1.7]) with left_info: st.markdown(f"**Current source:** `{loaded.source_name}`") st.markdown(f"**Input type in app:** {loaded.input_type_label}") st.markdown(f"**Observed intensity range:** [{value_range.minimum:.1f}, {value_range.maximum:.1f}]") if current_slice_index is not None: st.markdown(f"**Current slice index:** {current_slice_index}") if loaded.dataset_name and loaded.dataset_url: st.markdown(f"**Dataset source:** [{loaded.dataset_name}]({loaded.dataset_url})") with right_info: if loaded.dataset_summary: st.markdown(f"**Dataset description:** {loaded.dataset_summary}") st.markdown(f"**Interpretation note:** {loaded.note}") section_header( "Visualization", "The four panels separate raw precision loss from display windowing. This makes it easier to see what quantization changes by itself and what windowing changes by itself.", ) viz_left_pad, viz_center, viz_right_pad = st.columns([0.6, 8.8, 0.6]) with viz_center: try: visualization_html = create_visualization_svg_html( original_display=normalized_image, windowed_display=windowed_display, quantized_display=quantized_norm, quantized_windowed_display=quantized_windowed_display, bit_depth=bit_depth, low=low, high=high, metric_mode=metric_mode, ) except Exception as exc: show_user_error("Could not render the visualization.", exc) return visualization_result = VISUALIZATION_COMPONENT( key="visualization_svg", data={"html": visualization_html}, height=1000, on_selected_compare_change=noop_callback, ) st.caption("Click any double-arrow connection to update the active comparison below. The selected connection changes color, and Metrics plus Comparison Views follow that choice.") selected_compare = getattr(visualization_result, "selected_compare", None) if selected_compare in METRIC_COMPARISONS and selected_compare != st.session_state.get("metric_mode"): st.session_state["metric_mode"] = selected_compare st.query_params["compare"] = selected_compare st.rerun() section_header( "Metrics", "Choose the comparison target directly in this section. Metrics are computed on the selected pair of image states.", ) metric_left_pad, metric_center, metric_right_pad = st.columns([0.6, 8.8, 0.6]) with metric_center: metric_title_col, metric_control_col = st.columns([1.6, 1.0]) with metric_title_col: st.caption("Select which two image states should be compared by MSE and PSNR.") with metric_control_col: metric_mode = st.selectbox( "Compare", options=list(METRIC_COMPARISONS.keys()), index=list(METRIC_COMPARISONS.keys()).index(metric_mode), key="metric_mode", on_change=sync_metric_mode_to_query_params, label_visibility="visible", help="Choose which two displays should be compared by the quantitative metrics.", ) st.caption("This selector and the clickable arrows above are synchronized. You can switch the comparison from either place.") metric_images = { "original": raw_image, "quantized": quantized_raw, "windowed": denormalize_image(windowed_display, value_range), "quantized_windowed": denormalize_image(quantized_windowed_display, value_range), } metric_left, metric_right = get_metric_reference_pair(metric_mode, metric_images) histogram_images = { "original": raw_image, "quantized": quantized_raw, "windowed": raw_image, "quantized_windowed": quantized_raw, } histogram_left, histogram_right = get_metric_reference_pair(metric_mode, histogram_images) histogram_left_key, histogram_right_key = get_metric_reference_keys(metric_mode) histogram_bounds = { "original": (float(np.min(raw_image)), float(np.max(raw_image))), "quantized": (float(np.min(quantized_raw)), float(np.max(quantized_raw))), "windowed": (low, high), "quantized_windowed": (low, high), } mse_value = mean_squared_error(metric_left, metric_right) psnr_value = peak_signal_to_noise_ratio(metric_left, metric_right, data_range=value_range.span) left_label, right_label = get_metric_reference_labels(metric_mode) left_levels = gray_levels_used_from_image(metric_left) right_levels = gray_levels_used_from_image(metric_right) left_entropy = shannon_entropy_from_image(metric_left) right_entropy = shannon_entropy_from_image(metric_right) with metric_center: metric_cols_top = st.columns(4) metric_cols_top[0].metric("MSE", format_metric(mse_value)) metric_cols_top[1].metric("PSNR (dB)", format_metric(psnr_value)) metric_cols_top[2].metric( "Gray levels", format_pair(left_levels, right_levels), ) metric_cols_top[3].metric( "Entropy", format_pair(left_entropy, right_entropy), ) st.caption( "Lower PSNR and higher MSE mean stronger intensity distortion between the two selected image states." ) explain_row_1_col_1, explain_row_1_col_2 = st.columns(2) with explain_row_1_col_1: metric_explainer( "MSE", r"\mathrm{MSE} = \frac{1}{N}\sum_{i=1}^{N}(x_i-y_i)^2", "Mean squared error: MSE = (1/N) * sum_i (x_i - y_i)^2. Larger values mean the compared images differ more strongly in intensity.", ) with explain_row_1_col_2: metric_explainer( "PSNR (dB)", r"\mathrm{PSNR} = 20\log_{10}(\mathrm{MAX}) - 10\log_{10}(\mathrm{MSE})", "Peak signal-to-noise ratio: PSNR = 20 log10(MAX) - 10 log10(MSE). Higher values mean the compared images are more similar.", ) explain_row_2_col_1, explain_row_2_col_2 = st.columns(2) with explain_row_2_col_1: metric_explainer( "Gray levels", r"L = \left|\{v \mid v \in \mathrm{image}\}\right|", "This counts the number of distinct intensity values. Quantization usually reduces this count because many original values collapse onto fewer bins.", ) with explain_row_2_col_2: metric_explainer( "Entropy", r"H = -\sum_k p_k \log_2 p_k", "Shannon entropy: H = -sum_k p_k log2(p_k). It summarizes how spread out the intensity distribution is across available levels.", ) section_header( "Comparison Views", "The histogram compares the two currently selected image states using a shared intensity axis. The MSE map shows where the selected pair differs most strongly in squared-error terms.", ) compare_left_pad, compare_center, compare_right_pad = st.columns([0.6, 8.8, 0.6]) with compare_center: hist_col, error_col = st.columns([1.2, 1.0]) with hist_col: st.pyplot( create_histogram_figure( histogram_left, histogram_right, top_label=left_label, bottom_label=right_label, top_bounds=histogram_bounds[histogram_left_key], bottom_bounds=histogram_bounds[histogram_right_key], ), clear_figure=True, ) with error_col: st.pyplot(create_mse_figure(metric_left, metric_right), clear_figure=True) st.markdown("## Interpretation") st.caption( "Quantization changes stored intensities, so metrics like MSE and PSNR measure fidelity loss directly. " "Windowing changes only the display mapping, so the histogram and side-by-side views are better tools for understanding what becomes visible or clipped." ) with st.expander("What the controls mean"): st.markdown( """ - **Bit depth:** Lower bit depth means fewer available gray levels and usually more visible banding. - **Window lower / upper bounds:** These set the displayed intensity interval directly. - **CT window presets:** Lung, mediastinal, and bone presets provide common clinical-style display ranges. """ ) with st.expander("Known limitations"): st.markdown( """ - This app is educational and does not replace a clinical image viewer. - Uploaded PNG and JPG images may already be limited to 8-bit precision. - The current app works on one 2D slice at a time. - Uploaded files are currently limited to PNG/JPG and single-slice DICOM in the active UI. - Public deployment may require license-safe built-in samples if a dataset does not allow redistribution of image content. """ ) if __name__ == "__main__": main()