VisionLanguageGroup commited on
Commit
cd351de
Β·
1 Parent(s): b51b5c2

add input standardization

Browse files
Files changed (2) hide show
  1. _utils/image_io.py +358 -0
  2. app.py +455 -113
_utils/image_io.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image standardization.
2
+
3
+ Microscopy TIFFs come in many flavors: 16-bit or float pixels, single or
4
+ multi channel, and multi-page Z / time stacks. ``standardize_image`` collapses
5
+ any of those variants to a single canonical 8-bit RGB PNG used for BOTH the
6
+ on-screen preview and the model, so what you see is what gets segmented.
7
+
8
+ Which axis means what is read from the file's own metadata (``series.axes``
9
+ from tifffile: 'C'=channel, 'S'=RGB samples, 'Z'/'T'=stack, 'Y'/'X'=spatial)
10
+ rather than guessed from the array shape -- guessing cannot tell a 3-channel
11
+ (C, Y, X) image apart from a 3-slice (Z, Y, X) stack. Only when a file names
12
+ no axes at all (tifffile reports 'Q' = unknown) do we fall back to the dominant
13
+ convention: the first unknown axis of size 2-4 is the channel axis.
14
+
15
+ Reduction policy:
16
+
17
+ * multi-page / Z / time stacks -> one frame (default: the first)
18
+ * more than 3 channels -> first 3 channels (as R, G, B)
19
+ * intensity -> 0-255 -> 1st-99th percentile auto-contrast for
20
+ 16-bit/float input (outlier-robust: a hot /
21
+ saturated pixel would make plain min/max
22
+ scaling collapse the real signal to black)
23
+
24
+ Standard 8-bit inputs (PNG/JPG/8-bit TIFF) are passed through unchanged in
25
+ value; only channel layout is normalized.
26
+ """
27
+
28
+ import os
29
+ import tempfile
30
+ from typing import NamedTuple
31
+
32
+ import numpy as np
33
+ from PIL import Image
34
+
35
+ try:
36
+ import tifffile
37
+ except ImportError: # pragma: no cover - tifffile is a project dependency
38
+ tifffile = None
39
+
40
+ # Size policy. The model resizes any input to 512x512 (see segmentation.py), so a
41
+ # larger image costs memory/time and *loses* detail rather than adding any.
42
+ RECOMMENDED_SIZE = 512 # cropping to about this preserves the most detail
43
+ WARN_SIZE = 3072 # above this, advise the user to crop
44
+ MAX_SIZE = 4096 # above this, refuse: reading the pixels risks an OOM
45
+ # Refuse files whose full array would not comfortably fit in memory. Measured on
46
+ # the uncompressed array (shape x dtype) rather than the file size on disk, since
47
+ # compression makes disk size a poor proxy for what we actually allocate.
48
+ MAX_READ_BYTES = 300 * 1024 ** 2 # 300 MiB
49
+
50
+ # Axis roles, per tifffile's `series.axes` naming.
51
+ _SPATIAL = ("Y", "X")
52
+ _CHANNEL = ("C", "S") # C = separate channel planes, S = interleaved RGB samples
53
+ _UNKNOWN = ("Q", "I") # file named no axis; only these may be *guessed* as channels
54
+ # Anything else (Z, T, ...) is a stack axis and is indexed by frame.
55
+
56
+
57
+ def _read_array(path):
58
+ """Load an image file into (array, axes) where axes names each dimension.
59
+
60
+ axes uses tifffile's convention ('C' channel, 'S' RGB samples, 'Z'/'T'
61
+ stack, 'Y'/'X' spatial, 'Q' unknown). Indexed / palette images (ImageJ
62
+ "8-bit Color", palette PNG/GIF) are expanded through their color lookup
63
+ table so we return true RGB, not bare indices.
64
+ """
65
+ ext = os.path.splitext(path)[1].lower()
66
+ if ext in (".tif", ".tiff") and tifffile is not None:
67
+ with tifffile.TiffFile(path) as tif:
68
+ series = tif.series[0]
69
+ page = tif.pages[0]
70
+ arr = np.asarray(series.asarray())
71
+ axes = str(series.axes)
72
+ is_palette = getattr(page, "photometric", None) == tifffile.PHOTOMETRIC.PALETTE
73
+ if is_palette and page.colormap is not None:
74
+ colormap = np.asarray(page.colormap) # (3, 2**bits), uint16
75
+ rgb = np.moveaxis(colormap[:, arr], 0, -1) # (..., H, W, 3)
76
+ # TIFF colormaps are 16-bit; scale down to 8-bit (65535/255=257).
77
+ arr = np.round(rgb / 257.0).astype(np.uint8)
78
+ axes = axes + "S" # the LUT added an RGB sample axis
79
+ return arr, axes
80
+ # Everything else (and if tifffile is unavailable): PIL. Expand palette
81
+ # images to RGB so the LUT is applied instead of returning bare indices.
82
+ img = Image.open(path)
83
+ if img.mode in ("P", "PA"):
84
+ img = img.convert("RGB")
85
+ arr = np.asarray(img)
86
+ return arr, ("YXS" if arr.ndim == 3 else "YX")
87
+
88
+
89
+ def _shape_axes(path):
90
+ """Return (shape, axes) from metadata only - no pixel decode."""
91
+ ext = os.path.splitext(path)[1].lower()
92
+ if ext in (".tif", ".tiff") and tifffile is not None:
93
+ try:
94
+ with tifffile.TiffFile(path) as tif:
95
+ series = tif.series[0]
96
+ return tuple(series.shape), str(series.axes)
97
+ except Exception: # noqa: BLE001 - fall through to PIL
98
+ pass
99
+ with Image.open(path) as img:
100
+ w, h = img.size
101
+ if img.mode in ("P", "PA", "RGB", "RGBA", "CMYK", "YCbCr", "LAB", "HSV"):
102
+ return (h, w, len(img.getbands())), "YXS"
103
+ return (h, w), "YX"
104
+
105
+
106
+ def _plan_axes(shape, axes):
107
+ """Drop size-1 axes, infer a channel axis when the file names none, and move
108
+ it last.
109
+
110
+ Returns (shape, axes, guessed) as lists + a flag saying whether the channel
111
+ axis had to be inferred rather than read from the file.
112
+ """
113
+ axes = list(axes)
114
+ shape = list(shape)
115
+ if len(axes) != len(shape): # be defensive; keep the trailing spatial axes
116
+ axes = ["Q"] * (len(shape) - 2) + ["Y", "X"]
117
+
118
+ pairs = [(a, d) for a, d in zip(axes, shape) if d != 1]
119
+ axes = [a for a, _ in pairs]
120
+ shape = [d for _, d in pairs]
121
+
122
+ guessed = False
123
+ if not any(a in _CHANNEL for a in axes):
124
+ # Only guess on axes the file left unnamed. An axis the file explicitly
125
+ # calls Z/T is a stack even when its length happens to be 3.
126
+ for i, (a, d) in enumerate(zip(axes, shape)):
127
+ if a in _UNKNOWN and d in (2, 3, 4):
128
+ axes[i] = "C"
129
+ guessed = True
130
+ break
131
+
132
+ ci = next((i for i, a in enumerate(axes) if a in _CHANNEL), None)
133
+ if ci is not None:
134
+ axes.append(axes.pop(ci))
135
+ shape.append(shape.pop(ci))
136
+ return shape, axes, guessed
137
+
138
+
139
+ def _reduce_to_hwc(arr, axes, frame=0):
140
+ """Collapse a labelled array to 2D (H, W) or 3D (H, W, C).
141
+
142
+ The channel axis (named by the file, or inferred by ``_plan_axes`` only when
143
+ the file names none) is moved last and kept whole. The remaining stack axes
144
+ (Z / time / page) are indexed: the first by ``frame``, any deeper ones by 0.
145
+ """
146
+ axes = list(axes)
147
+
148
+ # Drop size-1 axes, keeping arr and axes in sync.
149
+ for i in range(len(axes) - 1, -1, -1):
150
+ if arr.shape[i] == 1:
151
+ arr = arr.reshape(arr.shape[:i] + arr.shape[i + 1:])
152
+ axes.pop(i)
153
+
154
+ _, planned, _ = _plan_axes(arr.shape, axes)
155
+
156
+ # Apply the plan to the array: infer the channel axis, then move it last.
157
+ if "C" in planned and not any(a in _CHANNEL for a in axes):
158
+ for i, (a, d) in enumerate(zip(axes, arr.shape)):
159
+ if a in _UNKNOWN and d in (2, 3, 4):
160
+ axes[i] = "C"
161
+ break
162
+ ci = next((i for i, a in enumerate(axes) if a in _CHANNEL), None)
163
+ if ci is not None:
164
+ arr = np.moveaxis(arr, ci, -1)
165
+ axes.append(axes.pop(ci))
166
+
167
+ # Index the leading stack axes: first by `frame`, deeper by 0.
168
+ target = 3 if ci is not None else 2
169
+ first = True
170
+ while len(axes) > target:
171
+ idx = int(frame) if first else 0
172
+ idx = max(0, min(idx, arr.shape[0] - 1))
173
+ arr = arr[idx]
174
+ axes.pop(0)
175
+ first = False
176
+
177
+ return arr
178
+
179
+
180
+ class ImageInfo(NamedTuple):
181
+ """How a file's dimensions were interpreted (read from metadata only).
182
+
183
+ frames - length of the first stack (Z/T) axis, 1 if not a stack
184
+ channels - length of the channel axis, 1 if single-channel
185
+ axes - the file's own axes string, e.g. 'CZYX' ('Q' = unnamed)
186
+ guessed - True if the channel axis was inferred rather than read
187
+ shape - the file's raw shape as stored, e.g. (1, 4, 1, 1024, 1024)
188
+ width - pixels along the X axis (0 if unknown)
189
+ height - pixels along the Y axis (0 if unknown)
190
+ """
191
+ frames: int
192
+ channels: int
193
+ axes: str
194
+ guessed: bool
195
+ shape: tuple
196
+ width: int
197
+ height: int
198
+
199
+
200
+ def inspect_image(path):
201
+ """Describe a file's structure from metadata only (no pixel decode)."""
202
+ try:
203
+ shape, axes = _shape_axes(path)
204
+ planned_shape, planned_axes, guessed = _plan_axes(shape, axes)
205
+ has_c = bool(planned_axes) and planned_axes[-1] in _CHANNEL
206
+ channels = int(planned_shape[-1]) if has_c else 1
207
+ target = 3 if has_c else 2
208
+ frames = int(planned_shape[0]) if len(planned_axes) > target else 1
209
+
210
+ # Spatial size comes from the named Y/X axes, so it stays correct
211
+ # whatever order the other axes are in.
212
+ width = height = 0
213
+ for a, d in zip(axes, shape):
214
+ if a == "Y":
215
+ height = int(d)
216
+ elif a == "X":
217
+ width = int(d)
218
+ if not (width and height): # no Y/X named; fall back to the header probe
219
+ width, height = image_size(path)
220
+
221
+ return ImageInfo(frames, channels, str(axes), guessed, tuple(shape), width, height)
222
+ except Exception: # noqa: BLE001
223
+ return ImageInfo(1, 1, "", False, (), 0, 0)
224
+
225
+
226
+ def count_frames(path):
227
+ """Return how many frames a file's stack (Z/T) axis has (1 if not a stack)."""
228
+ return inspect_image(path).frames
229
+
230
+
231
+ def array_nbytes(path):
232
+ """Bytes that opening this file's full array would allocate.
233
+
234
+ Computed from shape + dtype in the header - no pixel decode - so it is safe
235
+ to call on a file that is too large to open. Returns 0 if unknown.
236
+ """
237
+ ext = os.path.splitext(path)[1].lower()
238
+ if ext in (".tif", ".tiff") and tifffile is not None:
239
+ try:
240
+ with tifffile.TiffFile(path) as tif:
241
+ series = tif.series[0]
242
+ return int(np.prod(series.shape)) * int(np.dtype(series.dtype).itemsize)
243
+ except Exception: # noqa: BLE001 - fall through to PIL
244
+ pass
245
+ try:
246
+ with Image.open(path) as img:
247
+ w, h = img.size
248
+ return int(w) * int(h) * len(img.getbands()) # 8-bit assumption
249
+ except Exception: # noqa: BLE001
250
+ return 0
251
+
252
+
253
+ def image_size(path):
254
+ """Return an image's (width, height) by reading only its header.
255
+
256
+ Never decodes pixel data, so this is safe to call as a size guard on a file
257
+ that would be too large to load. Returns (0, 0) if the size cannot be
258
+ determined, so callers treat it as "unknown" and proceed.
259
+ """
260
+ ext = os.path.splitext(path)[1].lower()
261
+ if ext in (".tif", ".tiff") and tifffile is not None:
262
+ try:
263
+ with tifffile.TiffFile(path) as tif:
264
+ page = tif.pages[0]
265
+ return int(page.imagewidth), int(page.imagelength)
266
+ except Exception: # noqa: BLE001 - fall through to PIL
267
+ pass
268
+ try:
269
+ with Image.open(path) as img: # PIL parses the header lazily
270
+ return int(img.size[0]), int(img.size[1])
271
+ except Exception: # noqa: BLE001
272
+ return 0, 0
273
+
274
+
275
+ def _to_rgb(arr):
276
+ """Turn a 2D or (H, W, C) array into exactly 3 channels."""
277
+ if arr.ndim == 2:
278
+ return np.stack([arr] * 3, axis=-1)
279
+
280
+ channels = arr.shape[2]
281
+ if channels == 1:
282
+ return np.repeat(arr, 3, axis=2)
283
+ if channels == 2:
284
+ # Pad a zero third channel rather than inventing signal.
285
+ return np.concatenate([arr, np.zeros_like(arr[:, :, :1])], axis=2)
286
+ return arr[:, :, :3]
287
+
288
+
289
+ def _load_rgb(path, frame=0):
290
+ """Read a file and reduce it to an (H, W, 3) array plus its source dtype."""
291
+ raw, axes = _read_array(path)
292
+ arr = _reduce_to_hwc(raw, axes, frame=frame)
293
+ arr = _to_rgb(arr)
294
+ return arr, raw.dtype
295
+
296
+
297
+ def _to_uint8(arr, stretch):
298
+ """Map pixel values to uint8.
299
+
300
+ When ``stretch`` is True (non-8-bit input) a 1st-99th percentile auto-
301
+ contrast stretch is applied - outlier-robust, so a hot/saturated pixel does
302
+ not collapse the visible signal to black. Otherwise values are only clipped,
303
+ so standard 8-bit images are unchanged.
304
+ """
305
+ arr = arr.astype(np.float32)
306
+
307
+ if not stretch:
308
+ return np.clip(arr, 0, 255).astype(np.uint8)
309
+
310
+ lo, hi = np.percentile(arr, (1, 99))
311
+ if hi <= lo: # near-flat image; fall back to full min/max
312
+ lo, hi = float(arr.min()), float(arr.max())
313
+ if hi <= lo: # truly constant image
314
+ return np.zeros(arr.shape, dtype=np.uint8)
315
+
316
+ arr = np.clip((arr - lo) / (hi - lo), 0.0, 1.0)
317
+ return (arr * 255.0).astype(np.uint8)
318
+
319
+
320
+ def _save_png(arr, out_path=None, out_dir=None, base=None, suffix="_std.png"):
321
+ """Save an (H, W, 3) uint8 array as a PNG and return the path."""
322
+ if out_path is None:
323
+ if out_dir is not None:
324
+ os.makedirs(out_dir, exist_ok=True)
325
+ out_path = os.path.join(out_dir, (base or "image") + suffix)
326
+ else:
327
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
328
+ out_path = tmp.name
329
+ tmp.close()
330
+ Image.fromarray(arr, mode="RGB").save(out_path)
331
+ return out_path
332
+
333
+
334
+ def standardize_image(path, frame=0, out_dir=None, out_path=None):
335
+ """Standardize any image/TIFF to a canonical 8-bit RGB PNG.
336
+
337
+ Used for both the preview and the model: one frame, <=3 channels, with a
338
+ 1st-99th percentile auto-contrast stretch for 16-bit/float input (8-bit is
339
+ passed through unchanged).
340
+
341
+ Args:
342
+ path: path to the input image (TIFF, PNG, JPG, ...).
343
+ frame: which frame of a stack to use (0-based; ignored for non-stacks).
344
+ out_dir: optional directory for the output PNG.
345
+ out_path: optional explicit output file path (overrides out_dir).
346
+
347
+ Returns:
348
+ Path to the standardized PNG. On any failure the original ``path`` is
349
+ returned unchanged so callers degrade gracefully.
350
+ """
351
+ try:
352
+ arr, dtype = _load_rgb(path, frame=frame)
353
+ arr = _to_uint8(arr, stretch=(dtype != np.uint8))
354
+ base = os.path.splitext(os.path.basename(path))[0]
355
+ return _save_png(arr, out_path=out_path, out_dir=out_dir, base=base, suffix="_std.png")
356
+ except Exception as e: # noqa: BLE001 - never let preprocessing crash a run
357
+ print(f"⚠️ standardize_image failed for {path}: {e}; using original file")
358
+ return path
app.py CHANGED
@@ -21,9 +21,13 @@ import spaces
21
  from inference_seg import load_model as load_seg_model, run as run_seg
22
  from inference_count import load_model as load_count_model, run as run_count
23
  from inference_track import load_model as load_track_model, run as run_track
 
 
 
 
24
 
25
  HF_TOKEN = os.getenv("HF_TOKEN")
26
- DATASET_REPO = "phoebe777777/celltool_feedback"
27
 
28
 
29
  print("===== clearing cache =====")
@@ -83,24 +87,47 @@ def save_feedback_to_hf(query_id, feedback_type, feedback_text=None, img_path=No
83
  save_feedback(query_id, feedback_type, feedback_text, img_path, bboxes)
84
  return
85
 
86
- feedback_data = {
87
- "query_id": query_id,
88
- "feedback_type": feedback_type,
89
- "feedback_text": feedback_text,
90
- "image_path": img_path,
91
- "bboxes": str(bboxes), # 转为字符串
92
- "datetime": time.strftime("%Y-%m-%d %H:%M:%S"),
93
- "timestamp": time.time()
94
- }
95
-
96
  try:
97
  api = HfApi()
98
-
99
- filename = f"feedback_{query_id}_{int(time.time())}.json"
100
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  with open(filename, 'w', encoding='utf-8') as f:
102
  json.dump(feedback_data, f, indent=2, ensure_ascii=False)
103
-
104
  api.upload_file(
105
  path_or_fileobj=filename,
106
  path_in_repo=f"data/{filename}",
@@ -108,11 +135,11 @@ def save_feedback_to_hf(query_id, feedback_type, feedback_text=None, img_path=No
108
  repo_type="dataset",
109
  token=HF_TOKEN
110
  )
111
-
112
  os.remove(filename)
113
-
114
- print(f"βœ… Feedback saved to HF Dataset: {DATASET_REPO}")
115
-
116
  except Exception as e:
117
  print(f"⚠️ Failed to save to HF Dataset: {e}")
118
  save_feedback(query_id, feedback_type, feedback_text, img_path, bboxes)
@@ -293,6 +320,134 @@ def cleanup_tracking_cache(track_vis_cache):
293
  pass
294
 
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  @spaces.GPU
297
  def segment_with_choice(use_box_choice, annot_value, overlay_alpha):
298
  """Segmentation handler - supports bounding box, returns colorized overlay and original mask path"""
@@ -304,6 +459,9 @@ def segment_with_choice(use_box_choice, annot_value, overlay_alpha):
304
  bboxes = annot_value[1] if len(annot_value) > 1 else []
305
 
306
  print(f"πŸ–ΌοΈ Image path: {img_path}")
 
 
 
307
  box_array = None
308
  if use_box_choice == "Yes" and bboxes:
309
  box = parse_bboxes(bboxes)
@@ -359,21 +517,24 @@ def count_cells_handler(use_box_choice, annot_value, overlay_alpha):
359
  """Counting handler - supports bounding box, returns only density map"""
360
  if annot_value is None or len(annot_value) < 1:
361
  return None, None, "⚠️ Please provide an image.", {}
362
-
363
  image_path = annot_value[0]
364
  bboxes = annot_value[1] if len(annot_value) > 1 else []
365
 
366
  print(f"πŸ–ΌοΈ Image path: {image_path}")
 
 
 
367
  box_array = None
368
  if use_box_choice == "Yes" and bboxes:
369
  box = parse_bboxes(bboxes)
370
  if box:
371
  box_array = box
372
  print(f"πŸ“¦ Using bounding boxes: {box_array}")
373
-
374
  try:
375
  print(f"πŸ”’ Counting - Image: {image_path}")
376
-
377
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
378
  result = run_count(
379
  COUNT_MODEL,
@@ -1013,6 +1174,64 @@ button.secondary:hover {
1013
  /* ── Labels ──────────────────────────────────────────── */
1014
  label { font-weight: 500 !important; color: #374151 !important; }
1015
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1016
  /* ── Image output ────────────────────────────────────── */
1017
  .uniform-height {
1018
  height: 480px !important;
@@ -1125,6 +1344,11 @@ label { font-weight: 500 !important; color: #374151 !important; }
1125
  .dark label {
1126
  color: #cbd5e1 !important;
1127
  }
 
 
 
 
 
1128
 
1129
  /* ── Image output area ───────────────────────────────── */
1130
  .dark .uniform-height {
@@ -1155,7 +1379,10 @@ with gr.Blocks(
1155
  primary_hue=gr.themes.colors.sky,
1156
  secondary_hue=gr.themes.colors.slate,
1157
  neutral_hue=gr.themes.colors.slate,
1158
- font=gr.themes.GoogleFont("Inter"),
 
 
 
1159
  ),
1160
  css=CSS,
1161
  ) as demo:
@@ -1180,11 +1407,18 @@ with gr.Blocks(
1180
  )
1181
 
1182
  # ε…¨ε±€ηŠΆζ€
1183
- current_query_id = gr.State(str(uuid.uuid4()))
 
 
 
1184
  user_uploaded_examples = gr.State(example_images_seg.copy())
1185
  seg_vis_state = gr.State({})
1186
  count_vis_state = gr.State({})
1187
  track_vis_state = gr.State({})
 
 
 
 
1188
 
1189
  with gr.Tabs():
1190
  # ===== Tab 1: Segmentation =====
@@ -1193,25 +1427,50 @@ with gr.Blocks(
1193
  gr.Markdown(
1194
  """
1195
  **Instructions:**
1196
- 1. Upload an image or select an example image (supports various formats: .png, .jpg, .tif)
1197
  2. (Optional) Specify a target object with a bounding box and select "Yes", or click "Run Segmentation" directly
1198
  3. Click "Run Segmentation"
1199
  4. View the segmentation results (you can adjust the overlay opacity by sliding the opacity bar below the visualization), download the original predicted mask (.tif format); if needed, click "Clear Selection" to choose a new image
1200
-
1201
- 🀘 Rate and submit feedback to help us improve the model!
 
 
1202
  """
1203
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1204
 
1205
  with gr.Row():
1206
  with gr.Column(scale=1):
 
1207
  annotator = BBoxAnnotator(
1208
- label="πŸ–ΌοΈ Upload Image (Optional: Provide a Bounding Box)",
1209
  categories=["cell"],
1210
  )
1211
-
 
 
 
 
 
1212
  # Example Images Gallery
 
1213
  example_gallery = gr.Gallery(
1214
- label="πŸ“ Example Image Gallery",
1215
  columns=len(example_images_seg),
1216
  rows=1,
1217
  height=120,
@@ -1231,16 +1490,21 @@ with gr.Blocks(
1231
  clear_btn = gr.Button("πŸ”„ Clear Selection", variant="secondary")
1232
 
1233
  # Upload Example Image
1234
- image_uploader = gr.Image(
1235
- label="βž• Upload New Example Image to Gallery",
 
 
1236
  type="filepath"
1237
  )
 
 
1238
 
1239
 
1240
  with gr.Column(scale=2):
 
1241
  seg_output = gr.Image(
1242
  type="pil",
1243
- label="πŸ“Έ Segmentation Result",
1244
  elem_classes="uniform-height"
1245
  )
1246
  seg_alpha_slider = gr.Slider(
@@ -1252,8 +1516,9 @@ with gr.Blocks(
1252
  )
1253
 
1254
  # Download Original Prediction
 
1255
  download_mask_btn = gr.File(
1256
- label="πŸ“₯ Download Original Prediction (.tif format)",
1257
  visible=True,
1258
  height=40,
1259
  )
@@ -1283,6 +1548,19 @@ with gr.Blocks(
1283
  visible=False
1284
  )
1285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1286
  # click event for segmentation
1287
  run_seg_btn.click(
1288
  fn=segment_with_choice,
@@ -1297,9 +1575,9 @@ with gr.Blocks(
1297
 
1298
  # click event for clear button
1299
  clear_btn.click(
1300
- fn=lambda: (None, {}),
1301
  inputs=None,
1302
- outputs=[annotator, seg_vis_state]
1303
  )
1304
 
1305
  # init Gallery with example images
@@ -1308,37 +1586,41 @@ with gr.Blocks(
1308
  outputs=example_gallery
1309
  )
1310
 
1311
- # click event for image uploader
 
1312
  def add_to_gallery(img_path, current_imgs):
1313
  if not img_path:
1314
- return current_imgs
1315
- try:
1316
- if img_path not in current_imgs:
1317
- current_imgs.append(img_path)
1318
- return current_imgs
1319
- except:
1320
- return current_imgs
1321
-
1322
- image_uploader.change(
 
 
 
 
 
 
1323
  fn=add_to_gallery,
1324
  inputs=[image_uploader, user_uploaded_examples],
1325
- outputs=user_uploaded_examples
1326
- ).then(
1327
- fn=lambda imgs: imgs,
1328
- inputs=user_uploaded_examples,
1329
- outputs=example_gallery
1330
  )
1331
 
1332
- # click event for Gallery selection
1333
  def load_from_gallery(evt: gr.SelectData, all_imgs):
1334
  if evt.index is not None and evt.index < len(all_imgs):
1335
- return all_imgs[evt.index]
1336
- return None
1337
-
 
1338
  example_gallery.select(
1339
  fn=load_from_gallery,
1340
  inputs=user_uploaded_examples,
1341
- outputs=annotator
1342
  )
1343
 
1344
  # click event for submitting feedback
@@ -1378,26 +1660,50 @@ with gr.Blocks(
1378
  gr.Markdown(
1379
  """
1380
  **Usage Instructions:**
1381
- 1. Upload an image or select an example image (supports multiple formats: .png, .jpg, .tif)
1382
  2. (Optional) Specify a target object with a bounding box and select "Yes", or click "Run Counting" directly
1383
  3. Click "Run Counting"
1384
  4. View the density map (you can adjust the density opacity by sliding the opacity bar below the visualization), download the original prediction (.npy format); if needed, click "Clear Selection" to choose a new image to run
1385
-
1386
- 🀘 Rate and submit feedback to help us improve the model!
 
 
1387
  """
1388
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1389
 
1390
  with gr.Row():
1391
  with gr.Column(scale=1):
 
1392
  count_annotator = BBoxAnnotator(
1393
- label="πŸ–ΌοΈ Upload Image (Optional: Provide a Bounding Box)",
1394
  categories=["cell"],
1395
  )
1396
-
 
 
 
 
 
1397
  # Example gallery with "add" functionality
 
1398
  with gr.Row():
1399
  count_example_gallery = gr.Gallery(
1400
- label="πŸ“ Example Image Gallery",
1401
  columns=len(example_images_cnt),
1402
  rows=1,
1403
  object_fit="cover",
@@ -1419,20 +1725,23 @@ with gr.Blocks(
1419
  clear_btn = gr.Button("πŸ”„ Clear Selection", variant="secondary")
1420
 
1421
  # Add button to upload new examples
1422
- with gr.Row():
1423
- count_image_uploader = gr.File(
1424
- label="βž• Add Example Image to Gallery",
1425
- file_types=["image"],
1426
- type="filepath"
1427
- )
 
 
1428
 
1429
 
1430
  with gr.Column(scale=2):
 
1431
  count_output = gr.Image(
1432
- label="πŸ“Έ Density Map",
1433
  type="filepath",
1434
  elem_id="density_map_output"
1435
-
1436
  )
1437
  count_alpha_slider = gr.Slider(
1438
  minimum=0.0,
@@ -1445,8 +1754,9 @@ with gr.Blocks(
1445
  label="πŸ“Š Statistics",
1446
  lines=2
1447
  )
 
1448
  download_density_btn = gr.File(
1449
- label="πŸ“₯ Download Original Prediction (.npy format)",
1450
  visible=True
1451
  )
1452
 
@@ -1478,44 +1788,61 @@ with gr.Blocks(
1478
  # State for managing gallery images
1479
  count_user_examples = gr.State(example_images_cnt.copy())
1480
 
1481
- # Function to add image to gallery
 
1482
  def add_to_count_gallery(new_img_file, current_imgs):
1483
  """Add uploaded image to gallery"""
1484
  if new_img_file is None:
1485
- return current_imgs, current_imgs
1486
-
1487
- try:
1488
- # Add new image path to list
1489
- if new_img_file not in current_imgs:
1490
- current_imgs.append(new_img_file)
1491
- print(f"βœ… Added image to gallery: {new_img_file}")
1492
- except Exception as e:
1493
- print(f"⚠️ Failed to add image: {e}")
1494
-
1495
- return current_imgs, current_imgs
1496
-
1497
- # When user uploads a new image file
1498
- count_image_uploader.upload(
 
 
 
1499
  fn=add_to_count_gallery,
1500
  inputs=[count_image_uploader, count_user_examples],
1501
- outputs=[count_user_examples, count_example_gallery]
1502
  )
1503
 
1504
- # When user selects from gallery, load into annotator
 
 
 
 
 
 
 
 
 
 
 
 
 
1505
  def load_from_count_gallery(evt: gr.SelectData, all_imgs):
1506
  """Load selected image from gallery into annotator"""
1507
  if evt.index is not None and evt.index < len(all_imgs):
1508
  selected_img = all_imgs[evt.index]
1509
  print(f"πŸ“Έ Loading image from gallery: {selected_img}")
1510
- return selected_img
1511
- return None
1512
-
1513
  count_example_gallery.select(
1514
  fn=load_from_count_gallery,
1515
  inputs=count_user_examples,
1516
- outputs=count_annotator
1517
  )
1518
-
1519
  # Run counting
1520
  count_btn.click(
1521
  fn=count_cells_handler,
@@ -1530,9 +1857,9 @@ with gr.Blocks(
1530
 
1531
  # Clear selection
1532
  clear_btn.click(
1533
- fn=lambda: (None, {}),
1534
  inputs=None,
1535
- outputs=[count_annotator, count_vis_state]
1536
  )
1537
 
1538
  # Submit feedback
@@ -1572,33 +1899,39 @@ with gr.Blocks(
1572
  gr.Markdown(
1573
  """
1574
  **Instructions:**
1575
- 1. Upload a ZIP file or select from the example library. The ZIP should contain a sequence of TIF images named in chronological order (e.g., t000.tif, t001.tif...)
1576
  2. (Optional) Specify a target object with a bounding box on the first frame and select "Yes", or click "Run Tracking" directly
1577
  3. Click "Run Tracking"
1578
  4. View the tracking results (you can adjust the overlay opacity by sliding the opacity bar below the visualization), download the CTC format results; if needed, click "Clear Selection" to choose a new ZIP file to run
 
 
1579
 
1580
- 🀘 Rate and submit feedback to help us improve the model!
1581
 
1582
  """
1583
  )
1584
 
1585
  with gr.Row():
1586
  with gr.Column(scale=1):
 
1587
  track_zip_upload = gr.File(
1588
- label="πŸ“¦ Upload Image Sequence in ZIP File",
1589
  file_types=[".zip"]
1590
  )
1591
 
1592
- # First frame annotation for bounding box
1593
- track_first_frame_annotator = BBoxAnnotator(
1594
- label="πŸ–ΌοΈ (Optional) First Frame Bounding Box Annotation",
1595
- categories=["cell"],
1596
- visible=False, # Hidden initially
1597
- )
 
 
1598
 
1599
  # Example ZIP gallery
 
1600
  track_example_gallery = gr.Gallery(
1601
- label="πŸ“ Example Video Gallery (Click to Select)",
1602
  columns=10,
1603
  rows=1,
1604
  height=120,
@@ -1618,15 +1951,17 @@ with gr.Blocks(
1618
  clear_btn = gr.Button("πŸ”„ Clear Selection", variant="secondary")
1619
 
1620
  # Add to gallery button
 
1621
  track_gallery_upload = gr.File(
1622
- label="βž• Add ZIP to Example Gallery",
1623
  file_types=[".zip"],
1624
  type="filepath"
1625
  )
1626
 
1627
  with gr.Column(scale=2):
 
1628
  track_first_frame_preview = gr.Image(
1629
- label="πŸ“Έ Tracking Visualization",
1630
  type="filepath",
1631
  # height=400,
1632
  elem_classes="uniform-height",
@@ -1646,10 +1981,12 @@ with gr.Blocks(
1646
  interactive=False
1647
  )
1648
 
1649
- track_download = gr.File(
1650
- label="πŸ“₯ Download Tracking Results (CTC Format)",
1651
- visible=False
1652
- )
 
 
1653
 
1654
  # Satisfaction rating
1655
  score_slider = gr.Slider(
@@ -1847,14 +2184,14 @@ with gr.Blocks(
1847
  track_zip_upload.change(
1848
  fn=load_first_frame_for_annotation,
1849
  inputs=track_zip_upload,
1850
- outputs=[track_first_frame_annotator, track_first_frame_annotator]
1851
  )
1852
 
1853
  # Run tracking
1854
  track_btn.click(
1855
  fn=track_video_handler,
1856
  inputs=[track_use_box_radio, track_first_frame_annotator, track_zip_upload, track_alpha_slider, track_vis_state],
1857
- outputs=[track_download, track_output, track_download, track_first_frame_preview, track_vis_state]
1858
  )
1859
  track_alpha_slider.change(
1860
  fn=update_tracking_overlay_alpha,
@@ -1909,4 +2246,9 @@ if __name__ == "__main__":
1909
  share=False,
1910
  ssr_mode=False,
1911
  show_error=True,
 
 
 
 
 
1912
  )
 
21
  from inference_seg import load_model as load_seg_model, run as run_seg
22
  from inference_count import load_model as load_count_model, run as run_count
23
  from inference_track import load_model as load_track_model, run as run_track
24
+ from _utils.image_io import (
25
+ standardize_image, inspect_image, image_size, array_nbytes,
26
+ RECOMMENDED_SIZE, WARN_SIZE, MAX_SIZE, MAX_READ_BYTES,
27
+ )
28
 
29
  HF_TOKEN = os.getenv("HF_TOKEN")
30
+ DATASET_REPO = "VisionLanguageGroup/feedback"
31
 
32
 
33
  print("===== clearing cache =====")
 
87
  save_feedback(query_id, feedback_type, feedback_text, img_path, bboxes)
88
  return
89
 
90
+ # One stem for the record and its image so the two pair up. query_id alone is
91
+ # not enough - a single session can submit feedback more than once.
92
+ stem = f"feedback_{query_id}_{int(time.time())}"
93
+
 
 
 
 
 
 
94
  try:
95
  api = HfApi()
96
+
97
+ # Upload the standardized PNG: the exact image the model saw (first
98
+ # frame, <=3 channels, auto-contrasted), not the raw upload.
99
+ image_in_repo = None
100
+ if img_path and os.path.exists(img_path):
101
+ try:
102
+ image_in_repo = f"images/{stem}.png"
103
+ api.upload_file(
104
+ path_or_fileobj=img_path,
105
+ path_in_repo=image_in_repo,
106
+ repo_id=DATASET_REPO,
107
+ repo_type="dataset",
108
+ token=HF_TOKEN
109
+ )
110
+ except Exception as e:
111
+ print(f"⚠️ Failed to upload image: {e}")
112
+ image_in_repo = None # still record the rest of the feedback
113
+
114
+ feedback_data = {
115
+ "query_id": query_id,
116
+ "feedback_type": feedback_type,
117
+ "feedback_text": feedback_text,
118
+ # Path inside the dataset repo. This used to be the local temp path,
119
+ # which was meaningless once the session ended.
120
+ "image_path": image_in_repo,
121
+ "bboxes": str(bboxes), # 转为字符串
122
+ "datetime": time.strftime("%Y-%m-%d %H:%M:%S"),
123
+ "timestamp": time.time()
124
+ }
125
+
126
+ filename = f"{stem}.json"
127
+
128
  with open(filename, 'w', encoding='utf-8') as f:
129
  json.dump(feedback_data, f, indent=2, ensure_ascii=False)
130
+
131
  api.upload_file(
132
  path_or_fileobj=filename,
133
  path_in_repo=f"data/{filename}",
 
135
  repo_type="dataset",
136
  token=HF_TOKEN
137
  )
138
+
139
  os.remove(filename)
140
+
141
+ print(f"βœ… Feedback saved to HF Dataset: {DATASET_REPO} ({stem})")
142
+
143
  except Exception as e:
144
  print(f"⚠️ Failed to save to HF Dataset: {e}")
145
  save_feedback(query_id, feedback_type, feedback_text, img_path, bboxes)
 
320
  pass
321
 
322
 
323
+ def _annot_path(annot_value):
324
+ """Extract the image path from a BBoxAnnotator value (path or (path, boxes))."""
325
+ if not annot_value:
326
+ return None
327
+ if isinstance(annot_value, (list, tuple)):
328
+ return annot_value[0] if len(annot_value) > 0 else None
329
+ return annot_value
330
+
331
+
332
+ def _human_bytes(n):
333
+ """Format a byte count as MB or GB, whichever reads better."""
334
+ gb = n / 1024 ** 3
335
+ return f"{gb:.1f} GB" if gb >= 1 else f"{n / 1024 ** 2:.0f} MB"
336
+
337
+
338
+ def check_image(img_path):
339
+ """Check a file from its header only (no pixel decode).
340
+
341
+ Returns a rejection message if the file cannot be used, otherwise None:
342
+ * unreadable / unsupported format
343
+ * dimensions over MAX_SIZE
344
+ * full array over MAX_READ_BYTES (a small time-lapse can still be huge)
345
+ Also emits an advisory warning over WARN_SIZE, since the model resizes every
346
+ input to RECOMMENDED_SIZE and a larger image only loses detail.
347
+ """
348
+ w, h = image_size(img_path)
349
+ if w <= 0 or h <= 0:
350
+ # Neither tifffile nor PIL could parse the header, so this is not a
351
+ # format we can read. Say so instead of failing silently downstream.
352
+ ext = os.path.splitext(img_path)[1].lower() or "(no extension)"
353
+ return (f"Could not read {ext} as an image. Supported formats: "
354
+ f"TIFF / OME-TIFF (8/16/32-bit, stacks, multi-channel), PNG, JPG, and other formats suported by tiffile. "
355
+ f"please export to TIFF/PNG/JPG (e.g. from ImageJ/Fiji) first.")
356
+
357
+ if w > MAX_SIZE or h > MAX_SIZE:
358
+ return (f"Image is {w}Γ—{h}, larger than the {MAX_SIZE}Γ—{MAX_SIZE} limit. "
359
+ f"Please crop or downsample it before uploading.")
360
+
361
+ nbytes = array_nbytes(img_path)
362
+ if nbytes > MAX_READ_BYTES:
363
+ info = inspect_image(img_path)
364
+ detail = f"{w}Γ—{h}"
365
+ if info.frames > 1:
366
+ detail += f" Γ— {info.frames} frames"
367
+ if info.channels > 1:
368
+ detail += f" Γ— {info.channels} channels"
369
+ return (f"This file needs {_human_bytes(nbytes)} of memory to open "
370
+ f"({detail}), over the {_human_bytes(MAX_READ_BYTES)} limit. "
371
+ f"Please crop it, or split out the frames you need.")
372
+
373
+ if w > WARN_SIZE or h > WARN_SIZE:
374
+ gr.Warning(f"Image is {w}Γ—{h} and will be resized to "
375
+ f"{RECOMMENDED_SIZE}Γ—{RECOMMENDED_SIZE}, so fine detail could be lost. "
376
+ f"You can try cropping the image for better results.",
377
+ # f"For best results, try cropping the image to focus on the region of interest.",
378
+ duration=None, title="⚠️ Large image")
379
+ return None
380
+
381
+
382
+ def _describe_read(info):
383
+ """Plain-language summary of how a file's dimensions were interpreted.
384
+
385
+ Deliberately avoids array jargon ("axis", "size-4"): the reader thinks in
386
+ channels / z-slices / timepoints, not numpy axes.
387
+ """
388
+ # lines = [f"Detected shape: {info.shape}"]
389
+ if info.frames >1:
390
+ lines = [f"Detected {info.channels}-channel {info.frames}-frame image stack of size {info.width}Γ—{info.height}."]
391
+ else:
392
+ lines = [f"Detected {info.channels}-channel image of size {info.width}Γ—{info.height}."]
393
+ if info.channels >= 3:
394
+ lines.append(f"Loaded as 3-channel RGB image.")
395
+ elif info.channels == 2:
396
+ lines.append(f"Loaded as 2-channel image (red, green).")
397
+ else:
398
+ lines.append(f"Loaded as single-channel grayscale image.")
399
+
400
+ if info.frames > 1:
401
+ lines.append(f"Showing first frame by default (you can use the stack frame slider to pick another).")
402
+
403
+ lines.append(
404
+ "If wrong, please convert your image to a 8-bit RGB/Grayscale image file before uploading (there are available tools such as ImageJ/Fiji)."
405
+ )
406
+ return "<br>".join(lines)
407
+
408
+
409
+ def prepare_uploaded_image(annot_value):
410
+ """On annotator upload: standardize frame 0 for preview and configure the
411
+ stack frame slider.
412
+
413
+ Browsers cannot render 16-bit / float / multi-page TIFFs, so we standardize
414
+ to an 8-bit RGB PNG. If the file is a stack, notify the user and reveal a
415
+ frame slider (default frame 1); otherwise keep it hidden.
416
+
417
+ Returns (annotator_value, raw_path, frame_slider_update).
418
+ """
419
+ img_path = _annot_path(annot_value)
420
+ if not img_path:
421
+ return annot_value, None, gr.update(visible=False, value=1)
422
+
423
+ # Guard before any pixels are read. On refusal clear the annotator so the
424
+ # oversize file cannot be sent to inference.
425
+ rejected = check_image(img_path)
426
+ if rejected:
427
+ gr.Warning(rejected, duration=None, title="❌ Cannot use this file")
428
+ return None, None, gr.update(visible=False, value=1)
429
+
430
+ info = inspect_image(img_path)
431
+ display = standardize_image(img_path, frame=0)
432
+
433
+ # Only speak up when something non-obvious happened: a dimension had to be
434
+ # guessed, it is a stack, or channels are being dropped. A plain RGB or
435
+ # grayscale image needs no explanation.
436
+ if info.guessed or info.frames > 1 or info.channels > 3:
437
+ gr.Info(_describe_read(info), duration=None, title="πŸ“š Image Loading Info")
438
+
439
+ slider = (gr.update(visible=True, maximum=info.frames, value=1) if info.frames > 1
440
+ else gr.update(visible=False, value=1))
441
+ return display, img_path, slider
442
+
443
+
444
+ def select_frame(raw_path, frame_num):
445
+ """Re-render the annotator preview for the chosen stack frame (1-based)."""
446
+ if not raw_path:
447
+ return gr.update()
448
+ return standardize_image(raw_path, frame=int(frame_num) - 1)
449
+
450
+
451
  @spaces.GPU
452
  def segment_with_choice(use_box_choice, annot_value, overlay_alpha):
453
  """Segmentation handler - supports bounding box, returns colorized overlay and original mask path"""
 
459
  bboxes = annot_value[1] if len(annot_value) > 1 else []
460
 
461
  print(f"πŸ–ΌοΈ Image path: {img_path}")
462
+ # One standardized image for both the model and the overlay (WYSIWYG).
463
+ img_path = standardize_image(img_path)
464
+ print(f"πŸ§ͺ Standardized image: {img_path}")
465
  box_array = None
466
  if use_box_choice == "Yes" and bboxes:
467
  box = parse_bboxes(bboxes)
 
517
  """Counting handler - supports bounding box, returns only density map"""
518
  if annot_value is None or len(annot_value) < 1:
519
  return None, None, "⚠️ Please provide an image.", {}
520
+
521
  image_path = annot_value[0]
522
  bboxes = annot_value[1] if len(annot_value) > 1 else []
523
 
524
  print(f"πŸ–ΌοΈ Image path: {image_path}")
525
+ # One standardized image for both the model and the overlay (WYSIWYG).
526
+ image_path = standardize_image(image_path)
527
+ print(f"πŸ§ͺ Standardized image: {image_path}")
528
  box_array = None
529
  if use_box_choice == "Yes" and bboxes:
530
  box = parse_bboxes(bboxes)
531
  if box:
532
  box_array = box
533
  print(f"πŸ“¦ Using bounding boxes: {box_array}")
534
+
535
  try:
536
  print(f"πŸ”’ Counting - Image: {image_path}")
537
+
538
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
539
  result = run_count(
540
  COUNT_MODEL,
 
1174
  /* ── Labels ──────────────────────────────────────────── */
1175
  label { font-weight: 500 !important; color: #374151 !important; }
1176
 
1177
+ /* File/media title pills use .block-label on a <label>, so the rule above
1178
+ forces their text dark. Restore the theme color/weight so they match the
1179
+ other native labels (e.g. "Overlay Opacity"). */
1180
+ .block-label,
1181
+ .block-label span {
1182
+ color: var(--block-label-text-color) !important;
1183
+ font-weight: var(--block-label-text-weight) !important;
1184
+ }
1185
+
1186
+ /* ── Collapsible "Image requirements" accordion ───────── */
1187
+
1188
+ /* The label is a <button class="label-wrap"> whose text sits in an inner <span>.
1189
+ Gradio styles that span directly:
1190
+ span.svelte-xxx { font-weight: var(--section-header-text-weight); // = 400
1191
+ font-size: var(--section-header-text-size); }
1192
+ so font-size/weight set on the button is overridden and has no effect - the
1193
+ span itself must be targeted. Colour is not set on the span, so it inherits. */
1194
+ .req-accordion .label-wrap {
1195
+ color: var(--block-label-text-color) !important;
1196
+ }
1197
+ .req-accordion .label-wrap span:not(.icon) {
1198
+ font-size: 0.9rem !important;
1199
+ font-weight: 700 !important;
1200
+ color: inherit !important;
1201
+ }
1202
+
1203
+ /* ── Frame headings (labels above media frames) ──────── */
1204
+ /* Neutralize the Gradio block wrapper so it doesn't clip/round the pill */
1205
+ .frame-heading {
1206
+ /* The column has a 16px flex gap; pull the pill back down so it sits
1207
+ close to the frame below instead of floating far above it. */
1208
+ margin-bottom: -8px !important;
1209
+ padding: 0 !important;
1210
+ min-width: 0 !important;
1211
+ min-height: 0 !important;
1212
+ background: transparent !important;
1213
+ border: none !important;
1214
+ box-shadow: none !important;
1215
+ border-radius: 0 !important;
1216
+ overflow: visible !important;
1217
+ }
1218
+ /* Match the theme's native field labels (e.g. "Overlay Opacity",
1219
+ "Download Original Prediction") exactly: same colors, borderless, fully
1220
+ rounded β€” driven by theme variables so it adapts to light/dark mode. */
1221
+ .frame-heading p {
1222
+ display: inline-block !important;
1223
+ width: fit-content !important;
1224
+ background: var(--block-label-background-fill) !important;
1225
+ color: var(--block-label-text-color) !important;
1226
+ border: none !important;
1227
+ border-radius: var(--block-label-radius) !important;
1228
+ font-weight: var(--block-label-text-weight) !important;
1229
+ font-size: var(--block-label-text-size) !important;
1230
+ line-height: 1.3 !important;
1231
+ padding: 5px 12px !important;
1232
+ margin: 0 !important;
1233
+ }
1234
+
1235
  /* ── Image output ────────────────────────────────────── */
1236
  .uniform-height {
1237
  height: 480px !important;
 
1344
  .dark label {
1345
  color: #cbd5e1 !important;
1346
  }
1347
+ .dark .block-label,
1348
+ .dark .block-label span {
1349
+ color: var(--block-label-text-color) !important;
1350
+ font-weight: var(--block-label-text-weight) !important;
1351
+ }
1352
 
1353
  /* ── Image output area ───────────────────────────────── */
1354
  .dark .uniform-height {
 
1379
  primary_hue=gr.themes.colors.sky,
1380
  secondary_hue=gr.themes.colors.slate,
1381
  neutral_hue=gr.themes.colors.slate,
1382
+ # Weights must be loaded to be usable: GoogleFont defaults to (400, 600),
1383
+ # so any font-weight above 600 silently falls back. Inter tops out at 900;
1384
+ # anything higher is dropped by Google Fonts without an error.
1385
+ font=gr.themes.GoogleFont("Inter", weights=(400, 600, 700, 800)),
1386
  ),
1387
  css=CSS,
1388
  ) as demo:
 
1407
  )
1408
 
1409
  # ε…¨ε±€ηŠΆζ€
1410
+ # Must be a callable: gr.State deepcopies a plain value, so str(uuid4())
1411
+ # would be evaluated once at construction and every session would share the
1412
+ # same id. A callable is re-run on each app load, giving one id per session.
1413
+ current_query_id = gr.State(lambda: str(uuid.uuid4()))
1414
  user_uploaded_examples = gr.State(example_images_seg.copy())
1415
  seg_vis_state = gr.State({})
1416
  count_vis_state = gr.State({})
1417
  track_vis_state = gr.State({})
1418
+ # Raw uploaded path, kept so a different stack frame can be re-extracted
1419
+ # (the annotator only holds the already-flattened preview PNG).
1420
+ seg_raw_state = gr.State(None)
1421
+ count_raw_state = gr.State(None)
1422
 
1423
  with gr.Tabs():
1424
  # ===== Tab 1: Segmentation =====
 
1427
  gr.Markdown(
1428
  """
1429
  **Instructions:**
1430
+ 1. Select an example image from the Example Image Gallery or upload your own image (**Please see the "Image Upload Requirements" section below before uploading**)
1431
  2. (Optional) Specify a target object with a bounding box and select "Yes", or click "Run Segmentation" directly
1432
  3. Click "Run Segmentation"
1433
  4. View the segmentation results (you can adjust the overlay opacity by sliding the opacity bar below the visualization), download the original predicted mask (.tif format); if needed, click "Clear Selection" to choose a new image
1434
+
1435
+ πŸ’‘ Want to reuse an image? Upload it under "βž• Upload New Example Image to Gallery" and click "βž• Add to Gallery". The uploaded image will appear at the front of the gallery, and can be loaded by clicking its thumbnail. Gallery additions last for the current session only and are cleared when you reload the page.
1436
+
1437
+ 🀘 Tell us about your experience by rating and submitting feedback, which would greatly help us improve the framework!
1438
  """
1439
  )
1440
+ # Reference material - collapsed by default. Must stay AFTER the
1441
+ # instructions markdown: the CSS styles it via .prose:nth-child(2).
1442
+ with gr.Accordion("πŸ“‹ Image Upload Requirements (click to expand or fold)", open=False, elem_classes="req-accordion"):
1443
+ gr.Markdown(
1444
+ """
1445
+ **Please do not upload images or data containing protected health information (PHI) or personally identifiable information (PII).**
1446
+
1447
+ - **Formats:** Supports TIFF / OME-TIFF (8/16/32-bit, stacks, multi-channel), PNG, JPG, as well as various other microscopy image formats supported by [tifffiles](https://github.com/cgohlke/tifffile)
1448
+ - **Maximum image size:** 4096 Γ— 4096 pixels
1449
+ - **Channels:** Accepts RGB (3-channel), Grayscale (single-channel); for images with more than 3 channels, only the first three channels are used as RGB channels.
1450
+ - **For image stacks:** First frame is loaded by default; you can pick another with the stack frame slider that appears after upload
1451
+
1452
+ **Alternatively, you can use existing tools (e.g., ImageJ/Fiji) to convert your image to a 8-bit RGB/Grayscale PNG/JPG/TIFF file before uploading.**
1453
+
1454
+ """
1455
+ )
1456
 
1457
  with gr.Row():
1458
  with gr.Column(scale=1):
1459
+ gr.Markdown("πŸ–ΌοΈ Upload Image (Optional: Provide a Bounding Box)", elem_classes="frame-heading")
1460
  annotator = BBoxAnnotator(
1461
+ show_label=False,
1462
  categories=["cell"],
1463
  )
1464
+ seg_frame_slider = gr.Slider(
1465
+ minimum=1, maximum=1, step=1, value=1,
1466
+ label="πŸ“š Stack frame (choose frame to use)",
1467
+ visible=False,
1468
+ )
1469
+
1470
  # Example Images Gallery
1471
+ gr.Markdown("πŸ“ Example Image Gallery", elem_classes="frame-heading")
1472
  example_gallery = gr.Gallery(
1473
+ show_label=True,
1474
  columns=len(example_images_seg),
1475
  rows=1,
1476
  height=120,
 
1490
  clear_btn = gr.Button("πŸ”„ Clear Selection", variant="secondary")
1491
 
1492
  # Upload Example Image
1493
+ gr.Markdown("βž• Upload New Example Image to Gallery", elem_classes="frame-heading")
1494
+ image_uploader = gr.File(
1495
+ show_label=False,
1496
+ file_types=["image", ".tif", ".tiff"],
1497
  type="filepath"
1498
  )
1499
+ add_to_gallery_btn = gr.Button("βž• Add to Gallery", variant="secondary", size="sm")
1500
+ add_gallery_status = gr.Markdown(visible=False)
1501
 
1502
 
1503
  with gr.Column(scale=2):
1504
+ gr.Markdown("πŸ“Έ Segmentation Result", elem_classes="frame-heading")
1505
  seg_output = gr.Image(
1506
  type="pil",
1507
+ show_label=False,
1508
  elem_classes="uniform-height"
1509
  )
1510
  seg_alpha_slider = gr.Slider(
 
1516
  )
1517
 
1518
  # Download Original Prediction
1519
+ gr.Markdown("πŸ“₯ Download Original Prediction (.tif format)", elem_classes="frame-heading")
1520
  download_mask_btn = gr.File(
1521
+ show_label=False,
1522
  visible=True,
1523
  height=40,
1524
  )
 
1548
  visible=False
1549
  )
1550
 
1551
+ # standardize on upload (preview) + configure the stack frame slider
1552
+ annotator.upload(
1553
+ fn=prepare_uploaded_image,
1554
+ inputs=annotator,
1555
+ outputs=[annotator, seg_raw_state, seg_frame_slider]
1556
+ )
1557
+ # re-render the preview when the user picks a different stack frame
1558
+ seg_frame_slider.release(
1559
+ fn=select_frame,
1560
+ inputs=[seg_raw_state, seg_frame_slider],
1561
+ outputs=annotator
1562
+ )
1563
+
1564
  # click event for segmentation
1565
  run_seg_btn.click(
1566
  fn=segment_with_choice,
 
1575
 
1576
  # click event for clear button
1577
  clear_btn.click(
1578
+ fn=lambda: (None, {}, None, gr.update(visible=False, value=1)),
1579
  inputs=None,
1580
+ outputs=[annotator, seg_vis_state, seg_raw_state, seg_frame_slider]
1581
  )
1582
 
1583
  # init Gallery with example images
 
1586
  outputs=example_gallery
1587
  )
1588
 
1589
+ # Add the uploaded image to the gallery on button click, confirm with
1590
+ # a status message, and clear the uploader so the upload box returns.
1591
  def add_to_gallery(img_path, current_imgs):
1592
  if not img_path:
1593
+ return (current_imgs, current_imgs,
1594
+ gr.update(value="⚠️ Please upload an image first.", visible=True), None)
1595
+ rejected = check_image(img_path)
1596
+ if rejected:
1597
+ return (current_imgs, current_imgs,
1598
+ gr.update(value=f"❌ {rejected}", visible=True), None)
1599
+ # Standardize to an 8-bit PNG so TIFFs render as gallery
1600
+ # thumbnails; prepend so it's immediately visible at the front.
1601
+ std_path = standardize_image(img_path)
1602
+ if std_path not in current_imgs:
1603
+ current_imgs.insert(0, std_path)
1604
+ return (current_imgs, current_imgs,
1605
+ gr.update(value="βœ… Added to the Example Gallery below.", visible=True), None)
1606
+
1607
+ add_to_gallery_btn.click(
1608
  fn=add_to_gallery,
1609
  inputs=[image_uploader, user_uploaded_examples],
1610
+ outputs=[user_uploaded_examples, example_gallery, add_gallery_status, image_uploader]
 
 
 
 
1611
  )
1612
 
1613
+ # click event for Gallery selection (gallery entries are single-frame)
1614
  def load_from_gallery(evt: gr.SelectData, all_imgs):
1615
  if evt.index is not None and evt.index < len(all_imgs):
1616
+ item = all_imgs[evt.index]
1617
+ return standardize_image(item), item, gr.update(visible=False, value=1)
1618
+ return None, None, gr.update(visible=False, value=1)
1619
+
1620
  example_gallery.select(
1621
  fn=load_from_gallery,
1622
  inputs=user_uploaded_examples,
1623
+ outputs=[annotator, seg_raw_state, seg_frame_slider]
1624
  )
1625
 
1626
  # click event for submitting feedback
 
1660
  gr.Markdown(
1661
  """
1662
  **Usage Instructions:**
1663
+ 1. Select an example image from the Example Image Gallery or upload your own image (**Please see the "Image Upload Requirements" section below before uploading**)
1664
  2. (Optional) Specify a target object with a bounding box and select "Yes", or click "Run Counting" directly
1665
  3. Click "Run Counting"
1666
  4. View the density map (you can adjust the density opacity by sliding the opacity bar below the visualization), download the original prediction (.npy format); if needed, click "Clear Selection" to choose a new image to run
1667
+
1668
+ πŸ’‘ Want to reuse an image? Upload it under "βž• Upload New Example Image to Gallery" and click "βž• Add to Gallery". The uploaded image will appear at the front of the gallery, and can be loaded by clicking its thumbnail. Gallery additions last for the current session only and are cleared when you reload the page.
1669
+
1670
+ 🀘 Tell us about your experience by rating and submitting feedback, which would greatly help us improve the framework!
1671
  """
1672
  )
1673
+ # Reference material - collapsed by default. Must stay AFTER the
1674
+ # instructions markdown: the CSS styles it via .prose:nth-child(2).
1675
+ with gr.Accordion("πŸ“‹ Image requirements", open=False, elem_classes="req-accordion"):
1676
+ gr.Markdown(
1677
+ """
1678
+ **Please do not upload images or data containing protected health information (PHI) or personally identifiable information (PII).**
1679
+
1680
+ - **Formats:** Supports TIFF / OME-TIFF (8/16/32-bit, stacks, multi-channel), PNG, JPG, as well as various other microscopy image formats supported by [tifffiles](https://github.com/cgohlke/tifffile)
1681
+ - **Maximum image size:** 4096 Γ— 4096 pixels
1682
+ - **Channels:** Accepts RGB (3-channel), Grayscale (single-channel); for images with more than 3 channels, only the first three channels are used as RGB channels.
1683
+ - **For image stacks:** First frame is loaded by default; you can pick another with the stack frame slider that appears after upload
1684
+
1685
+ **Alternatively, you can use existing tools (e.g., ImageJ/Fiji) to convert your image to a 8-bit RGB/Grayscale PNG/JPG/TIFF file before uploading.**
1686
+ """
1687
+ )
1688
 
1689
  with gr.Row():
1690
  with gr.Column(scale=1):
1691
+ gr.Markdown("πŸ–ΌοΈ Upload Image (Optional: Provide a Bounding Box)", elem_classes="frame-heading")
1692
  count_annotator = BBoxAnnotator(
1693
+ show_label=False,
1694
  categories=["cell"],
1695
  )
1696
+ count_frame_slider = gr.Slider(
1697
+ minimum=1, maximum=1, step=1, value=1,
1698
+ label="πŸ“š Stack frame (choose frame to use)",
1699
+ visible=False,
1700
+ )
1701
+
1702
  # Example gallery with "add" functionality
1703
+ gr.Markdown("πŸ“ Example Image Gallery", elem_classes="frame-heading")
1704
  with gr.Row():
1705
  count_example_gallery = gr.Gallery(
1706
+ show_label=False,
1707
  columns=len(example_images_cnt),
1708
  rows=1,
1709
  object_fit="cover",
 
1725
  clear_btn = gr.Button("πŸ”„ Clear Selection", variant="secondary")
1726
 
1727
  # Add button to upload new examples
1728
+ gr.Markdown("βž• Add Example Image to Gallery", elem_classes="frame-heading")
1729
+ count_image_uploader = gr.File(
1730
+ show_label=False,
1731
+ file_types=["image"],
1732
+ type="filepath"
1733
+ )
1734
+ add_to_count_gallery_btn = gr.Button("βž• Add to Gallery", variant="secondary", size="sm")
1735
+ count_add_status = gr.Markdown(visible=False)
1736
 
1737
 
1738
  with gr.Column(scale=2):
1739
+ gr.Markdown("πŸ“Έ Density Map", elem_classes="frame-heading")
1740
  count_output = gr.Image(
1741
+ show_label=False,
1742
  type="filepath",
1743
  elem_id="density_map_output"
1744
+
1745
  )
1746
  count_alpha_slider = gr.Slider(
1747
  minimum=0.0,
 
1754
  label="πŸ“Š Statistics",
1755
  lines=2
1756
  )
1757
+ gr.Markdown("πŸ“₯ Download Original Prediction (.npy format)", elem_classes="frame-heading")
1758
  download_density_btn = gr.File(
1759
+ show_label=False,
1760
  visible=True
1761
  )
1762
 
 
1788
  # State for managing gallery images
1789
  count_user_examples = gr.State(example_images_cnt.copy())
1790
 
1791
+ # Add the uploaded image to the gallery on button click, confirm with
1792
+ # a status message, and clear the uploader afterward.
1793
  def add_to_count_gallery(new_img_file, current_imgs):
1794
  """Add uploaded image to gallery"""
1795
  if new_img_file is None:
1796
+ return (current_imgs, current_imgs,
1797
+ gr.update(value="⚠️ Please upload an image first.", visible=True), None)
1798
+ rejected = check_image(new_img_file)
1799
+ if rejected:
1800
+ return (current_imgs, current_imgs,
1801
+ gr.update(value=f"❌ {rejected}", visible=True), None)
1802
+ # Standardize to an 8-bit PNG so TIFFs render as gallery
1803
+ # thumbnails; prepend so it's immediately visible at the front.
1804
+ std_path = standardize_image(new_img_file)
1805
+ if std_path not in current_imgs:
1806
+ current_imgs.insert(0, std_path)
1807
+ print(f"βœ… Added image to gallery: {std_path}")
1808
+ return (current_imgs, current_imgs,
1809
+ gr.update(value="βœ… Added to the Example Gallery above.", visible=True), None)
1810
+
1811
+ # When user clicks "Add to Gallery"
1812
+ add_to_count_gallery_btn.click(
1813
  fn=add_to_count_gallery,
1814
  inputs=[count_image_uploader, count_user_examples],
1815
+ outputs=[count_user_examples, count_example_gallery, count_add_status, count_image_uploader]
1816
  )
1817
 
1818
+ # standardize on upload (preview) + configure the stack frame slider
1819
+ count_annotator.upload(
1820
+ fn=prepare_uploaded_image,
1821
+ inputs=count_annotator,
1822
+ outputs=[count_annotator, count_raw_state, count_frame_slider]
1823
+ )
1824
+ # re-render the preview when the user picks a different stack frame
1825
+ count_frame_slider.release(
1826
+ fn=select_frame,
1827
+ inputs=[count_raw_state, count_frame_slider],
1828
+ outputs=count_annotator
1829
+ )
1830
+
1831
+ # When user selects from gallery, load into annotator (single-frame)
1832
  def load_from_count_gallery(evt: gr.SelectData, all_imgs):
1833
  """Load selected image from gallery into annotator"""
1834
  if evt.index is not None and evt.index < len(all_imgs):
1835
  selected_img = all_imgs[evt.index]
1836
  print(f"πŸ“Έ Loading image from gallery: {selected_img}")
1837
+ return standardize_image(selected_img), selected_img, gr.update(visible=False, value=1)
1838
+ return None, None, gr.update(visible=False, value=1)
1839
+
1840
  count_example_gallery.select(
1841
  fn=load_from_count_gallery,
1842
  inputs=count_user_examples,
1843
+ outputs=[count_annotator, count_raw_state, count_frame_slider]
1844
  )
1845
+
1846
  # Run counting
1847
  count_btn.click(
1848
  fn=count_cells_handler,
 
1857
 
1858
  # Clear selection
1859
  clear_btn.click(
1860
+ fn=lambda: (None, {}, None, gr.update(visible=False, value=1)),
1861
  inputs=None,
1862
+ outputs=[count_annotator, count_vis_state, count_raw_state, count_frame_slider]
1863
  )
1864
 
1865
  # Submit feedback
 
1899
  gr.Markdown(
1900
  """
1901
  **Instructions:**
1902
+ 1. Select a video from the example library or Upload your own video ZIP file. The ZIP should contain a sequence of TIF images named in chronological order (e.g., t000.tif, t001.tif...)
1903
  2. (Optional) Specify a target object with a bounding box on the first frame and select "Yes", or click "Run Tracking" directly
1904
  3. Click "Run Tracking"
1905
  4. View the tracking results (you can adjust the overlay opacity by sliding the opacity bar below the visualization), download the CTC format results; if needed, click "Clear Selection" to choose a new ZIP file to run
1906
+
1907
+ πŸ’‘ Want to reuse a video? Upload the ZIP under "βž• Upload New Example Image to Gallery" and click "βž• Add to Gallery". The uploaded video will appear at the front of the gallery, and can be loaded by clicking its thumbnail. Gallery additions last for the current session only and are cleared when you reload the page.
1908
 
1909
+ 🀘 Tell us about your experience by rating and submitting feedback, which would greatly help us improve the framework!
1910
 
1911
  """
1912
  )
1913
 
1914
  with gr.Row():
1915
  with gr.Column(scale=1):
1916
+ gr.Markdown("πŸ“¦ Upload Image Sequence in ZIP File", elem_classes="frame-heading")
1917
  track_zip_upload = gr.File(
1918
+ show_label=False,
1919
  file_types=[".zip"]
1920
  )
1921
 
1922
+ # First frame annotation for bounding box (heading + annotator
1923
+ # share one column so they show/hide together)
1924
+ with gr.Column(visible=False) as track_annot_group:
1925
+ gr.Markdown("πŸ–ΌοΈ (Optional) First Frame Bounding Box Annotation", elem_classes="frame-heading")
1926
+ track_first_frame_annotator = BBoxAnnotator(
1927
+ show_label=False,
1928
+ categories=["cell"],
1929
+ )
1930
 
1931
  # Example ZIP gallery
1932
+ gr.Markdown("πŸ“ Example Video Gallery (Click to Select)", elem_classes="frame-heading")
1933
  track_example_gallery = gr.Gallery(
1934
+ show_label=False,
1935
  columns=10,
1936
  rows=1,
1937
  height=120,
 
1951
  clear_btn = gr.Button("πŸ”„ Clear Selection", variant="secondary")
1952
 
1953
  # Add to gallery button
1954
+ gr.Markdown("βž• Add ZIP to Example Gallery", elem_classes="frame-heading")
1955
  track_gallery_upload = gr.File(
1956
+ show_label=False,
1957
  file_types=[".zip"],
1958
  type="filepath"
1959
  )
1960
 
1961
  with gr.Column(scale=2):
1962
+ gr.Markdown("πŸ“Έ Tracking Visualization", elem_classes="frame-heading")
1963
  track_first_frame_preview = gr.Image(
1964
+ show_label=False,
1965
  type="filepath",
1966
  # height=400,
1967
  elem_classes="uniform-height",
 
1981
  interactive=False
1982
  )
1983
 
1984
+ # heading + download share one column so they show/hide together
1985
+ with gr.Column(visible=False) as track_dl_group:
1986
+ gr.Markdown("πŸ“₯ Download Tracking Results (CTC Format)", elem_classes="frame-heading")
1987
+ track_download = gr.File(
1988
+ show_label=False,
1989
+ )
1990
 
1991
  # Satisfaction rating
1992
  score_slider = gr.Slider(
 
2184
  track_zip_upload.change(
2185
  fn=load_first_frame_for_annotation,
2186
  inputs=track_zip_upload,
2187
+ outputs=[track_first_frame_annotator, track_annot_group]
2188
  )
2189
 
2190
  # Run tracking
2191
  track_btn.click(
2192
  fn=track_video_handler,
2193
  inputs=[track_use_box_radio, track_first_frame_annotator, track_zip_upload, track_alpha_slider, track_vis_state],
2194
+ outputs=[track_download, track_output, track_dl_group, track_first_frame_preview, track_vis_state]
2195
  )
2196
  track_alpha_slider.change(
2197
  fn=update_tracking_overlay_alpha,
 
2246
  share=False,
2247
  ssr_mode=False,
2248
  show_error=True,
2249
+ # Deliberately no max_file_size: a transport-layer 413 is silent in the
2250
+ # annotator (we never get to run, so we cannot show a message), and file
2251
+ # size is a poor proxy anyway - a 1344x1024 time-lapse can be 256MB while
2252
+ # a 4096x4096 RGB is only 50MB. Size is checked in check_image()
2253
+ # instead, where a real message can be shown.
2254
  )