Spaces:
Running
Running
updated
Browse files- .streamlit/config.toml +4 -0
- Dockerfile +1 -0
- app.py +79 -64
- config/constants.py +0 -10
- requirements-docker.txt +0 -1
- requirements.txt +0 -1
- static/s2f_styles.css +96 -34
- ui/components.py +0 -2
- ui/heatmaps.py +11 -2
- ui/result_display.py +19 -9
- utils/display.py +26 -46
.streamlit/config.toml
CHANGED
|
@@ -2,6 +2,10 @@
|
|
| 2 |
# Required for file uploads on Hugging Face Spaces (iframe blocks XSRF cookies)
|
| 3 |
enableXsrfProtection = false
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
[theme]
|
| 6 |
primaryColor = "#0d9488"
|
| 7 |
backgroundColor = "#ffffff"
|
|
|
|
| 2 |
# Required for file uploads on Hugging Face Spaces (iframe blocks XSRF cookies)
|
| 3 |
enableXsrfProtection = false
|
| 4 |
|
| 5 |
+
# Optional: slimmer top bar (Streamlit ≥1.29). Uncomment if the header still clips custom UI:
|
| 6 |
+
# [client]
|
| 7 |
+
# toolbarMode = "minimal"
|
| 8 |
+
|
| 9 |
[theme]
|
| 10 |
primaryColor = "#0d9488"
|
| 11 |
backgroundColor = "#ffffff"
|
Dockerfile
CHANGED
|
@@ -16,6 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
| 16 |
COPY requirements.txt requirements-docker.txt ./
|
| 17 |
|
| 18 |
# Install Python dependencies - CPU-only PyTorch to fit Space memory limits (avoids OOM)
|
|
|
|
| 19 |
# PyTorch 2.2 + torchvision 0.17 (CPU) - match requirements.txt torch>=2.0
|
| 20 |
# Install numpy first with compatible version (<2) so torch.from_numpy works
|
| 21 |
RUN pip install --no-cache-dir "numpy>=1.20.0,<2.0" && \
|
|
|
|
| 16 |
COPY requirements.txt requirements-docker.txt ./
|
| 17 |
|
| 18 |
# Install Python dependencies - CPU-only PyTorch to fit Space memory limits (avoids OOM)
|
| 19 |
+
# (psutil removed from requirements-docker.txt — optional CPU/RAM widget not used in app)
|
| 20 |
# PyTorch 2.2 + torchvision 0.17 (CPU) - match requirements.txt torch>=2.0
|
| 21 |
# Install numpy first with compatible version (<2) so torch.from_numpy works
|
| 22 |
RUN pip install --no-cache-dir "numpy>=1.20.0,<2.0" && \
|
app.py
CHANGED
|
@@ -25,7 +25,6 @@ from config.constants import (
|
|
| 25 |
MODEL_TYPE_LABELS,
|
| 26 |
SAMPLE_EXTENSIONS,
|
| 27 |
SAMPLE_THUMBNAIL_LIMIT,
|
| 28 |
-
THEMES,
|
| 29 |
)
|
| 30 |
from utils.paths import get_ckp_base, get_ckp_folder, get_sample_folder, list_files_in_folder, model_subfolder
|
| 31 |
from utils.segmentation import estimate_cell_mask
|
|
@@ -37,7 +36,6 @@ from ui.components import (
|
|
| 37 |
render_batch_results,
|
| 38 |
render_result_display,
|
| 39 |
render_region_canvas,
|
| 40 |
-
render_system_status,
|
| 41 |
ST_DIALOG,
|
| 42 |
HAS_DRAWABLE_CANVAS,
|
| 43 |
)
|
|
@@ -47,6 +45,20 @@ CITATION = (
|
|
| 47 |
"<b>\"Shape-to-force (S2F): Predicting Cell Traction Forces from LabelFree Imaging\"</b>, 2026."
|
| 48 |
)
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
# Measure tool dialog: defined early so it exists before render_result_display uses it
|
| 51 |
if HAS_DRAWABLE_CANVAS and ST_DIALOG:
|
| 52 |
@ST_DIALOG("Measure tool", width="medium")
|
|
@@ -56,13 +68,16 @@ if HAS_DRAWABLE_CANVAS and ST_DIALOG:
|
|
| 56 |
st.warning("No prediction available to measure.")
|
| 57 |
return
|
| 58 |
display_mode = st.session_state.get("measure_display_mode", "Default")
|
|
|
|
|
|
|
|
|
|
| 59 |
display_heatmap = apply_display_scale(
|
| 60 |
raw_heatmap, display_mode,
|
| 61 |
min_percentile=st.session_state.get("measure_min_percentile", 0),
|
| 62 |
max_percentile=st.session_state.get("measure_max_percentile", 100),
|
| 63 |
clip_min=st.session_state.get("measure_clip_min", 0),
|
| 64 |
clip_max=st.session_state.get("measure_clip_max", 1),
|
| 65 |
-
|
| 66 |
)
|
| 67 |
bf_img = st.session_state.get("measure_bf_img")
|
| 68 |
original_vals = st.session_state.get("measure_original_vals")
|
|
@@ -88,7 +103,7 @@ def _get_measure_dialog_fn():
|
|
| 88 |
def _populate_measure_session_state(heatmap, img, pixel_sum, force, key_img, colormap_name,
|
| 89 |
display_mode, auto_cell_boundary, cell_mask=None,
|
| 90 |
min_percentile=0, max_percentile=100, clip_min=0, clip_max=1,
|
| 91 |
-
|
| 92 |
"""Populate session state for the measure tool. If cell_mask is None and auto_cell_boundary, computes it."""
|
| 93 |
if cell_mask is None and auto_cell_boundary:
|
| 94 |
cell_mask = estimate_cell_mask(heatmap)
|
|
@@ -98,7 +113,7 @@ def _populate_measure_session_state(heatmap, img, pixel_sum, force, key_img, col
|
|
| 98 |
st.session_state["measure_max_percentile"] = max_percentile
|
| 99 |
st.session_state["measure_clip_min"] = clip_min
|
| 100 |
st.session_state["measure_clip_max"] = clip_max
|
| 101 |
-
st.session_state["
|
| 102 |
st.session_state["measure_bf_img"] = img.copy()
|
| 103 |
st.session_state["measure_input_filename"] = key_img or "image"
|
| 104 |
st.session_state["measure_original_vals"] = build_original_vals(heatmap, pixel_sum, force)
|
|
@@ -110,7 +125,10 @@ def _populate_measure_session_state(heatmap, img, pixel_sum, force, key_img, col
|
|
| 110 |
|
| 111 |
st.set_page_config(page_title="Shape2Force (S2F)", page_icon="🦠", layout="wide")
|
| 112 |
|
| 113 |
-
st.markdown(
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
_css_path = os.path.join(S2F_ROOT, "static", "s2f_styles.css")
|
| 116 |
if os.path.exists(_css_path):
|
|
@@ -276,23 +294,48 @@ with st.sidebar:
|
|
| 276 |
help="When on: estimate cell region from force map and use it for metrics (red contour). When off: use entire map.",
|
| 277 |
)
|
| 278 |
|
| 279 |
-
|
| 280 |
-
"Force
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
format="%.2f",
|
| 286 |
-
help="Min–max range for force values. Values outside are set to 0; inside are rescaled so max shows as red.",
|
| 287 |
)
|
| 288 |
-
if
|
| 289 |
clip_min, clip_max = 0.0, 1.0
|
| 290 |
-
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
min_percentile, max_percentile = 0, 100
|
| 293 |
|
| 294 |
-
st.markdown('<div class="sidebar-section"><span class="section-title">Display</span></div>', unsafe_allow_html=True)
|
| 295 |
-
|
| 296 |
cm_col_lbl, cm_col_sb = st.columns([1, 2])
|
| 297 |
with cm_col_lbl:
|
| 298 |
st.markdown('<p class="selectbox-label">Colormap</p>', unsafe_allow_html=True)
|
|
@@ -305,35 +348,6 @@ with st.sidebar:
|
|
| 305 |
help="Color scheme for the force map. Viridis is often preferred for accessibility.",
|
| 306 |
)
|
| 307 |
|
| 308 |
-
th_col_lbl, th_col_sb = st.columns([1, 2])
|
| 309 |
-
with th_col_lbl:
|
| 310 |
-
st.markdown('<p class="selectbox-label">Theme</p>', unsafe_allow_html=True)
|
| 311 |
-
with th_col_sb:
|
| 312 |
-
theme_name = st.selectbox(
|
| 313 |
-
"Theme",
|
| 314 |
-
list(THEMES.keys()),
|
| 315 |
-
index=0,
|
| 316 |
-
key="s2f_theme",
|
| 317 |
-
label_visibility="collapsed",
|
| 318 |
-
help="App accent color theme.",
|
| 319 |
-
)
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
# Inject theme CSS (main area so it applies globally)
|
| 323 |
-
primary, primary_dark, primary_darker, primary_rgb = THEMES[theme_name]
|
| 324 |
-
st.markdown(
|
| 325 |
-
f"""
|
| 326 |
-
<style>
|
| 327 |
-
:root {{
|
| 328 |
-
--s2f-primary: {primary};
|
| 329 |
-
--s2f-primary-dark: {primary_dark};
|
| 330 |
-
--s2f-primary-darker: {primary_darker};
|
| 331 |
-
--s2f-primary-rgb: {primary_rgb};
|
| 332 |
-
}}
|
| 333 |
-
</style>
|
| 334 |
-
""",
|
| 335 |
-
unsafe_allow_html=True,
|
| 336 |
-
)
|
| 337 |
|
| 338 |
# Main area: image input
|
| 339 |
img_source = st.radio("Image source", ["Upload", "Example"], horizontal=True, label_visibility="collapsed", key="s2f_img_source")
|
|
@@ -405,7 +419,8 @@ if not batch_mode:
|
|
| 405 |
|
| 406 |
# Single-image keys (for non-batch)
|
| 407 |
key_img = (uploaded.name if uploaded else None) if img_source == "Upload" else selected_sample
|
| 408 |
-
|
|
|
|
| 409 |
cached = st.session_state["prediction_result"]
|
| 410 |
has_cached = cached is not None and cached.get("cache_key") == current_key and not batch_mode
|
| 411 |
just_ran = run and checkpoint and has_image and not batch_mode
|
|
@@ -424,8 +439,9 @@ def _load_predictor(model_type, checkpoint, ckp_folder):
|
|
| 424 |
|
| 425 |
|
| 426 |
def _prepare_and_render_cached_result(r, key_img, colormap_name, display_mode, auto_cell_boundary,
|
| 427 |
-
min_percentile, max_percentile, clip_min, clip_max,
|
| 428 |
-
download_key_suffix="", check_measure_dialog=False
|
|
|
|
| 429 |
"""Prepare display from cached result and render. Used by both just_ran and has_cached paths."""
|
| 430 |
img, heatmap, force, pixel_sum = r["img"], r["heatmap"], r["force"], r["pixel_sum"]
|
| 431 |
display_heatmap = apply_display_scale(
|
|
@@ -434,18 +450,19 @@ def _prepare_and_render_cached_result(r, key_img, colormap_name, display_mode, a
|
|
| 434 |
max_percentile=max_percentile,
|
| 435 |
clip_min=clip_min,
|
| 436 |
clip_max=clip_max,
|
| 437 |
-
|
| 438 |
)
|
| 439 |
cell_mask = estimate_cell_mask(heatmap) if auto_cell_boundary else None
|
| 440 |
_populate_measure_session_state(
|
| 441 |
heatmap, img, pixel_sum, force, key_img, colormap_name,
|
| 442 |
display_mode, auto_cell_boundary, cell_mask=cell_mask,
|
| 443 |
min_percentile=min_percentile, max_percentile=max_percentile,
|
| 444 |
-
clip_min=clip_min, clip_max=clip_max,
|
| 445 |
)
|
| 446 |
if check_measure_dialog and st.session_state.pop("open_measure_dialog", False):
|
| 447 |
measure_region_dialog()
|
| 448 |
-
|
|
|
|
| 449 |
render_result_display(
|
| 450 |
img, heatmap, display_heatmap, pixel_sum, force, key_img,
|
| 451 |
download_key_suffix=download_key_suffix,
|
|
@@ -454,7 +471,7 @@ def _prepare_and_render_cached_result(r, key_img, colormap_name, display_mode, a
|
|
| 454 |
measure_region_dialog=_get_measure_dialog_fn(),
|
| 455 |
auto_cell_boundary=auto_cell_boundary,
|
| 456 |
cell_mask=cell_mask,
|
| 457 |
-
clip_min=clip_min, clip_max=clip_max,
|
| 458 |
)
|
| 459 |
|
| 460 |
|
|
@@ -502,7 +519,7 @@ if just_ran_batch:
|
|
| 502 |
clip_min=clip_min,
|
| 503 |
clip_max=clip_max,
|
| 504 |
auto_cell_boundary=auto_cell_boundary,
|
| 505 |
-
|
| 506 |
)
|
| 507 |
except Exception as e:
|
| 508 |
if progress_bar is not None:
|
|
@@ -511,7 +528,6 @@ if just_ran_batch:
|
|
| 511 |
st.code(traceback.format_exc())
|
| 512 |
|
| 513 |
elif batch_mode and st.session_state.get("batch_results"):
|
| 514 |
-
st.success("Prediction complete!")
|
| 515 |
render_batch_results(
|
| 516 |
st.session_state["batch_results"],
|
| 517 |
colormap_name=colormap_name,
|
|
@@ -521,7 +537,7 @@ elif batch_mode and st.session_state.get("batch_results"):
|
|
| 521 |
clip_min=clip_min,
|
| 522 |
clip_max=clip_max,
|
| 523 |
auto_cell_boundary=auto_cell_boundary,
|
| 524 |
-
|
| 525 |
)
|
| 526 |
|
| 527 |
elif just_ran:
|
|
@@ -535,7 +551,7 @@ elif just_ran:
|
|
| 535 |
substrate=sub_val,
|
| 536 |
substrate_config=substrate_config if model_type == "single_cell" else None,
|
| 537 |
)
|
| 538 |
-
cache_key = (model_type, checkpoint, key_img)
|
| 539 |
r = {
|
| 540 |
"img": img.copy(),
|
| 541 |
"heatmap": heatmap.copy(),
|
|
@@ -546,8 +562,9 @@ elif just_ran:
|
|
| 546 |
st.session_state["prediction_result"] = r
|
| 547 |
_prepare_and_render_cached_result(
|
| 548 |
r, key_img, colormap_name, display_mode, auto_cell_boundary,
|
| 549 |
-
min_percentile, max_percentile, clip_min, clip_max,
|
| 550 |
download_key_suffix="", check_measure_dialog=False,
|
|
|
|
| 551 |
)
|
| 552 |
except Exception as e:
|
| 553 |
st.error(f"Prediction failed: {e}")
|
|
@@ -557,8 +574,9 @@ elif has_cached:
|
|
| 557 |
r = st.session_state["prediction_result"]
|
| 558 |
_prepare_and_render_cached_result(
|
| 559 |
r, key_img, colormap_name, display_mode, auto_cell_boundary,
|
| 560 |
-
min_percentile, max_percentile, clip_min, clip_max,
|
| 561 |
download_key_suffix="_cached", check_measure_dialog=True,
|
|
|
|
| 562 |
)
|
| 563 |
|
| 564 |
elif run and not checkpoint:
|
|
@@ -568,9 +586,6 @@ elif run and not has_image and not has_batch:
|
|
| 568 |
elif run and batch_mode and not has_batch:
|
| 569 |
st.warning(f"Please upload or select 1–{BATCH_MAX_IMAGES} images for batch processing.")
|
| 570 |
|
| 571 |
-
st.sidebar.markdown('<div class="sidebar-section"><span class="section-title"></span></div>', unsafe_allow_html=True)
|
| 572 |
-
render_system_status()
|
| 573 |
-
|
| 574 |
st.markdown(f"""
|
| 575 |
<div class="footer-citation">
|
| 576 |
<span>If you find this software useful, please cite: {CITATION}</span>
|
|
|
|
| 25 |
MODEL_TYPE_LABELS,
|
| 26 |
SAMPLE_EXTENSIONS,
|
| 27 |
SAMPLE_THUMBNAIL_LIMIT,
|
|
|
|
| 28 |
)
|
| 29 |
from utils.paths import get_ckp_base, get_ckp_folder, get_sample_folder, list_files_in_folder, model_subfolder
|
| 30 |
from utils.segmentation import estimate_cell_mask
|
|
|
|
| 36 |
render_batch_results,
|
| 37 |
render_result_display,
|
| 38 |
render_region_canvas,
|
|
|
|
| 39 |
ST_DIALOG,
|
| 40 |
HAS_DRAWABLE_CANVAS,
|
| 41 |
)
|
|
|
|
| 45 |
"<b>\"Shape-to-force (S2F): Predicting Cell Traction Forces from LabelFree Imaging\"</b>, 2026."
|
| 46 |
)
|
| 47 |
|
| 48 |
+
|
| 49 |
+
def _inference_cache_condition_key(model_type, use_manual, substrate_val, substrate_config):
|
| 50 |
+
"""Hashable key for substrate / manual conditions so cache invalidates when single-cell inputs change."""
|
| 51 |
+
if model_type != "single_cell":
|
| 52 |
+
return None
|
| 53 |
+
if use_manual and substrate_config is not None:
|
| 54 |
+
return (
|
| 55 |
+
"manual",
|
| 56 |
+
round(float(substrate_config["pixelsize"]), 6),
|
| 57 |
+
round(float(substrate_config["young"]), 2),
|
| 58 |
+
)
|
| 59 |
+
return ("preset", str(substrate_val))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
# Measure tool dialog: defined early so it exists before render_result_display uses it
|
| 63 |
if HAS_DRAWABLE_CANVAS and ST_DIALOG:
|
| 64 |
@ST_DIALOG("Measure tool", width="medium")
|
|
|
|
| 68 |
st.warning("No prediction available to measure.")
|
| 69 |
return
|
| 70 |
display_mode = st.session_state.get("measure_display_mode", "Default")
|
| 71 |
+
_m_clamp = st.session_state.get(
|
| 72 |
+
"measure_clamp_only", st.session_state.get("measure_clip_bounds", False)
|
| 73 |
+
)
|
| 74 |
display_heatmap = apply_display_scale(
|
| 75 |
raw_heatmap, display_mode,
|
| 76 |
min_percentile=st.session_state.get("measure_min_percentile", 0),
|
| 77 |
max_percentile=st.session_state.get("measure_max_percentile", 100),
|
| 78 |
clip_min=st.session_state.get("measure_clip_min", 0),
|
| 79 |
clip_max=st.session_state.get("measure_clip_max", 1),
|
| 80 |
+
clamp_only=_m_clamp,
|
| 81 |
)
|
| 82 |
bf_img = st.session_state.get("measure_bf_img")
|
| 83 |
original_vals = st.session_state.get("measure_original_vals")
|
|
|
|
| 103 |
def _populate_measure_session_state(heatmap, img, pixel_sum, force, key_img, colormap_name,
|
| 104 |
display_mode, auto_cell_boundary, cell_mask=None,
|
| 105 |
min_percentile=0, max_percentile=100, clip_min=0, clip_max=1,
|
| 106 |
+
clamp_only=False):
|
| 107 |
"""Populate session state for the measure tool. If cell_mask is None and auto_cell_boundary, computes it."""
|
| 108 |
if cell_mask is None and auto_cell_boundary:
|
| 109 |
cell_mask = estimate_cell_mask(heatmap)
|
|
|
|
| 113 |
st.session_state["measure_max_percentile"] = max_percentile
|
| 114 |
st.session_state["measure_clip_min"] = clip_min
|
| 115 |
st.session_state["measure_clip_max"] = clip_max
|
| 116 |
+
st.session_state["measure_clamp_only"] = clamp_only
|
| 117 |
st.session_state["measure_bf_img"] = img.copy()
|
| 118 |
st.session_state["measure_input_filename"] = key_img or "image"
|
| 119 |
st.session_state["measure_original_vals"] = build_original_vals(heatmap, pixel_sum, force)
|
|
|
|
| 125 |
|
| 126 |
st.set_page_config(page_title="Shape2Force (S2F)", page_icon="🦠", layout="wide")
|
| 127 |
|
| 128 |
+
st.markdown(
|
| 129 |
+
'<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">',
|
| 130 |
+
unsafe_allow_html=True,
|
| 131 |
+
)
|
| 132 |
|
| 133 |
_css_path = os.path.join(S2F_ROOT, "static", "s2f_styles.css")
|
| 134 |
if os.path.exists(_css_path):
|
|
|
|
| 294 |
help="When on: estimate cell region from force map and use it for metrics (red contour). When off: use entire map.",
|
| 295 |
)
|
| 296 |
|
| 297 |
+
force_scale_mode = st.radio(
|
| 298 |
+
"Force scale",
|
| 299 |
+
["Default", "Range"],
|
| 300 |
+
horizontal=True,
|
| 301 |
+
key="s2f_force_scale",
|
| 302 |
+
help="Default: display forces on the full 0–1 scale. Range: set a sub-range; values outside are zeroed and the rest is stretched to the colormap.",
|
|
|
|
|
|
|
| 303 |
)
|
| 304 |
+
if force_scale_mode == "Default":
|
| 305 |
clip_min, clip_max = 0.0, 1.0
|
| 306 |
+
display_mode = "Default"
|
| 307 |
+
clamp_only = True
|
| 308 |
+
else:
|
| 309 |
+
mn_col, mx_col = st.columns(2)
|
| 310 |
+
with mn_col:
|
| 311 |
+
clip_min = st.number_input(
|
| 312 |
+
"Min",
|
| 313 |
+
min_value=0.0,
|
| 314 |
+
max_value=1.0,
|
| 315 |
+
value=0.0,
|
| 316 |
+
step=0.01,
|
| 317 |
+
format="%.2f",
|
| 318 |
+
key="s2f_clip_min",
|
| 319 |
+
help="Lower bound of the display range (0–1).",
|
| 320 |
+
)
|
| 321 |
+
with mx_col:
|
| 322 |
+
clip_max = st.number_input(
|
| 323 |
+
"Max",
|
| 324 |
+
min_value=0.0,
|
| 325 |
+
max_value=1.0,
|
| 326 |
+
value=1.0,
|
| 327 |
+
step=0.01,
|
| 328 |
+
format="%.2f",
|
| 329 |
+
key="s2f_clip_max",
|
| 330 |
+
help="Upper bound of the display range (0–1).",
|
| 331 |
+
)
|
| 332 |
+
if clip_min >= clip_max:
|
| 333 |
+
st.warning("Min must be less than max. Using 0.00–1.00 for display.")
|
| 334 |
+
clip_min, clip_max = 0.0, 1.0
|
| 335 |
+
display_mode = "Range"
|
| 336 |
+
clamp_only = False
|
| 337 |
min_percentile, max_percentile = 0, 100
|
| 338 |
|
|
|
|
|
|
|
| 339 |
cm_col_lbl, cm_col_sb = st.columns([1, 2])
|
| 340 |
with cm_col_lbl:
|
| 341 |
st.markdown('<p class="selectbox-label">Colormap</p>', unsafe_allow_html=True)
|
|
|
|
| 348 |
help="Color scheme for the force map. Viridis is often preferred for accessibility.",
|
| 349 |
)
|
| 350 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
|
| 352 |
# Main area: image input
|
| 353 |
img_source = st.radio("Image source", ["Upload", "Example"], horizontal=True, label_visibility="collapsed", key="s2f_img_source")
|
|
|
|
| 419 |
|
| 420 |
# Single-image keys (for non-batch)
|
| 421 |
key_img = (uploaded.name if uploaded else None) if img_source == "Upload" else selected_sample
|
| 422 |
+
_cond_key = _inference_cache_condition_key(model_type, use_manual, substrate_val, substrate_config)
|
| 423 |
+
current_key = (model_type, checkpoint, key_img, _cond_key)
|
| 424 |
cached = st.session_state["prediction_result"]
|
| 425 |
has_cached = cached is not None and cached.get("cache_key") == current_key and not batch_mode
|
| 426 |
just_ran = run and checkpoint and has_image and not batch_mode
|
|
|
|
| 439 |
|
| 440 |
|
| 441 |
def _prepare_and_render_cached_result(r, key_img, colormap_name, display_mode, auto_cell_boundary,
|
| 442 |
+
min_percentile, max_percentile, clip_min, clip_max, clamp_only,
|
| 443 |
+
download_key_suffix="", check_measure_dialog=False,
|
| 444 |
+
show_success=False):
|
| 445 |
"""Prepare display from cached result and render. Used by both just_ran and has_cached paths."""
|
| 446 |
img, heatmap, force, pixel_sum = r["img"], r["heatmap"], r["force"], r["pixel_sum"]
|
| 447 |
display_heatmap = apply_display_scale(
|
|
|
|
| 450 |
max_percentile=max_percentile,
|
| 451 |
clip_min=clip_min,
|
| 452 |
clip_max=clip_max,
|
| 453 |
+
clamp_only=clamp_only,
|
| 454 |
)
|
| 455 |
cell_mask = estimate_cell_mask(heatmap) if auto_cell_boundary else None
|
| 456 |
_populate_measure_session_state(
|
| 457 |
heatmap, img, pixel_sum, force, key_img, colormap_name,
|
| 458 |
display_mode, auto_cell_boundary, cell_mask=cell_mask,
|
| 459 |
min_percentile=min_percentile, max_percentile=max_percentile,
|
| 460 |
+
clip_min=clip_min, clip_max=clip_max, clamp_only=clamp_only,
|
| 461 |
)
|
| 462 |
if check_measure_dialog and st.session_state.pop("open_measure_dialog", False):
|
| 463 |
measure_region_dialog()
|
| 464 |
+
if show_success:
|
| 465 |
+
st.success("Prediction complete!")
|
| 466 |
render_result_display(
|
| 467 |
img, heatmap, display_heatmap, pixel_sum, force, key_img,
|
| 468 |
download_key_suffix=download_key_suffix,
|
|
|
|
| 471 |
measure_region_dialog=_get_measure_dialog_fn(),
|
| 472 |
auto_cell_boundary=auto_cell_boundary,
|
| 473 |
cell_mask=cell_mask,
|
| 474 |
+
clip_min=clip_min, clip_max=clip_max, clamp_only=clamp_only,
|
| 475 |
)
|
| 476 |
|
| 477 |
|
|
|
|
| 519 |
clip_min=clip_min,
|
| 520 |
clip_max=clip_max,
|
| 521 |
auto_cell_boundary=auto_cell_boundary,
|
| 522 |
+
clamp_only=clamp_only,
|
| 523 |
)
|
| 524 |
except Exception as e:
|
| 525 |
if progress_bar is not None:
|
|
|
|
| 528 |
st.code(traceback.format_exc())
|
| 529 |
|
| 530 |
elif batch_mode and st.session_state.get("batch_results"):
|
|
|
|
| 531 |
render_batch_results(
|
| 532 |
st.session_state["batch_results"],
|
| 533 |
colormap_name=colormap_name,
|
|
|
|
| 537 |
clip_min=clip_min,
|
| 538 |
clip_max=clip_max,
|
| 539 |
auto_cell_boundary=auto_cell_boundary,
|
| 540 |
+
clamp_only=clamp_only,
|
| 541 |
)
|
| 542 |
|
| 543 |
elif just_ran:
|
|
|
|
| 551 |
substrate=sub_val,
|
| 552 |
substrate_config=substrate_config if model_type == "single_cell" else None,
|
| 553 |
)
|
| 554 |
+
cache_key = (model_type, checkpoint, key_img, _cond_key)
|
| 555 |
r = {
|
| 556 |
"img": img.copy(),
|
| 557 |
"heatmap": heatmap.copy(),
|
|
|
|
| 562 |
st.session_state["prediction_result"] = r
|
| 563 |
_prepare_and_render_cached_result(
|
| 564 |
r, key_img, colormap_name, display_mode, auto_cell_boundary,
|
| 565 |
+
min_percentile, max_percentile, clip_min, clip_max, clamp_only,
|
| 566 |
download_key_suffix="", check_measure_dialog=False,
|
| 567 |
+
show_success=True,
|
| 568 |
)
|
| 569 |
except Exception as e:
|
| 570 |
st.error(f"Prediction failed: {e}")
|
|
|
|
| 574 |
r = st.session_state["prediction_result"]
|
| 575 |
_prepare_and_render_cached_result(
|
| 576 |
r, key_img, colormap_name, display_mode, auto_cell_boundary,
|
| 577 |
+
min_percentile, max_percentile, clip_min, clip_max, clamp_only,
|
| 578 |
download_key_suffix="_cached", check_measure_dialog=True,
|
| 579 |
+
show_success=False,
|
| 580 |
)
|
| 581 |
|
| 582 |
elif run and not checkpoint:
|
|
|
|
| 586 |
elif run and batch_mode and not has_batch:
|
| 587 |
st.warning(f"Please upload or select 1–{BATCH_MAX_IMAGES} images for batch processing.")
|
| 588 |
|
|
|
|
|
|
|
|
|
|
| 589 |
st.markdown(f"""
|
| 590 |
<div class="footer-citation">
|
| 591 |
<span>If you find this software useful, please cite: {CITATION}</span>
|
config/constants.py
CHANGED
|
@@ -27,16 +27,6 @@ TOOL_LABELS = {"polygon": "Polygon", "rect": "Rectangle", "circle": "Circle"}
|
|
| 27 |
# File extensions
|
| 28 |
SAMPLE_EXTENSIONS = (".tif", ".tiff", ".png", ".jpg", ".jpeg")
|
| 29 |
|
| 30 |
-
# UI themes: primary, primary-dark, primary-darker, rgb (for rgba)
|
| 31 |
-
THEMES = {
|
| 32 |
-
"Teal": ("#0d9488", "#0f766e", "#115e59", "13, 148, 136"),
|
| 33 |
-
"Blue": ("#2563eb", "#1d4ed8", "#1e40af", "37, 99, 235"),
|
| 34 |
-
"Indigo": ("#6366f1", "#4f46e5", "#4338ca", "99, 102, 241"),
|
| 35 |
-
"Purple": ("#7c3aed", "#6d28d9", "#5b21b6", "124, 58, 237"),
|
| 36 |
-
"Amber": ("#f59e0b", "#d97706", "#b45309", "245, 158, 11"),
|
| 37 |
-
"Emerald": ("#10b981", "#059669", "#047857", "16, 185, 129"),
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
# Colormaps (OpenCV)
|
| 41 |
COLORMAPS = {
|
| 42 |
"Jet": cv2.COLORMAP_JET,
|
|
|
|
| 27 |
# File extensions
|
| 28 |
SAMPLE_EXTENSIONS = (".tif", ".tiff", ".png", ".jpg", ".jpeg")
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
# Colormaps (OpenCV)
|
| 31 |
COLORMAPS = {
|
| 32 |
"Jet": cv2.COLORMAP_JET,
|
requirements-docker.txt
CHANGED
|
@@ -11,4 +11,3 @@ Pillow>=9.0.0
|
|
| 11 |
plotly>=5.14.0
|
| 12 |
huggingface_hub>=0.20.0
|
| 13 |
reportlab>=4.0.0
|
| 14 |
-
psutil>=5.9.0
|
|
|
|
| 11 |
plotly>=5.14.0
|
| 12 |
huggingface_hub>=0.20.0
|
| 13 |
reportlab>=4.0.0
|
|
|
requirements.txt
CHANGED
|
@@ -12,4 +12,3 @@ Pillow>=9.0.0
|
|
| 12 |
plotly>=5.14.0
|
| 13 |
huggingface_hub>=0.20.0
|
| 14 |
reportlab>=4.0.0
|
| 15 |
-
psutil>=5.9.0
|
|
|
|
| 12 |
plotly>=5.14.0
|
| 13 |
huggingface_hub>=0.20.0
|
| 14 |
reportlab>=4.0.0
|
|
|
static/s2f_styles.css
CHANGED
|
@@ -1,9 +1,15 @@
|
|
| 1 |
-
/* ===
|
| 2 |
:root {
|
| 3 |
--s2f-primary: #0d9488;
|
| 4 |
--s2f-primary-dark: #0f766e;
|
| 5 |
--s2f-primary-darker: #115e59;
|
| 6 |
--s2f-primary-rgb: 13, 148, 136;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
}
|
| 8 |
|
| 9 |
/* === Typography === */
|
|
@@ -11,19 +17,44 @@ html, body, .stApp {
|
|
| 11 |
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
|
| 12 |
}
|
| 13 |
|
| 14 |
-
/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
html, body {
|
| 16 |
height: 100% !important;
|
| 17 |
overflow: hidden !important;
|
| 18 |
margin: 0 !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
.stApp {
|
|
|
|
|
|
|
| 21 |
height: 100vh !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
overflow: hidden !important;
|
|
|
|
| 23 |
display: flex !important;
|
| 24 |
flex-direction: column !important;
|
| 25 |
}
|
| 26 |
-
/* Flex
|
| 27 |
.stApp > div {
|
| 28 |
display: flex !important;
|
| 29 |
flex: 1 !important;
|
|
@@ -119,13 +150,16 @@ section[data-testid="stSidebar"] {
|
|
| 119 |
/* === Sidebar === */
|
| 120 |
section[data-testid="stSidebar"] {
|
| 121 |
position: fixed !important;
|
| 122 |
-
top:
|
| 123 |
left: 0 !important;
|
| 124 |
width: 360px !important;
|
| 125 |
-
height:
|
| 126 |
-
max-height:
|
|
|
|
|
|
|
| 127 |
overflow-x: hidden !important;
|
| 128 |
overflow-y: auto !important;
|
|
|
|
| 129 |
scrollbar-gutter: stable !important;
|
| 130 |
background:
|
| 131 |
linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%),
|
|
@@ -435,7 +469,7 @@ div[data-testid="stHorizontalBlock"]:has([data-testid="stDownloadButton"]):has([
|
|
| 435 |
.stWarning { border-radius: 8px !important; }
|
| 436 |
.stInfo { border-radius: 8px !important; }
|
| 437 |
|
| 438 |
-
/* === Selectbox, multiselect, toggle === */
|
| 439 |
[data-testid="stSelectbox"] > div > div,
|
| 440 |
[data-testid="stSelectbox"] input,
|
| 441 |
[data-testid="stMultiSelect"] > div > div {
|
|
@@ -452,6 +486,39 @@ div[data-testid="stHorizontalBlock"]:has([data-testid="stDownloadButton"]):has([
|
|
| 452 |
[data-testid="stMultiSelect"] > div > div {
|
| 453 |
border-radius: 8px !important;
|
| 454 |
border: 1px solid #cbd5e1 !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
}
|
| 456 |
/* Dropdown options: ensure visible text on light background */
|
| 457 |
[role="listbox"] [role="option"],
|
|
@@ -514,28 +581,6 @@ hr { border-color: #cbd5e1 !important; opacity: 0.7; }
|
|
| 514 |
/* === Plotly chart === */
|
| 515 |
.stPlotlyChart { border-radius: 12px; overflow: hidden; }
|
| 516 |
|
| 517 |
-
/* === System status === */
|
| 518 |
-
.system-status {
|
| 519 |
-
font-size: 0.78rem;
|
| 520 |
-
margin-top: 0.5rem;
|
| 521 |
-
padding: 8px 12px;
|
| 522 |
-
border-radius: 8px;
|
| 523 |
-
border: 1px solid rgba(148, 163, 184, 0.25);
|
| 524 |
-
background: rgba(148, 163, 184, 0.08);
|
| 525 |
-
color: inherit;
|
| 526 |
-
display: flex;
|
| 527 |
-
align-items: center;
|
| 528 |
-
gap: 6px;
|
| 529 |
-
}
|
| 530 |
-
.system-status .status-dot {
|
| 531 |
-
width: 6px;
|
| 532 |
-
height: 6px;
|
| 533 |
-
border-radius: 50%;
|
| 534 |
-
background: #10b981;
|
| 535 |
-
display: inline-block;
|
| 536 |
-
flex-shrink: 0;
|
| 537 |
-
}
|
| 538 |
-
|
| 539 |
/* === Footer citation === */
|
| 540 |
.footer-citation {
|
| 541 |
position: fixed;
|
|
@@ -543,15 +588,18 @@ hr { border-color: #cbd5e1 !important; opacity: 0.7; }
|
|
| 543 |
left: 360px;
|
| 544 |
right: 0;
|
| 545 |
z-index: 999;
|
| 546 |
-
padding: 0.
|
| 547 |
background: #f1f5f9;
|
| 548 |
border-top: 1px solid #e2e8f0;
|
| 549 |
font-size: 0.7rem;
|
| 550 |
color: #64748b;
|
| 551 |
text-align: center;
|
| 552 |
-
line-height: 1.
|
| 553 |
}
|
| 554 |
-
/*
|
|
|
|
|
|
|
|
|
|
| 555 |
[data-testid="stAppViewContainer"],
|
| 556 |
.appview-container .main {
|
| 557 |
margin-left: 360px !important;
|
|
@@ -561,13 +609,22 @@ hr { border-color: #cbd5e1 !important; opacity: 0.7; }
|
|
| 561 |
overflow-x: hidden !important;
|
| 562 |
scrollbar-gutter: stable !important;
|
| 563 |
-webkit-overflow-scrolling: touch !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
}
|
| 565 |
.block-container {
|
| 566 |
-
padding-
|
|
|
|
| 567 |
max-width: 1050px !important;
|
| 568 |
}
|
| 569 |
section[data-testid="stSidebar"] > div:first-child {
|
| 570 |
-
padding-top:
|
|
|
|
| 571 |
}
|
| 572 |
|
| 573 |
/* === Responsive === */
|
|
@@ -579,4 +636,9 @@ section[data-testid="stSidebar"] > div:first-child {
|
|
| 579 |
.s2f-header h1 {
|
| 580 |
font-size: 1.4rem !important;
|
| 581 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
}
|
|
|
|
| 1 |
+
/* === Accent variables (fixed teal; used by buttons, sidebar focus, etc.) === */
|
| 2 |
:root {
|
| 3 |
--s2f-primary: #0d9488;
|
| 4 |
--s2f-primary-dark: #0f766e;
|
| 5 |
--s2f-primary-darker: #115e59;
|
| 6 |
--s2f-primary-rgb: 13, 148, 136;
|
| 7 |
+
/* Space for Streamlit’s fixed top toolbar (Deploy / menu). Set when header exists (see below). */
|
| 8 |
+
--s2f-streamlit-header-offset: 0px;
|
| 9 |
+
}
|
| 10 |
+
/* Streamlit renders a fixed header; without this offset, the sidebar and first main block sit underneath it. */
|
| 11 |
+
.stApp:has(header[data-testid="stHeader"]) {
|
| 12 |
+
--s2f-streamlit-header-offset: 2rem;
|
| 13 |
}
|
| 14 |
|
| 15 |
/* === Typography === */
|
|
|
|
| 17 |
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
|
| 18 |
}
|
| 19 |
|
| 20 |
+
/*
|
| 21 |
+
* Scroll layout (local app + HF iframe):
|
| 22 |
+
* - Lock document scroll so only one vertical scrollbar exists inside the app (avoids double scrollbars
|
| 23 |
+
* when the Space is embedded and the iframe has a fixed height).
|
| 24 |
+
* - The main column is the scroll container; the sidebar stays fixed beside it while main content scrolls.
|
| 25 |
+
* - Requires matching --s2f-streamlit-header-offset so fixed sidebar/top padding clear Streamlit’s toolbar.
|
| 26 |
+
* - HF/embed: wheel/touch at scroll boundaries can chain to the parent page (whole iframe moves, fixed
|
| 27 |
+
* sidebar looks like it scrolls away). overscroll-behavior + a strict #root chain reduces that.
|
| 28 |
+
*/
|
| 29 |
html, body {
|
| 30 |
height: 100% !important;
|
| 31 |
overflow: hidden !important;
|
| 32 |
margin: 0 !important;
|
| 33 |
+
overscroll-behavior-y: contain !important;
|
| 34 |
+
}
|
| 35 |
+
/* React mount: must participate in height % chain or body can grow and steal scroll from inner regions */
|
| 36 |
+
#root {
|
| 37 |
+
height: 100% !important;
|
| 38 |
+
max-height: 100% !important;
|
| 39 |
+
min-height: 0 !important;
|
| 40 |
+
overflow: hidden !important;
|
| 41 |
+
overscroll-behavior-y: contain !important;
|
| 42 |
}
|
| 43 |
.stApp {
|
| 44 |
+
/* Prefer small-dynamic viewport in iframes (mobile URL bar); fall back to % + vh */
|
| 45 |
+
height: 100% !important;
|
| 46 |
height: 100vh !important;
|
| 47 |
+
height: 100dvh !important;
|
| 48 |
+
height: 100svh !important;
|
| 49 |
+
max-height: 100vh !important;
|
| 50 |
+
max-height: 100dvh !important;
|
| 51 |
+
max-height: 100svh !important;
|
| 52 |
overflow: hidden !important;
|
| 53 |
+
overscroll-behavior-y: contain !important;
|
| 54 |
display: flex !important;
|
| 55 |
flex-direction: column !important;
|
| 56 |
}
|
| 57 |
+
/* Flex row: sidebar + main (first flex child of .stApp that wraps the app shell; header may be a sibling above this) */
|
| 58 |
.stApp > div {
|
| 59 |
display: flex !important;
|
| 60 |
flex: 1 !important;
|
|
|
|
| 150 |
/* === Sidebar === */
|
| 151 |
section[data-testid="stSidebar"] {
|
| 152 |
position: fixed !important;
|
| 153 |
+
top: var(--s2f-streamlit-header-offset, 0px) !important;
|
| 154 |
left: 0 !important;
|
| 155 |
width: 360px !important;
|
| 156 |
+
height: calc(100dvh - var(--s2f-streamlit-header-offset, 0px)) !important;
|
| 157 |
+
max-height: calc(100dvh - var(--s2f-streamlit-header-offset, 0px)) !important;
|
| 158 |
+
height: calc(100svh - var(--s2f-streamlit-header-offset, 0px)) !important;
|
| 159 |
+
max-height: calc(100svh - var(--s2f-streamlit-header-offset, 0px)) !important;
|
| 160 |
overflow-x: hidden !important;
|
| 161 |
overflow-y: auto !important;
|
| 162 |
+
overscroll-behavior-y: contain !important;
|
| 163 |
scrollbar-gutter: stable !important;
|
| 164 |
background:
|
| 165 |
linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%),
|
|
|
|
| 469 |
.stWarning { border-radius: 8px !important; }
|
| 470 |
.stInfo { border-radius: 8px !important; }
|
| 471 |
|
| 472 |
+
/* === Selectbox, multiselect, toggle (accent on focus) === */
|
| 473 |
[data-testid="stSelectbox"] > div > div,
|
| 474 |
[data-testid="stSelectbox"] input,
|
| 475 |
[data-testid="stMultiSelect"] > div > div {
|
|
|
|
| 486 |
[data-testid="stMultiSelect"] > div > div {
|
| 487 |
border-radius: 8px !important;
|
| 488 |
border: 1px solid #cbd5e1 !important;
|
| 489 |
+
transition: border-color 0.15s ease, box-shadow 0.15s ease !important;
|
| 490 |
+
}
|
| 491 |
+
section[data-testid="stSidebar"] [data-testid="stSelectbox"] > div > div:focus-within,
|
| 492 |
+
section[data-testid="stSidebar"] [data-testid="stMultiSelect"] > div > div:focus-within {
|
| 493 |
+
border-color: var(--s2f-primary) !important;
|
| 494 |
+
box-shadow: 0 0 0 1px rgba(var(--s2f-primary-rgb), 0.28) !important;
|
| 495 |
+
}
|
| 496 |
+
/* Radio: selected circle uses accent color */
|
| 497 |
+
[data-testid="stRadio"] input[type="radio"] {
|
| 498 |
+
accent-color: var(--s2f-primary);
|
| 499 |
+
}
|
| 500 |
+
[data-testid="stRadio"] input {
|
| 501 |
+
accent-color: var(--s2f-primary);
|
| 502 |
+
}
|
| 503 |
+
/* Toggle: on state uses --s2f-primary */
|
| 504 |
+
[data-testid="stToggle"] [data-baseweb="toggleTrack"] {
|
| 505 |
+
background-color: #cbd5e1 !important;
|
| 506 |
+
}
|
| 507 |
+
[data-testid="stToggle"] [data-baseweb="toggleTrack"][aria-checked="true"] {
|
| 508 |
+
background-color: var(--s2f-primary) !important;
|
| 509 |
+
}
|
| 510 |
+
[data-testid="stToggle"] [data-baseweb="toggleTrack"][data-state="checked"] {
|
| 511 |
+
background-color: var(--s2f-primary) !important;
|
| 512 |
+
}
|
| 513 |
+
[data-testid="stToggle"] [data-baseweb="toggleTrack"][data-state="unchecked"] {
|
| 514 |
+
background-color: #cbd5e1 !important;
|
| 515 |
+
}
|
| 516 |
+
/* Newer Streamlit may render a switch button instead of Base Web track */
|
| 517 |
+
[data-testid="stToggle"] button[role="switch"][aria-checked="true"] {
|
| 518 |
+
background-color: var(--s2f-primary) !important;
|
| 519 |
+
}
|
| 520 |
+
[data-testid="stToggle"] button[role="switch"][aria-checked="false"] {
|
| 521 |
+
background-color: #cbd5e1 !important;
|
| 522 |
}
|
| 523 |
/* Dropdown options: ensure visible text on light background */
|
| 524 |
[role="listbox"] [role="option"],
|
|
|
|
| 581 |
/* === Plotly chart === */
|
| 582 |
.stPlotlyChart { border-radius: 12px; overflow: hidden; }
|
| 583 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
/* === Footer citation === */
|
| 585 |
.footer-citation {
|
| 586 |
position: fixed;
|
|
|
|
| 588 |
left: 360px;
|
| 589 |
right: 0;
|
| 590 |
z-index: 999;
|
| 591 |
+
padding: 0.5rem 1rem 0.55rem;
|
| 592 |
background: #f1f5f9;
|
| 593 |
border-top: 1px solid #e2e8f0;
|
| 594 |
font-size: 0.7rem;
|
| 595 |
color: #64748b;
|
| 596 |
text-align: center;
|
| 597 |
+
line-height: 1.45;
|
| 598 |
}
|
| 599 |
+
/*
|
| 600 |
+
* Main column: horizontal offset for fixed sidebar + vertical scroll only here.
|
| 601 |
+
* Header offset is on stMain only (avoids doubling if container and .main both match).
|
| 602 |
+
*/
|
| 603 |
[data-testid="stAppViewContainer"],
|
| 604 |
.appview-container .main {
|
| 605 |
margin-left: 360px !important;
|
|
|
|
| 609 |
overflow-x: hidden !important;
|
| 610 |
scrollbar-gutter: stable !important;
|
| 611 |
-webkit-overflow-scrolling: touch !important;
|
| 612 |
+
overscroll-behavior-y: contain !important;
|
| 613 |
+
box-sizing: border-box !important;
|
| 614 |
+
}
|
| 615 |
+
/* Clear Streamlit’s fixed toolbar so the custom banner / first widgets aren’t clipped */
|
| 616 |
+
section[data-testid="stMain"] {
|
| 617 |
+
padding-top: var(--s2f-streamlit-header-offset, 0px) !important;
|
| 618 |
+
box-sizing: border-box !important;
|
| 619 |
}
|
| 620 |
.block-container {
|
| 621 |
+
padding-top: 0.75rem !important;
|
| 622 |
+
padding-bottom: 3.25rem !important;
|
| 623 |
max-width: 1050px !important;
|
| 624 |
}
|
| 625 |
section[data-testid="stSidebar"] > div:first-child {
|
| 626 |
+
padding-top: 0.85rem !important;
|
| 627 |
+
padding-bottom: 0.5rem !important;
|
| 628 |
}
|
| 629 |
|
| 630 |
/* === Responsive === */
|
|
|
|
| 636 |
.s2f-header h1 {
|
| 637 |
font-size: 1.4rem !important;
|
| 638 |
}
|
| 639 |
+
.footer-citation {
|
| 640 |
+
left: 0 !important;
|
| 641 |
+
padding-left: 0.75rem !important;
|
| 642 |
+
padding-right: 0.75rem !important;
|
| 643 |
+
}
|
| 644 |
}
|
ui/components.py
CHANGED
|
@@ -4,7 +4,6 @@ import streamlit as st
|
|
| 4 |
# Resolve st.dialog early to fix ordering bug (used in measure dialog)
|
| 5 |
ST_DIALOG = getattr(st, "dialog", None) or getattr(st, "experimental_dialog", None)
|
| 6 |
|
| 7 |
-
from ui.system_status import render_system_status
|
| 8 |
from ui.result_display import render_batch_results, render_result_display
|
| 9 |
from ui.measure_tool import (
|
| 10 |
build_original_vals,
|
|
@@ -18,7 +17,6 @@ from ui.measure_tool import (
|
|
| 18 |
__all__ = [
|
| 19 |
"ST_DIALOG",
|
| 20 |
"HAS_DRAWABLE_CANVAS",
|
| 21 |
-
"render_system_status",
|
| 22 |
"render_batch_results",
|
| 23 |
"render_result_display",
|
| 24 |
"build_original_vals",
|
|
|
|
| 4 |
# Resolve st.dialog early to fix ordering bug (used in measure dialog)
|
| 5 |
ST_DIALOG = getattr(st, "dialog", None) or getattr(st, "experimental_dialog", None)
|
| 6 |
|
|
|
|
| 7 |
from ui.result_display import render_batch_results, render_result_display
|
| 8 |
from ui.measure_tool import (
|
| 9 |
build_original_vals,
|
|
|
|
| 17 |
__all__ = [
|
| 18 |
"ST_DIALOG",
|
| 19 |
"HAS_DRAWABLE_CANVAS",
|
|
|
|
| 20 |
"render_batch_results",
|
| 21 |
"render_result_display",
|
| 22 |
"build_original_vals",
|
ui/heatmaps.py
CHANGED
|
@@ -44,8 +44,15 @@ def _draw_region_overlay(annotated, mask, color, fill_alpha=0.3, stroke_width=2)
|
|
| 44 |
cv2.drawContours(annotated, contours, -1, color, stroke_width)
|
| 45 |
|
| 46 |
|
| 47 |
-
def render_horizontal_colorbar(colormap_name, clip_min=0, clip_max=1, is_rescale=False):
|
| 48 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
ticks = [0, 0.25, 0.5, 0.75, 1]
|
| 50 |
if is_rescale:
|
| 51 |
rng = clip_max - clip_min
|
|
@@ -62,6 +69,8 @@ def render_horizontal_colorbar(colormap_name, clip_min=0, clip_max=1, is_rescale
|
|
| 62 |
</div>
|
| 63 |
"""
|
| 64 |
st.markdown(html, unsafe_allow_html=True)
|
|
|
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def make_annotated_heatmap(heatmap_rgb, mask, fill_alpha=0.3, stroke_color=(0, 188, 212), stroke_width=2):
|
|
|
|
| 44 |
cv2.drawContours(annotated, contours, -1, color, stroke_width)
|
| 45 |
|
| 46 |
|
| 47 |
+
def render_horizontal_colorbar(colormap_name, clip_min=0, clip_max=1, is_rescale=False, caption=None):
|
| 48 |
+
"""
|
| 49 |
+
Render a compact horizontal colorbar for batch mode, anchored above the table.
|
| 50 |
+
|
| 51 |
+
When ``is_rescale`` is True (Force scale **Range** with a strict sub-interval), tick labels show
|
| 52 |
+
model force values in ``[clip_min, clip_max]``. The gradient still spans the full colormap because
|
| 53 |
+
the heatmap has already been **rescaled** so the lowest (highest) value in your range maps to
|
| 54 |
+
the colormap minimum (maximum)—same convention as the main Plotly view.
|
| 55 |
+
"""
|
| 56 |
ticks = [0, 0.25, 0.5, 0.75, 1]
|
| 57 |
if is_rescale:
|
| 58 |
rng = clip_max - clip_min
|
|
|
|
| 69 |
</div>
|
| 70 |
"""
|
| 71 |
st.markdown(html, unsafe_allow_html=True)
|
| 72 |
+
if caption:
|
| 73 |
+
st.caption(caption)
|
| 74 |
|
| 75 |
|
| 76 |
def make_annotated_heatmap(heatmap_rgb, mask, fill_alpha=0.3, stroke_color=(0, 188, 212), stroke_width=2):
|
ui/result_display.py
CHANGED
|
@@ -22,10 +22,13 @@ from ui.measure_tool import (
|
|
| 22 |
HAS_DRAWABLE_CANVAS,
|
| 23 |
)
|
| 24 |
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
def render_batch_results(batch_results, colormap_name="Jet", display_mode="Default",
|
| 27 |
min_percentile=0, max_percentile=100, clip_min=0, clip_max=1,
|
| 28 |
-
auto_cell_boundary=False,
|
| 29 |
"""
|
| 30 |
Render batch prediction results: summary table, bright-field row, heatmap row, and bulk download.
|
| 31 |
batch_results: list of dicts with img, heatmap, force, pixel_sum, key_img, cell_mask.
|
|
@@ -43,7 +46,7 @@ def render_batch_results(batch_results, colormap_name="Jet", display_mode="Defau
|
|
| 43 |
r["_display_heatmap"] = apply_display_scale(
|
| 44 |
r["heatmap"], display_mode,
|
| 45 |
min_percentile=min_percentile, max_percentile=max_percentile,
|
| 46 |
-
clip_min=clip_min, clip_max=clip_max,
|
| 47 |
)
|
| 48 |
# Build table rows - consistent column names for both modes
|
| 49 |
headers = ["Image", "Force", "Sum", "Max", "Mean"]
|
|
@@ -86,7 +89,14 @@ def render_batch_results(batch_results, colormap_name="Jet", display_mode="Defau
|
|
| 86 |
)
|
| 87 |
with hm_cols[i % n_cols]:
|
| 88 |
st.image(hm_rgb, caption=r["key_img"], use_container_width=True)
|
| 89 |
-
render_horizontal_colorbar(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
# Table
|
| 91 |
st.dataframe(
|
| 92 |
{h: [r[i] for r in rows] for i, h in enumerate(headers)},
|
|
@@ -102,7 +112,7 @@ def render_batch_results(batch_results, colormap_name="Jet", display_mode="Defau
|
|
| 102 |
vals = vals[vals > 0] if np.any(vals > 0) else vals
|
| 103 |
st.markdown(f"**{r['key_img']}**")
|
| 104 |
if len(vals) > 0:
|
| 105 |
-
fig = go.Figure(data=[go.Histogram(x=vals, nbinsx=50, marker_color=
|
| 106 |
fig.update_layout(
|
| 107 |
height=220, margin=dict(l=40, r=20, t=10, b=40),
|
| 108 |
xaxis_title="Force value", yaxis_title="Count",
|
|
@@ -149,7 +159,7 @@ def render_batch_results(batch_results, colormap_name="Jet", display_mode="Defau
|
|
| 149 |
|
| 150 |
def render_result_display(img, raw_heatmap, display_heatmap, pixel_sum, force, key_img, download_key_suffix="",
|
| 151 |
colormap_name="Jet", display_mode="Default", measure_region_dialog=None, auto_cell_boundary=True,
|
| 152 |
-
cell_mask=None, clip_min=0.0, clip_max=1.0,
|
| 153 |
"""
|
| 154 |
Render prediction result: plot, metrics, expander, and download/measure buttons.
|
| 155 |
measure_region_dialog: callable to open measure dialog (when ST_DIALOG available).
|
|
@@ -172,7 +182,7 @@ def render_result_display(img, raw_heatmap, display_heatmap, pixel_sum, force, k
|
|
| 172 |
]
|
| 173 |
else:
|
| 174 |
main_csv_rows = [
|
| 175 |
-
["image", "Sum of all pixels", "
|
| 176 |
[base_name, f"{pixel_sum:.2f}", f"{force:.2f}",
|
| 177 |
f"{np.max(raw_heatmap):.4f}", f"{np.mean(raw_heatmap):.4f}"],
|
| 178 |
]
|
|
@@ -227,7 +237,7 @@ def render_result_display(img, raw_heatmap, display_heatmap, pixel_sum, force, k
|
|
| 227 |
with col1:
|
| 228 |
st.metric("Sum of all pixels", f"{pixel_sum:.2f}", help="Raw sum of all pixel values in the force map")
|
| 229 |
with col2:
|
| 230 |
-
st.metric("
|
| 231 |
with col3:
|
| 232 |
st.metric("Heatmap max", f"{np.max(raw_heatmap):.4f}", help="Peak force intensity in the map")
|
| 233 |
with col4:
|
|
@@ -252,7 +262,7 @@ def render_result_display(img, raw_heatmap, display_heatmap, pixel_sum, force, k
|
|
| 252 |
st.metric("P75", f"{p75:.4f}")
|
| 253 |
st.metric("P90", f"{p90:.4f}")
|
| 254 |
st.markdown("**Histogram**")
|
| 255 |
-
hist_fig = go.Figure(data=[go.Histogram(x=vals, nbinsx=50, marker_color=
|
| 256 |
hist_fig.update_layout(
|
| 257 |
height=220, margin=dict(l=40, r=20, t=20, b=40),
|
| 258 |
xaxis_title="Force value", yaxis_title="Count",
|
|
@@ -292,7 +302,7 @@ This is the raw image you provided—it shows cell shape but not forces.
|
|
| 292 |
|
| 293 |
**Metrics (auto cell boundary off):**
|
| 294 |
- **Sum of all pixels:** Raw sum over entire map
|
| 295 |
-
- **
|
| 296 |
- **Heatmap max/mean:** Peak and average force intensity (full field of view)
|
| 297 |
""")
|
| 298 |
|
|
|
|
| 22 |
HAS_DRAWABLE_CANVAS,
|
| 23 |
)
|
| 24 |
|
| 25 |
+
# Histogram bar color (matches static/s2f_styles.css accent)
|
| 26 |
+
_HISTOGRAM_ACCENT = "#0d9488"
|
| 27 |
+
|
| 28 |
|
| 29 |
def render_batch_results(batch_results, colormap_name="Jet", display_mode="Default",
|
| 30 |
min_percentile=0, max_percentile=100, clip_min=0, clip_max=1,
|
| 31 |
+
auto_cell_boundary=False, clamp_only=False):
|
| 32 |
"""
|
| 33 |
Render batch prediction results: summary table, bright-field row, heatmap row, and bulk download.
|
| 34 |
batch_results: list of dicts with img, heatmap, force, pixel_sum, key_img, cell_mask.
|
|
|
|
| 46 |
r["_display_heatmap"] = apply_display_scale(
|
| 47 |
r["heatmap"], display_mode,
|
| 48 |
min_percentile=min_percentile, max_percentile=max_percentile,
|
| 49 |
+
clip_min=clip_min, clip_max=clip_max, clamp_only=clamp_only,
|
| 50 |
)
|
| 51 |
# Build table rows - consistent column names for both modes
|
| 52 |
headers = ["Image", "Force", "Sum", "Max", "Mean"]
|
|
|
|
| 89 |
)
|
| 90 |
with hm_cols[i % n_cols]:
|
| 91 |
st.image(hm_rgb, caption=r["key_img"], use_container_width=True)
|
| 92 |
+
render_horizontal_colorbar(
|
| 93 |
+
colormap_name, clip_min, clip_max, is_rescale_b,
|
| 94 |
+
caption=(
|
| 95 |
+
"Ticks = force values in your selected [min, max] range; the strip uses the full colormap for the rescaled image (low → high)."
|
| 96 |
+
if is_rescale_b
|
| 97 |
+
else None
|
| 98 |
+
),
|
| 99 |
+
)
|
| 100 |
# Table
|
| 101 |
st.dataframe(
|
| 102 |
{h: [r[i] for r in rows] for i, h in enumerate(headers)},
|
|
|
|
| 112 |
vals = vals[vals > 0] if np.any(vals > 0) else vals
|
| 113 |
st.markdown(f"**{r['key_img']}**")
|
| 114 |
if len(vals) > 0:
|
| 115 |
+
fig = go.Figure(data=[go.Histogram(x=vals, nbinsx=50, marker_color=_HISTOGRAM_ACCENT)])
|
| 116 |
fig.update_layout(
|
| 117 |
height=220, margin=dict(l=40, r=20, t=10, b=40),
|
| 118 |
xaxis_title="Force value", yaxis_title="Count",
|
|
|
|
| 159 |
|
| 160 |
def render_result_display(img, raw_heatmap, display_heatmap, pixel_sum, force, key_img, download_key_suffix="",
|
| 161 |
colormap_name="Jet", display_mode="Default", measure_region_dialog=None, auto_cell_boundary=True,
|
| 162 |
+
cell_mask=None, clip_min=0.0, clip_max=1.0, clamp_only=False):
|
| 163 |
"""
|
| 164 |
Render prediction result: plot, metrics, expander, and download/measure buttons.
|
| 165 |
measure_region_dialog: callable to open measure dialog (when ST_DIALOG available).
|
|
|
|
| 182 |
]
|
| 183 |
else:
|
| 184 |
main_csv_rows = [
|
| 185 |
+
["image", "Sum of all pixels", "Force (scaled)", "Heatmap max", "Heatmap mean"],
|
| 186 |
[base_name, f"{pixel_sum:.2f}", f"{force:.2f}",
|
| 187 |
f"{np.max(raw_heatmap):.4f}", f"{np.mean(raw_heatmap):.4f}"],
|
| 188 |
]
|
|
|
|
| 237 |
with col1:
|
| 238 |
st.metric("Sum of all pixels", f"{pixel_sum:.2f}", help="Raw sum of all pixel values in the force map")
|
| 239 |
with col2:
|
| 240 |
+
st.metric("Force (scaled)", f"{force:.2f}", help="Total traction force in physical units (full field of view)")
|
| 241 |
with col3:
|
| 242 |
st.metric("Heatmap max", f"{np.max(raw_heatmap):.4f}", help="Peak force intensity in the map")
|
| 243 |
with col4:
|
|
|
|
| 262 |
st.metric("P75", f"{p75:.4f}")
|
| 263 |
st.metric("P90", f"{p90:.4f}")
|
| 264 |
st.markdown("**Histogram**")
|
| 265 |
+
hist_fig = go.Figure(data=[go.Histogram(x=vals, nbinsx=50, marker_color=_HISTOGRAM_ACCENT)])
|
| 266 |
hist_fig.update_layout(
|
| 267 |
height=220, margin=dict(l=40, r=20, t=20, b=40),
|
| 268 |
xaxis_title="Force value", yaxis_title="Count",
|
|
|
|
| 302 |
|
| 303 |
**Metrics (auto cell boundary off):**
|
| 304 |
- **Sum of all pixels:** Raw sum over entire map
|
| 305 |
+
- **Force (scaled):** Total traction force in physical units (full field of view)
|
| 306 |
- **Heatmap max/mean:** Peak and average force intensity (full field of view)
|
| 307 |
""")
|
| 308 |
|
utils/display.py
CHANGED
|
@@ -4,6 +4,14 @@ import cv2
|
|
| 4 |
|
| 5 |
from config.constants import COLORMAPS, COLORMAP_N_SAMPLES
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
def cv_colormap_to_plotly_colorscale(colormap_name, n_samples=None):
|
| 9 |
"""Build a Plotly colorscale from OpenCV colormap so UI matches download/PDF exactly."""
|
|
@@ -19,52 +27,25 @@ def cv_colormap_to_plotly_colorscale(colormap_name, n_samples=None):
|
|
| 19 |
return scale
|
| 20 |
|
| 21 |
|
| 22 |
-
def build_range_colorscale(colormap_name, clip_min, clip_max, n_range_samples=32):
|
| 23 |
-
"""
|
| 24 |
-
Build a Plotly colorscale for Range mode: normal gradient in [clip_min, clip_max],
|
| 25 |
-
the "zero" color everywhere else (0 → clip_min and clip_max → 1).
|
| 26 |
-
"""
|
| 27 |
-
cv2_cmap = COLORMAPS.get(colormap_name, cv2.COLORMAP_JET)
|
| 28 |
-
|
| 29 |
-
zero_px = np.array([[0]], dtype=np.uint8)
|
| 30 |
-
zero_rgb = cv2.applyColorMap(zero_px, cv2_cmap)
|
| 31 |
-
zero_rgb = cv2.cvtColor(zero_rgb, cv2.COLOR_BGR2RGB)
|
| 32 |
-
zr, zg, zb = zero_rgb[0, 0]
|
| 33 |
-
zero_color = f"rgb({zr},{zg},{zb})"
|
| 34 |
-
|
| 35 |
-
eps = 0.0005
|
| 36 |
-
scale = []
|
| 37 |
-
|
| 38 |
-
scale.append([0.0, zero_color])
|
| 39 |
-
if clip_min > eps:
|
| 40 |
-
scale.append([clip_min - eps, zero_color])
|
| 41 |
-
|
| 42 |
-
positions = np.linspace(clip_min, clip_max, n_range_samples)
|
| 43 |
-
pixel_vals = np.clip((positions * 255).astype(np.uint8), 0, 255).reshape(1, -1)
|
| 44 |
-
rgb = cv2.applyColorMap(pixel_vals, cv2_cmap)
|
| 45 |
-
rgb = cv2.cvtColor(rgb, cv2.COLOR_BGR2RGB)
|
| 46 |
-
for i, pos in enumerate(positions):
|
| 47 |
-
r, g, b = rgb[0, i]
|
| 48 |
-
scale.append([float(pos), f"rgb({r},{g},{b})"])
|
| 49 |
-
|
| 50 |
-
if clip_max < 1.0 - eps:
|
| 51 |
-
scale.append([clip_max + eps, zero_color])
|
| 52 |
-
scale.append([1.0, zero_color])
|
| 53 |
-
|
| 54 |
-
return scale
|
| 55 |
-
|
| 56 |
-
|
| 57 |
def apply_display_scale(heatmap, mode, min_percentile=0, max_percentile=100,
|
| 58 |
-
clip_min=0, clip_max=1,
|
| 59 |
"""
|
| 60 |
Apply display scaling. Display only—does not change underlying values.
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
"""
|
| 67 |
-
|
|
|
|
|
|
|
| 68 |
return np.clip(heatmap, 0, 1).astype(np.float32)
|
| 69 |
if mode == "Percentile":
|
| 70 |
pmin = float(np.percentile(heatmap, min_percentile))
|
|
@@ -73,12 +54,11 @@ def apply_display_scale(heatmap, mode, min_percentile=0, max_percentile=100,
|
|
| 73 |
out = (heatmap.astype(np.float32) - pmin) / (pmax - pmin)
|
| 74 |
return np.clip(out, 0, 1).astype(np.float32)
|
| 75 |
return np.clip(heatmap, 0, 1).astype(np.float32)
|
| 76 |
-
if mode == "Range"
|
| 77 |
-
# Range: filter (discard outside) + rescale [clip_min, clip_max] → [0, 1] so max shows as red
|
| 78 |
vmin, vmax = float(clip_min), float(clip_max)
|
| 79 |
if vmax > vmin:
|
| 80 |
h = heatmap.astype(np.float32)
|
| 81 |
-
if
|
| 82 |
return np.clip(h, vmin, vmax).astype(np.float32)
|
| 83 |
mask = (h >= vmin) & (h <= vmax)
|
| 84 |
out = np.zeros_like(h)
|
|
@@ -89,7 +69,7 @@ def apply_display_scale(heatmap, mode, min_percentile=0, max_percentile=100,
|
|
| 89 |
vmin, vmax = float(clip_min), float(clip_max)
|
| 90 |
if vmax > vmin:
|
| 91 |
h = heatmap.astype(np.float32)
|
| 92 |
-
if
|
| 93 |
clamped = np.clip(h, vmin, vmax)
|
| 94 |
return ((clamped - vmin) / (vmax - vmin)).astype(np.float32)
|
| 95 |
mask = (h >= vmin) & (h <= vmax)
|
|
|
|
| 4 |
|
| 5 |
from config.constants import COLORMAPS, COLORMAP_N_SAMPLES
|
| 6 |
|
| 7 |
+
# Legacy aliases (single mapping at entry — app uses Default / Range / Percentile / Rescale only)
|
| 8 |
+
_DISPLAY_MODE_ALIASES = {
|
| 9 |
+
"Auto": "Default",
|
| 10 |
+
"Full": "Default",
|
| 11 |
+
"Filter": "Range",
|
| 12 |
+
"Threshold": "Range",
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
|
| 16 |
def cv_colormap_to_plotly_colorscale(colormap_name, n_samples=None):
|
| 17 |
"""Build a Plotly colorscale from OpenCV colormap so UI matches download/PDF exactly."""
|
|
|
|
| 27 |
return scale
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def apply_display_scale(heatmap, mode, min_percentile=0, max_percentile=100,
|
| 31 |
+
clip_min=0, clip_max=1, clamp_only=False):
|
| 32 |
"""
|
| 33 |
Apply display scaling. Display only—does not change underlying values.
|
| 34 |
+
|
| 35 |
+
Parameters
|
| 36 |
+
----------
|
| 37 |
+
clamp_only : bool
|
| 38 |
+
Only used for ``mode == "Range"`` or ``mode == "Rescale"`` when ``clip_max > clip_min``:
|
| 39 |
+
|
| 40 |
+
- **False** (default for Range in the app): pixels outside ``[clip_min, clip_max]`` are set
|
| 41 |
+
to 0; values inside are linearly mapped to ``[0, 1]`` so the colormap uses the full
|
| 42 |
+
dynamic range (blue→red) within that interval.
|
| 43 |
+
- **True**: values are clamped to ``[clip_min, clip_max]`` but **not** rescaled to
|
| 44 |
+
``[0, 1]`` (rarely used for the main heatmap view; can compress colormap contrast).
|
| 45 |
"""
|
| 46 |
+
mode = _DISPLAY_MODE_ALIASES.get(mode, mode)
|
| 47 |
+
|
| 48 |
+
if mode == "Default":
|
| 49 |
return np.clip(heatmap, 0, 1).astype(np.float32)
|
| 50 |
if mode == "Percentile":
|
| 51 |
pmin = float(np.percentile(heatmap, min_percentile))
|
|
|
|
| 54 |
out = (heatmap.astype(np.float32) - pmin) / (pmax - pmin)
|
| 55 |
return np.clip(out, 0, 1).astype(np.float32)
|
| 56 |
return np.clip(heatmap, 0, 1).astype(np.float32)
|
| 57 |
+
if mode == "Range":
|
|
|
|
| 58 |
vmin, vmax = float(clip_min), float(clip_max)
|
| 59 |
if vmax > vmin:
|
| 60 |
h = heatmap.astype(np.float32)
|
| 61 |
+
if clamp_only:
|
| 62 |
return np.clip(h, vmin, vmax).astype(np.float32)
|
| 63 |
mask = (h >= vmin) & (h <= vmax)
|
| 64 |
out = np.zeros_like(h)
|
|
|
|
| 69 |
vmin, vmax = float(clip_min), float(clip_max)
|
| 70 |
if vmax > vmin:
|
| 71 |
h = heatmap.astype(np.float32)
|
| 72 |
+
if clamp_only:
|
| 73 |
clamped = np.clip(h, vmin, vmax)
|
| 74 |
return ((clamped - vmin) / (vmax - vmin)).astype(np.float32)
|
| 75 |
mask = (h >= vmin) & (h <= vmax)
|