kaveh commited on
Commit
1f836a4
·
1 Parent(s): 02f1db5
.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
- clip_bounds=st.session_state.get("measure_clip_bounds", False),
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
- clip_bounds=False):
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["measure_clip_bounds"] = clip_bounds
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('<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">', unsafe_allow_html=True)
 
 
 
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
- clip_min, clip_max = st.slider(
280
- "Force Range",
281
- min_value=0.0,
282
- max_value=1.0,
283
- value=(0.0, 1.0),
284
- step=0.01,
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 clip_min >= clip_max:
289
  clip_min, clip_max = 0.0, 1.0
290
- display_mode = "Range" if (clip_min != 0.0 or clip_max != 1.0) else "Default"
291
- clip_bounds = False if display_mode == "Range" else True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- current_key = (model_type, checkpoint, key_img)
 
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, clip_bounds,
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
- clip_bounds=clip_bounds,
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, clip_bounds=clip_bounds,
445
  )
446
  if check_measure_dialog and st.session_state.pop("open_measure_dialog", False):
447
  measure_region_dialog()
448
- st.success("Prediction complete!")
 
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, clip_bounds=clip_bounds,
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
- clip_bounds=clip_bounds,
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
- clip_bounds=clip_bounds,
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, clip_bounds,
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, clip_bounds,
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
- /* === Theme variables (overridden by theme selector) === */
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
- /* === Scroll lock for embedded (HF Spaces iframe): prevent parent scroll, main content scrolls internally === */
 
 
 
 
 
 
 
 
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 wrapper for sidebar + main */
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: 0 !important;
123
  left: 0 !important;
124
  width: 360px !important;
125
- height: 100vh !important;
126
- max-height: 100vh !important;
 
 
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.45rem 1rem;
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.4;
553
  }
554
- /* Main content: offset for fixed sidebar, scroll internally (fixes HF iframe embed) */
 
 
 
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-bottom: 3rem !important;
 
567
  max-width: 1050px !important;
568
  }
569
  section[data-testid="stSidebar"] > div:first-child {
570
- padding-top: 1rem !important;
 
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
- """Render a compact horizontal colorbar for batch mode, anchored above the table."""
 
 
 
 
 
 
 
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, clip_bounds=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, clip_bounds=clip_bounds,
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(colormap_name, clip_min, clip_max, is_rescale_b)
 
 
 
 
 
 
 
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="#0d9488")])
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, clip_bounds=False):
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", "Cell force (scaled)", "Heatmap max", "Heatmap mean"],
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("Cell force (scaled)", f"{force:.2f}", help="Total traction force in physical units")
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="#0d9488")])
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
- - **Cell force (scaled):** Total traction force in physical units
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, clip_bounds=False):
59
  """
60
  Apply display scaling. Display only—does not change underlying values.
61
- - Default: full 0–1 range as-is.
62
- - Range: keep original values inside [clip_min, clip_max].
63
- clip_bounds=False → zero out outside. clip_bounds=True → clamp to bounds.
64
- - Rescale: map [clip_min, clip_max] → [0, 1].
65
- clip_bounds=False zero out outside. clip_bounds=True clamp to bounds first.
 
 
 
 
 
 
66
  """
67
- if mode == "Default" or mode == "Auto" or mode == "Full":
 
 
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" or mode == "Filter" or mode == "Threshold":
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 clip_bounds:
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 clip_bounds:
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)