HuggingKatze's picture
original window bounds fixed
85440a5
Raw
History Blame Contribute Delete
28.7 kB
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()