| 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( |
| """ |
| <style> |
| div[data-testid="stMetricLabel"] p { |
| font-size: 1.05rem; |
| font-weight: 600; |
| } |
| div[data-testid="stMetricValue"] { |
| font-size: 1.35rem; |
| } |
| </style> |
| """, |
| unsafe_allow_html=True, |
| ) |
|
|
|
|
| VISUALIZATION_COMPONENT = st.components.v2.component( |
| "quantization_windowing_visualization", |
| html=""" |
| <div id="viz-root"></div> |
| """, |
| 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() |
|
|