VisionLanguageGroup commited on
Commit
7b5645e
Β·
1 Parent(s): 73d5259

support more formats

Browse files
Files changed (2) hide show
  1. _utils/image_io.py +93 -46
  2. app.py +58 -34
_utils/image_io.py CHANGED
@@ -32,6 +32,31 @@ _CHANNEL = ("C", "S") # C = separate channel planes, S = interleaved RGB sa
32
  _UNKNOWN = ("Q", "I") # file named no axis; only these may be *guessed* as channels
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def _read_array(path):
36
  """Load an image file into (array, axes) where axes names each dimension.
37
 
@@ -40,23 +65,12 @@ def _read_array(path):
40
  "8-bit Color", palette PNG/GIF) are expanded through their color lookup
41
  table so we return true RGB, not bare indices.
42
  """
43
- ext = os.path.splitext(path)[1].lower()
44
- if ext in (".tif", ".tiff") and tifffile is not None:
45
- with tifffile.TiffFile(path) as tif:
46
- series = tif.series[0]
47
- page = tif.pages[0]
48
- arr = np.asarray(series.asarray())
49
- axes = str(series.axes)
50
- is_palette = getattr(page, "photometric", None) == tifffile.PHOTOMETRIC.PALETTE
51
- if is_palette and page.colormap is not None:
52
- colormap = np.asarray(page.colormap) # (3, 2**bits), uint16
53
- rgb = np.moveaxis(colormap[:, arr], 0, -1) # (..., H, W, 3)
54
- # TIFF colormaps are 16-bit; scale down to 8-bit (65535/255=257).
55
- arr = np.round(rgb / 257.0).astype(np.uint8)
56
- axes = axes + "S" # the LUT added an RGB sample axis
57
- return arr, axes
58
- # Everything else (and if tifffile is unavailable): PIL. Expand palette
59
- # images to RGB so the LUT is applied instead of returning bare indices.
60
  img = Image.open(path)
61
  if img.mode in ("P", "PA"):
62
  img = img.convert("RGB")
@@ -66,8 +80,7 @@ def _read_array(path):
66
 
67
  def _shape_axes(path):
68
  """Return (shape, axes) from metadata only - no pixel decode."""
69
- ext = os.path.splitext(path)[1].lower()
70
- if ext in (".tif", ".tiff") and tifffile is not None:
71
  try:
72
  with tifffile.TiffFile(path) as tif:
73
  series = tif.series[0]
@@ -114,12 +127,17 @@ def _plan_axes(shape, axes):
114
  return shape, axes, guessed
115
 
116
 
117
- def _reduce_to_hwc(arr, axes, frame=0):
118
  """Collapse a labelled array to 2D (H, W) or 3D (H, W, C).
119
 
120
  The channel axis (named by the file, or inferred by ``_plan_axes`` only when
121
- the file names none) is moved last and kept whole. The remaining stack axes
122
- (Z / time / page) are indexed: the first by ``frame``, any deeper ones by 0.
 
 
 
 
 
123
  """
124
  axes = list(axes)
125
 
@@ -141,27 +159,45 @@ def _reduce_to_hwc(arr, axes, frame=0):
141
  axes.append(axes.pop(ci))
142
 
143
  target = 3 if ci is not None else 2
144
- first = True
145
  while len(axes) > target:
146
- idx = int(frame) if first else 0
147
- idx = max(0, min(idx, arr.shape[0] - 1))
148
- arr = arr[idx]
 
 
 
 
 
149
  axes.pop(0)
150
- first = False
151
 
152
  return arr
153
 
154
 
 
 
 
 
 
 
 
 
 
 
155
  class ImageInfo(NamedTuple):
156
  """How a file's dimensions were interpreted (read from metadata only).
157
 
158
- frames - length of the first stack (Z/T) axis, 1 if not a stack
159
- channels - length of the channel axis, 1 if single-channel
160
- axes - the file's own axes string, e.g. 'CZYX' ('Q' = unnamed)
161
- guessed - True if the channel axis was inferred rather than read
162
- shape - the file's raw shape as stored, e.g. (1, 4, 1, 1024, 1024)
163
- width - pixels along the X axis (0 if unknown)
164
- height - pixels along the Y axis (0 if unknown)
 
 
 
165
  """
166
  frames: int
167
  channels: int
@@ -170,6 +206,9 @@ class ImageInfo(NamedTuple):
170
  shape: tuple
171
  width: int
172
  height: int
 
 
 
173
 
174
 
175
  def inspect_image(path):
@@ -179,8 +218,13 @@ def inspect_image(path):
179
  planned_shape, planned_axes, guessed = _plan_axes(shape, axes)
180
  has_c = bool(planned_axes) and planned_axes[-1] in _CHANNEL
181
  channels = int(planned_shape[-1]) if has_c else 1
182
- target = 3 if has_c else 2
183
- frames = int(planned_shape[0]) if len(planned_axes) > target else 1
 
 
 
 
 
184
 
185
  # Spatial size comes from the named Y/X axes, so it stays correct
186
  # whatever order the other axes are in.
@@ -193,7 +237,8 @@ def inspect_image(path):
193
  if not (width and height): # no Y/X named; fall back to the header probe
194
  width, height = image_size(path)
195
 
196
- return ImageInfo(frames, channels, str(axes), guessed, tuple(shape), width, height)
 
197
  except Exception: # noqa: BLE001
198
  return ImageInfo(1, 1, "", False, (), 0, 0)
199
 
@@ -209,8 +254,7 @@ def array_nbytes(path):
209
  Computed from shape + dtype in the header - no pixel decode - so it is safe
210
  to call on a file that is too large to open. Returns 0 if unknown.
211
  """
212
- ext = os.path.splitext(path)[1].lower()
213
- if ext in (".tif", ".tiff") and tifffile is not None:
214
  try:
215
  with tifffile.TiffFile(path) as tif:
216
  series = tif.series[0]
@@ -232,8 +276,7 @@ def image_size(path):
232
  that would be too large to load. Returns (0, 0) if the size cannot be
233
  determined, so callers treat it as "unknown" and proceed.
234
  """
235
- ext = os.path.splitext(path)[1].lower()
236
- if ext in (".tif", ".tiff") and tifffile is not None:
237
  try:
238
  with tifffile.TiffFile(path) as tif:
239
  page = tif.pages[0]
@@ -261,10 +304,10 @@ def _to_rgb(arr):
261
  return arr[:, :, :3]
262
 
263
 
264
- def _load_rgb(path, frame=0):
265
  """Read a file and reduce it to an (H, W, 3) array plus its source dtype."""
266
  raw, axes = _read_array(path)
267
- arr = _reduce_to_hwc(raw, axes, frame=frame)
268
  arr = _to_rgb(arr)
269
  return arr, raw.dtype
270
 
@@ -306,7 +349,7 @@ def _save_png(arr, out_path=None, out_dir=None, base=None, suffix="_std.png"):
306
  return out_path
307
 
308
 
309
- def standardize_image(path, frame=0, out_dir=None, out_path=None):
310
  """Standardize any image/TIFF to a canonical 8-bit RGB PNG.
311
 
312
  Used for both the preview and the model: one frame, <=3 channels, with a
@@ -315,7 +358,11 @@ def standardize_image(path, frame=0, out_dir=None, out_path=None):
315
 
316
  Args:
317
  path: path to the input image (TIFF, PNG, JPG, ...).
318
- frame: which frame of a stack to use (0-based; ignored for non-stacks).
 
 
 
 
319
  out_dir: optional directory for the output PNG.
320
  out_path: optional explicit output file path (overrides out_dir).
321
 
@@ -324,7 +371,7 @@ def standardize_image(path, frame=0, out_dir=None, out_path=None):
324
  returned unchanged so callers degrade gracefully.
325
  """
326
  try:
327
- arr, dtype = _load_rgb(path, frame=frame)
328
  arr = _to_uint8(arr, stretch=(dtype != np.uint8))
329
  base = os.path.splitext(os.path.basename(path))[0]
330
  return _save_png(arr, out_path=out_path, out_dir=out_dir, base=base, suffix="_std.png")
 
32
  _UNKNOWN = ("Q", "I") # file named no axis; only these may be *guessed* as channels
33
 
34
 
35
+ TIFF_EXTENSIONS = (
36
+ sorted("." + e for e in tifffile.TIFF.FILE_EXTENSIONS if "." not in e)
37
+ if tifffile is not None else [".tif", ".tiff"]
38
+ )
39
+
40
+
41
+ def _read_tiff(path):
42
+ """Read a TIFF-family file into (array, axes). Raises if not readable."""
43
+ with tifffile.TiffFile(path) as tif:
44
+ series = tif.series[0]
45
+ page = tif.pages[0]
46
+ arr = np.asarray(series.asarray())
47
+ axes = str(series.axes)
48
+
49
+ is_palette = (getattr(page, "photometric", None) == tifffile.PHOTOMETRIC.PALETTE
50
+ and not any(a in _CHANNEL for a in axes))
51
+ if is_palette and page.colormap is not None:
52
+ colormap = np.asarray(page.colormap) # (3, 2**bits), uint16
53
+ rgb = np.moveaxis(colormap[:, arr], 0, -1) # (..., H, W, 3)
54
+ # TIFF colormaps are 16-bit; scale down to 8-bit (65535/255=257).
55
+ arr = np.round(rgb / 257.0).astype(np.uint8)
56
+ axes = axes + "S" # the LUT added an RGB sample axis
57
+ return arr, axes
58
+
59
+
60
  def _read_array(path):
61
  """Load an image file into (array, axes) where axes names each dimension.
62
 
 
65
  "8-bit Color", palette PNG/GIF) are expanded through their color lookup
66
  table so we return true RGB, not bare indices.
67
  """
68
+ if tifffile is not None:
69
+ try:
70
+ return _read_tiff(path)
71
+ except Exception: # noqa: BLE001 - not a TIFF, or tifffile cannot parse it
72
+ pass
73
+
 
 
 
 
 
 
 
 
 
 
 
74
  img = Image.open(path)
75
  if img.mode in ("P", "PA"):
76
  img = img.convert("RGB")
 
80
 
81
  def _shape_axes(path):
82
  """Return (shape, axes) from metadata only - no pixel decode."""
83
+ if tifffile is not None:
 
84
  try:
85
  with tifffile.TiffFile(path) as tif:
86
  series = tif.series[0]
 
127
  return shape, axes, guessed
128
 
129
 
130
+ def _reduce_to_hwc(arr, axes, frame=0, sub_frame=None):
131
  """Collapse a labelled array to 2D (H, W) or 3D (H, W, C).
132
 
133
  The channel axis (named by the file, or inferred by ``_plan_axes`` only when
134
+ the file names none) is moved last and kept whole. Stack axes (Z / time /
135
+ page) are handled in order:
136
+ * the first is indexed by ``frame`` (the frame slider);
137
+ * the second is indexed by ``sub_frame`` if given, else collapsed by a
138
+ maximum-intensity projection (the natural default for a focal stack -
139
+ keeps the brightest signal across planes rather than an arbitrary one);
140
+ * any deeper ones are projected too.
141
  """
142
  axes = list(axes)
143
 
 
159
  axes.append(axes.pop(ci))
160
 
161
  target = 3 if ci is not None else 2
162
+ stack = 0
163
  while len(axes) > target:
164
+ if stack == 0:
165
+ idx = max(0, min(int(frame), arr.shape[0] - 1))
166
+ arr = arr[idx]
167
+ elif stack == 1 and sub_frame is not None:
168
+ idx = max(0, min(int(sub_frame), arr.shape[0] - 1))
169
+ arr = arr[idx]
170
+ else:
171
+ arr = arr.max(axis=0) # maximum-intensity projection over this axis
172
  axes.pop(0)
173
+ stack += 1
174
 
175
  return arr
176
 
177
 
178
+ # tifffile axis letters -> words a microscopist uses, for messages and slider
179
+ # labels. Anything unmapped (an unnamed 'Q'/'I' axis, a raw page axis) is just a
180
+ # "frame".
181
+ _AXIS_LABELS = {"T": "timepoint", "Z": "z-plane"}
182
+
183
+
184
+ def _axis_label(a):
185
+ return _AXIS_LABELS.get(a, "frame")
186
+
187
+
188
  class ImageInfo(NamedTuple):
189
  """How a file's dimensions were interpreted (read from metadata only).
190
 
191
+ frames - length of the first stack (Z/T) axis, 1 if not a stack
192
+ channels - length of the channel axis, 1 if single-channel
193
+ axes - the file's own axes string, e.g. 'CZYX' ('Q' = unnamed)
194
+ guessed - True if the channel axis was inferred rather than read
195
+ shape - the file's raw shape as stored, e.g. (1, 4, 1, 1024, 1024)
196
+ width - pixels along the X axis (0 if unknown)
197
+ height - pixels along the Y axis (0 if unknown)
198
+ sub_frames- length of a *second* stack axis (e.g. Z in a time+Z file), 1 if
199
+ there is only one stack axis
200
+ frame_label / sub_label - the words for those two axes ('timepoint', ...)
201
  """
202
  frames: int
203
  channels: int
 
206
  shape: tuple
207
  width: int
208
  height: int
209
+ sub_frames: int = 1
210
+ frame_label: str = "frame"
211
+ sub_label: str = "frame"
212
 
213
 
214
  def inspect_image(path):
 
218
  planned_shape, planned_axes, guessed = _plan_axes(shape, axes)
219
  has_c = bool(planned_axes) and planned_axes[-1] in _CHANNEL
220
  channels = int(planned_shape[-1]) if has_c else 1
221
+
222
+ stack = [(a, d) for a, d in zip(planned_axes, planned_shape)
223
+ if a not in ("Y", "X") and a not in _CHANNEL]
224
+ frames = int(stack[0][1]) if stack else 1
225
+ frame_label = _axis_label(stack[0][0]) if stack else "frame"
226
+ sub_frames = int(stack[1][1]) if len(stack) > 1 else 1
227
+ sub_label = _axis_label(stack[1][0]) if len(stack) > 1 else "frame"
228
 
229
  # Spatial size comes from the named Y/X axes, so it stays correct
230
  # whatever order the other axes are in.
 
237
  if not (width and height): # no Y/X named; fall back to the header probe
238
  width, height = image_size(path)
239
 
240
+ return ImageInfo(frames, channels, str(axes), guessed, tuple(shape),
241
+ width, height, sub_frames, frame_label, sub_label)
242
  except Exception: # noqa: BLE001
243
  return ImageInfo(1, 1, "", False, (), 0, 0)
244
 
 
254
  Computed from shape + dtype in the header - no pixel decode - so it is safe
255
  to call on a file that is too large to open. Returns 0 if unknown.
256
  """
257
+ if tifffile is not None:
 
258
  try:
259
  with tifffile.TiffFile(path) as tif:
260
  series = tif.series[0]
 
276
  that would be too large to load. Returns (0, 0) if the size cannot be
277
  determined, so callers treat it as "unknown" and proceed.
278
  """
279
+ if tifffile is not None:
 
280
  try:
281
  with tifffile.TiffFile(path) as tif:
282
  page = tif.pages[0]
 
304
  return arr[:, :, :3]
305
 
306
 
307
+ def _load_rgb(path, frame=0, sub_frame=None):
308
  """Read a file and reduce it to an (H, W, 3) array plus its source dtype."""
309
  raw, axes = _read_array(path)
310
+ arr = _reduce_to_hwc(raw, axes, frame=frame, sub_frame=sub_frame)
311
  arr = _to_rgb(arr)
312
  return arr, raw.dtype
313
 
 
349
  return out_path
350
 
351
 
352
+ def standardize_image(path, frame=0, sub_frame=None, out_dir=None, out_path=None):
353
  """Standardize any image/TIFF to a canonical 8-bit RGB PNG.
354
 
355
  Used for both the preview and the model: one frame, <=3 channels, with a
 
358
 
359
  Args:
360
  path: path to the input image (TIFF, PNG, JPG, ...).
361
+ frame: which frame of the first stack axis to use (0-based; ignored for
362
+ non-stacks).
363
+ sub_frame: which plane of a second stack axis (e.g. Z in a time+Z file)
364
+ to use (0-based). None means combine those planes by a maximum-
365
+ intensity projection.
366
  out_dir: optional directory for the output PNG.
367
  out_path: optional explicit output file path (overrides out_dir).
368
 
 
371
  returned unchanged so callers degrade gracefully.
372
  """
373
  try:
374
+ arr, dtype = _load_rgb(path, frame=frame, sub_frame=sub_frame)
375
  arr = _to_uint8(arr, stretch=(dtype != np.uint8))
376
  base = os.path.splitext(os.path.basename(path))[0]
377
  return _save_png(arr, out_path=out_path, out_dir=out_dir, base=base, suffix="_std.png")
app.py CHANGED
@@ -23,7 +23,7 @@ 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")
@@ -408,6 +408,8 @@ def _describe_read(info):
408
 
409
  if info.frames > 1:
410
  lines.append(f"Showing first frame by default (you can use the stack frame slider to pick another).")
 
 
411
 
412
  lines.append(
413
  "If wrong, please convert your image to a 8-bit RGB/Grayscale image file before uploading (there are available tools such as ImageJ/Fiji)."
@@ -417,26 +419,30 @@ def _describe_read(info):
417
 
418
  def prepare_uploaded_image(annot_value):
419
  """On annotator upload: standardize frame 0 for preview and configure the
420
- stack frame slider.
421
 
422
  Browsers cannot render 16-bit / float / multi-page TIFFs, so we standardize
423
  to an 8-bit RGB PNG. If the file is a stack, notify the user and reveal a
424
- frame slider (default frame 1); otherwise keep it hidden.
 
 
425
 
426
- Returns (annotator_value, raw_path, frame_slider_update).
427
  """
 
428
  img_path = _annot_path(annot_value)
429
  if not img_path:
430
- return annot_value, None, gr.update(visible=False, value=1)
431
 
432
  # Clear the annotator on refusal, so a rejected file cannot reach inference.
433
  rejected = check_image(img_path)
434
  if rejected:
435
  gr.Warning(rejected, duration=None, title="❌ Cannot use this file")
436
- return None, None, gr.update(visible=False, value=1)
437
 
438
  info = inspect_image(img_path)
439
- display = standardize_image(img_path, frame=0)
 
440
 
441
  # Stay quiet unless something non-obvious happened; a plain RGB or grayscale
442
  # image needs no explanation.
@@ -444,15 +450,19 @@ def prepare_uploaded_image(annot_value):
444
  gr.Info(_describe_read(info), duration=None, title="πŸ“š Image Loading Info")
445
 
446
  slider = (gr.update(visible=True, maximum=info.frames, value=1) if info.frames > 1
447
- else gr.update(visible=False, value=1))
448
- return display, img_path, slider
 
 
 
449
 
450
 
451
- def select_frame(raw_path, frame_num):
452
- """Re-render the annotator preview for the chosen stack frame (1-based)."""
 
453
  if not raw_path:
454
  return gr.update()
455
- return standardize_image(raw_path, frame=int(frame_num) - 1)
456
 
457
 
458
  @spaces.GPU
@@ -1470,6 +1480,11 @@ with gr.Blocks(
1470
  label="πŸ“š Stack frame (choose frame to use)",
1471
  visible=False,
1472
  )
 
 
 
 
 
1473
 
1474
  # Example Images Gallery
1475
  gr.Markdown("πŸ“ Example Image Gallery", elem_classes="frame-heading")
@@ -1497,7 +1512,7 @@ with gr.Blocks(
1497
  gr.Markdown("βž• Upload New Example Image to Gallery", elem_classes="frame-heading")
1498
  image_uploader = gr.File(
1499
  show_label=False,
1500
- file_types=["image", ".tif", ".tiff"],
1501
  type="filepath"
1502
  )
1503
  add_to_gallery_btn = gr.Button("βž• Add to Gallery", variant="secondary", size="sm")
@@ -1555,13 +1570,14 @@ with gr.Blocks(
1555
  annotator.upload(
1556
  fn=prepare_uploaded_image,
1557
  inputs=annotator,
1558
- outputs=[annotator, seg_raw_state, seg_frame_slider]
1559
- )
1560
- seg_frame_slider.release(
1561
- fn=select_frame,
1562
- inputs=[seg_raw_state, seg_frame_slider],
1563
- outputs=annotator
1564
  )
 
 
 
 
 
 
1565
 
1566
  # click event for segmentation
1567
  run_seg_btn.click(
@@ -1577,9 +1593,10 @@ with gr.Blocks(
1577
 
1578
  # click event for clear button
1579
  clear_btn.click(
1580
- fn=lambda: (None, {}, None, gr.update(visible=False, value=1)),
 
1581
  inputs=None,
1582
- outputs=[annotator, seg_vis_state, seg_raw_state, seg_frame_slider]
1583
  )
1584
 
1585
  # init Gallery with example images
@@ -1621,12 +1638,12 @@ with gr.Blocks(
1621
  if evt.index is not None and evt.index < len(all_imgs):
1622
  item = all_imgs[evt.index]
1623
  return prepare_uploaded_image(_GALLERY_RAW_SOURCE.get(item, item))
1624
- return None, None, gr.update(visible=False, value=1)
1625
 
1626
  example_gallery.select(
1627
  fn=load_from_gallery,
1628
  inputs=user_uploaded_examples,
1629
- outputs=[annotator, seg_raw_state, seg_frame_slider]
1630
  )
1631
 
1632
  # click event for submitting feedback
@@ -1704,6 +1721,11 @@ with gr.Blocks(
1704
  label="πŸ“š Stack frame (choose frame to use)",
1705
  visible=False,
1706
  )
 
 
 
 
 
1707
 
1708
  # Example gallery with "add" functionality
1709
  gr.Markdown("πŸ“ Example Image Gallery", elem_classes="frame-heading")
@@ -1734,7 +1756,7 @@ with gr.Blocks(
1734
  gr.Markdown("βž• Add Example Image to Gallery", elem_classes="frame-heading")
1735
  count_image_uploader = gr.File(
1736
  show_label=False,
1737
- file_types=["image"],
1738
  type="filepath"
1739
  )
1740
  add_to_count_gallery_btn = gr.Button("βž• Add to Gallery", variant="secondary", size="sm")
@@ -1828,13 +1850,14 @@ with gr.Blocks(
1828
  count_annotator.upload(
1829
  fn=prepare_uploaded_image,
1830
  inputs=count_annotator,
1831
- outputs=[count_annotator, count_raw_state, count_frame_slider]
1832
- )
1833
- count_frame_slider.release(
1834
- fn=select_frame,
1835
- inputs=[count_raw_state, count_frame_slider],
1836
- outputs=count_annotator
1837
  )
 
 
 
 
 
 
1838
 
1839
  # When user selects from gallery, load into annotator
1840
  def load_from_count_gallery(evt: gr.SelectData, all_imgs):
@@ -1843,12 +1866,12 @@ with gr.Blocks(
1843
  selected_img = all_imgs[evt.index]
1844
  print(f"πŸ“Έ Loading image from gallery: {selected_img}")
1845
  return prepare_uploaded_image(_GALLERY_RAW_SOURCE.get(selected_img, selected_img))
1846
- return None, None, gr.update(visible=False, value=1)
1847
 
1848
  count_example_gallery.select(
1849
  fn=load_from_count_gallery,
1850
  inputs=count_user_examples,
1851
- outputs=[count_annotator, count_raw_state, count_frame_slider]
1852
  )
1853
 
1854
  # Run counting
@@ -1865,9 +1888,10 @@ with gr.Blocks(
1865
 
1866
  # Clear selection
1867
  clear_btn.click(
1868
- fn=lambda: (None, {}, None, gr.update(visible=False, value=1)),
 
1869
  inputs=None,
1870
- outputs=[count_annotator, count_vis_state, count_raw_state, count_frame_slider]
1871
  )
1872
 
1873
  # Submit feedback
 
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, TIFF_EXTENSIONS,
27
  )
28
 
29
  HF_TOKEN = os.getenv("HF_TOKEN")
 
408
 
409
  if info.frames > 1:
410
  lines.append(f"Showing first frame by default (you can use the stack frame slider to pick another).")
411
+ if info.sub_frames > 1:
412
+ lines.append(f"Showing the first of {info.sub_frames} {info.sub_label}s by default (use the {info.sub_label} slider to pick another).")
413
 
414
  lines.append(
415
  "If wrong, please convert your image to a 8-bit RGB/Grayscale image file before uploading (there are available tools such as ImageJ/Fiji)."
 
419
 
420
  def prepare_uploaded_image(annot_value):
421
  """On annotator upload: standardize frame 0 for preview and configure the
422
+ stack sliders.
423
 
424
  Browsers cannot render 16-bit / float / multi-page TIFFs, so we standardize
425
  to an 8-bit RGB PNG. If the file is a stack, notify the user and reveal a
426
+ frame slider (default frame 1); a file with a second stack axis (e.g. Z in a
427
+ time+Z series) also gets a z-plane slider (0 = max-intensity projection).
428
+ Sliders that don't apply stay hidden.
429
 
430
+ Returns (annotator_value, raw_path, frame_slider_update, zplane_slider_update).
431
  """
432
+ hidden = gr.update(visible=False, value=1)
433
  img_path = _annot_path(annot_value)
434
  if not img_path:
435
+ return annot_value, None, hidden, hidden
436
 
437
  # Clear the annotator on refusal, so a rejected file cannot reach inference.
438
  rejected = check_image(img_path)
439
  if rejected:
440
  gr.Warning(rejected, duration=None, title="❌ Cannot use this file")
441
+ return None, None, hidden, hidden
442
 
443
  info = inspect_image(img_path)
444
+ display = standardize_image(img_path, frame=0,
445
+ sub_frame=0 if info.sub_frames > 1 else None)
446
 
447
  # Stay quiet unless something non-obvious happened; a plain RGB or grayscale
448
  # image needs no explanation.
 
450
  gr.Info(_describe_read(info), duration=None, title="πŸ“š Image Loading Info")
451
 
452
  slider = (gr.update(visible=True, maximum=info.frames, value=1) if info.frames > 1
453
+ else hidden)
454
+ zslider = (gr.update(visible=True, maximum=info.sub_frames, value=1,
455
+ label=f"πŸ”¬ {info.sub_label.capitalize()} (choose plane to use)")
456
+ if info.sub_frames > 1 else hidden)
457
+ return display, img_path, slider, zslider
458
 
459
 
460
+ def select_frame(raw_path, frame_num, zplane=1):
461
+ """Re-render the annotator preview for the chosen frame and z-plane (both
462
+ 1-based; z-plane is ignored for files with only one stack axis)."""
463
  if not raw_path:
464
  return gr.update()
465
+ return standardize_image(raw_path, frame=int(frame_num) - 1, sub_frame=int(zplane) - 1)
466
 
467
 
468
  @spaces.GPU
 
1480
  label="πŸ“š Stack frame (choose frame to use)",
1481
  visible=False,
1482
  )
1483
+ seg_zplane_slider = gr.Slider(
1484
+ minimum=1, maximum=1, step=1, value=1,
1485
+ label="πŸ”¬ Z-plane (choose plane to use)",
1486
+ visible=False,
1487
+ )
1488
 
1489
  # Example Images Gallery
1490
  gr.Markdown("πŸ“ Example Image Gallery", elem_classes="frame-heading")
 
1512
  gr.Markdown("βž• Upload New Example Image to Gallery", elem_classes="frame-heading")
1513
  image_uploader = gr.File(
1514
  show_label=False,
1515
+ file_types=["image"] + list(TIFF_EXTENSIONS),
1516
  type="filepath"
1517
  )
1518
  add_to_gallery_btn = gr.Button("βž• Add to Gallery", variant="secondary", size="sm")
 
1570
  annotator.upload(
1571
  fn=prepare_uploaded_image,
1572
  inputs=annotator,
1573
+ outputs=[annotator, seg_raw_state, seg_frame_slider, seg_zplane_slider]
 
 
 
 
 
1574
  )
1575
+ for _slider in (seg_frame_slider, seg_zplane_slider):
1576
+ _slider.release(
1577
+ fn=select_frame,
1578
+ inputs=[seg_raw_state, seg_frame_slider, seg_zplane_slider],
1579
+ outputs=annotator
1580
+ )
1581
 
1582
  # click event for segmentation
1583
  run_seg_btn.click(
 
1593
 
1594
  # click event for clear button
1595
  clear_btn.click(
1596
+ fn=lambda: (None, {}, None, gr.update(visible=False, value=1),
1597
+ gr.update(visible=False, value=1)),
1598
  inputs=None,
1599
+ outputs=[annotator, seg_vis_state, seg_raw_state, seg_frame_slider, seg_zplane_slider]
1600
  )
1601
 
1602
  # init Gallery with example images
 
1638
  if evt.index is not None and evt.index < len(all_imgs):
1639
  item = all_imgs[evt.index]
1640
  return prepare_uploaded_image(_GALLERY_RAW_SOURCE.get(item, item))
1641
+ return None, None, gr.update(visible=False, value=1), gr.update(visible=False, value=1)
1642
 
1643
  example_gallery.select(
1644
  fn=load_from_gallery,
1645
  inputs=user_uploaded_examples,
1646
+ outputs=[annotator, seg_raw_state, seg_frame_slider, seg_zplane_slider]
1647
  )
1648
 
1649
  # click event for submitting feedback
 
1721
  label="πŸ“š Stack frame (choose frame to use)",
1722
  visible=False,
1723
  )
1724
+ count_zplane_slider = gr.Slider(
1725
+ minimum=1, maximum=1, step=1, value=1,
1726
+ label="πŸ”¬ Z-plane (choose plane to use)",
1727
+ visible=False,
1728
+ )
1729
 
1730
  # Example gallery with "add" functionality
1731
  gr.Markdown("πŸ“ Example Image Gallery", elem_classes="frame-heading")
 
1756
  gr.Markdown("βž• Add Example Image to Gallery", elem_classes="frame-heading")
1757
  count_image_uploader = gr.File(
1758
  show_label=False,
1759
+ file_types=["image"] + list(TIFF_EXTENSIONS),
1760
  type="filepath"
1761
  )
1762
  add_to_count_gallery_btn = gr.Button("βž• Add to Gallery", variant="secondary", size="sm")
 
1850
  count_annotator.upload(
1851
  fn=prepare_uploaded_image,
1852
  inputs=count_annotator,
1853
+ outputs=[count_annotator, count_raw_state, count_frame_slider, count_zplane_slider]
 
 
 
 
 
1854
  )
1855
+ for _slider in (count_frame_slider, count_zplane_slider):
1856
+ _slider.release(
1857
+ fn=select_frame,
1858
+ inputs=[count_raw_state, count_frame_slider, count_zplane_slider],
1859
+ outputs=count_annotator
1860
+ )
1861
 
1862
  # When user selects from gallery, load into annotator
1863
  def load_from_count_gallery(evt: gr.SelectData, all_imgs):
 
1866
  selected_img = all_imgs[evt.index]
1867
  print(f"πŸ“Έ Loading image from gallery: {selected_img}")
1868
  return prepare_uploaded_image(_GALLERY_RAW_SOURCE.get(selected_img, selected_img))
1869
+ return None, None, gr.update(visible=False, value=1), gr.update(visible=False, value=1)
1870
 
1871
  count_example_gallery.select(
1872
  fn=load_from_count_gallery,
1873
  inputs=count_user_examples,
1874
+ outputs=[count_annotator, count_raw_state, count_frame_slider, count_zplane_slider]
1875
  )
1876
 
1877
  # Run counting
 
1888
 
1889
  # Clear selection
1890
  clear_btn.click(
1891
+ fn=lambda: (None, {}, None, gr.update(visible=False, value=1),
1892
+ gr.update(visible=False, value=1)),
1893
  inputs=None,
1894
+ outputs=[count_annotator, count_vis_state, count_raw_state, count_frame_slider, count_zplane_slider]
1895
  )
1896
 
1897
  # Submit feedback