VisionLanguageGroup commited on
Commit
5c4d9d8
·
1 Parent(s): 7b5645e

fix bug and clean up

Browse files
Files changed (2) hide show
  1. _utils/image_io.py +106 -8
  2. app.py +45 -12
_utils/image_io.py CHANGED
@@ -7,6 +7,7 @@ for both the preview and the model.
7
 
8
  import os
9
  import tempfile
 
10
  from typing import NamedTuple
11
 
12
  import numpy as np
@@ -22,6 +23,15 @@ except ImportError: # pragma: no cover - tifffile is a project dependency
22
  RECOMMENDED_SIZE = 512
23
  WARN_SIZE = 3072
24
  MAX_SIZE = 4096
 
 
 
 
 
 
 
 
 
25
  # Measured on the uncompressed array, not the file size on disk: compression
26
  # makes disk size a poor proxy for what opening the file actually allocates.
27
  MAX_READ_BYTES = 300 * 1024 ** 2
@@ -33,14 +43,54 @@ _UNKNOWN = ("Q", "I") # file named no axis; only these may be *guessed* as
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())
@@ -82,7 +132,7 @@ 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]
87
  return tuple(series.shape), str(series.axes)
88
  except Exception: # noqa: BLE001 - fall through to PIL
@@ -243,9 +293,57 @@ def inspect_image(path):
243
  return ImageInfo(1, 1, "", False, (), 0, 0)
244
 
245
 
246
- def count_frames(path):
247
- """Return how many frames a file's stack (Z/T) axis has (1 if not a stack)."""
248
- return inspect_image(path).frames
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
 
250
 
251
  def array_nbytes(path):
@@ -256,7 +354,7 @@ def array_nbytes(path):
256
  """
257
  if tifffile is not None:
258
  try:
259
- with tifffile.TiffFile(path) as tif:
260
  series = tif.series[0]
261
  return int(np.prod(series.shape)) * int(np.dtype(series.dtype).itemsize)
262
  except Exception: # noqa: BLE001 - fall through to PIL
 
7
 
8
  import os
9
  import tempfile
10
+ from contextlib import contextmanager
11
  from typing import NamedTuple
12
 
13
  import numpy as np
 
23
  RECOMMENDED_SIZE = 512
24
  WARN_SIZE = 3072
25
  MAX_SIZE = 4096
26
+ # Warn below this: a shorter side under 32px means >16x upscaling to 512, so
27
+ # there is almost no real detail for the model to work with.
28
+ MIN_SIZE = 32
29
+ # An integer image is a low-dynamic-range candidate when its full span is tiny
30
+ # relative to the dtype's max. Float has no fixed range, so it is skipped.
31
+ NARROW_RANGE_FRAC = 0.02
32
+ # Sparse fluorescence can have a narrow span but still be valid; only flag it
33
+ # when the max is not meaningfully above the 99th percentile.
34
+ BRIGHT_TAIL_FRAC = 0.1
35
  # Measured on the uncompressed array, not the file size on disk: compression
36
  # makes disk size a poor proxy for what opening the file actually allocates.
37
  MAX_READ_BYTES = 300 * 1024 ** 2
 
43
 
44
 
45
  TIFF_EXTENSIONS = (
46
+ sorted(
47
+ {".ome.tif", ".ome.tiff"}
48
+ | {"." + e for e in tifffile.TIFF.FILE_EXTENSIONS if "." not in e}
49
+ )
50
+ if tifffile is not None else [".ome.tif", ".ome.tiff", ".tif", ".tiff"]
51
  )
52
 
53
 
54
+ def _series_planes(series):
55
+ """Number of 2D planes in a series: the product of every axis that is not
56
+ spatial (Y/X) or RGB samples (S). Axes-aware on purpose - a positional
57
+ shape[:-2] would miscount RGB, which tifffile stores as a trailing S axis."""
58
+ planes = int(np.prod([
59
+ dim for axis, dim in zip(str(series.axes), series.shape)
60
+ if axis not in ("Y", "X", "S")
61
+ ]))
62
+ return planes or 1
63
+
64
+
65
+ def _ome_needs_single_file(tif):
66
+ """True for a multi-file OME-TIFF whose logical series spans more planes than
67
+ this file holds: its data lives in sibling files that are not part of a
68
+ single upload, so the assembled series would zero-fill the out-of-file
69
+ planes. A self-contained OME has all its planes in-file (planes == pages)."""
70
+ try:
71
+ return bool(tif.is_ome) and _series_planes(tif.series[0]) > len(tif.pages)
72
+ except Exception: # noqa: BLE001 - detection must never break the read
73
+ return False
74
+
75
+
76
+ @contextmanager
77
+ def _open_tiff(path):
78
+ """Open a TIFF; for a multi-file OME set whose series spans beyond this file,
79
+ reopen just this file's own pages (is_ome=False) so out-of-file planes are
80
+ not zero-filled. Detection is lazy (shape/pages only, no pixel decode)."""
81
+ tif = tifffile.TiffFile(path)
82
+ try:
83
+ if _ome_needs_single_file(tif):
84
+ tif.close()
85
+ tif = tifffile.TiffFile(path, is_ome=False)
86
+ yield tif
87
+ finally:
88
+ tif.close()
89
+
90
+
91
  def _read_tiff(path):
92
  """Read a TIFF-family file into (array, axes). Raises if not readable."""
93
+ with _open_tiff(path) as tif:
94
  series = tif.series[0]
95
  page = tif.pages[0]
96
  arr = np.asarray(series.asarray())
 
132
  """Return (shape, axes) from metadata only - no pixel decode."""
133
  if tifffile is not None:
134
  try:
135
+ with _open_tiff(path) as tif:
136
  series = tif.series[0]
137
  return tuple(series.shape), str(series.axes)
138
  except Exception: # noqa: BLE001 - fall through to PIL
 
293
  return ImageInfo(1, 1, "", False, (), 0, 0)
294
 
295
 
296
+ class PixelReport(NamedTuple):
297
+ """Pixel-level sanity checks on decoded image data (see ``pixel_stats``).
298
+
299
+ decoded - False if the pixels could not be read (corrupt / truncated file)
300
+ finite - False if the frame contains NaN/inf (only checked for float data)
301
+ vmin/vmax - min and max pixel value of the frame
302
+ dtype_max - np.iinfo(dtype).max for integer data, else 0.0
303
+ low_range - True if an integer frame's robust span is a tiny fraction of the
304
+ dtype range (low contrast / narrow dynamic range)
305
+ """
306
+ decoded: bool
307
+ finite: bool
308
+ vmin: float
309
+ vmax: float
310
+ dtype_max: float
311
+ low_range: bool
312
+
313
+
314
+ def pixel_stats(path, frame=0, sub_frame=None):
315
+ """Decode image pixels and report problems (blank / non-finite / corrupt /
316
+ low dynamic range).
317
+
318
+ Works on the raw reduced frame, not the RGB-padded one, so a 2-channel
319
+ image's zero-padded third channel does not skew the min/max/finite stats.
320
+ Only call once the header size guard has passed, so decoding is bounded.
321
+ """
322
+ try:
323
+ raw, axes = _read_array(path)
324
+ arr = _reduce_to_hwc(raw, axes, frame=frame, sub_frame=sub_frame)
325
+ except Exception: # noqa: BLE001 - unreadable pixels
326
+ return PixelReport(False, True, 0.0, 0.0, 0.0, False)
327
+
328
+ if np.issubdtype(arr.dtype, np.floating) and not np.isfinite(arr).all():
329
+ return PixelReport(True, False, 0.0, 0.0, 0.0, False)
330
+
331
+ vmin, vmax = float(arr.min()), float(arr.max())
332
+ is_int = np.issubdtype(arr.dtype, np.integer)
333
+ dtype_max = float(np.iinfo(arr.dtype).max) if is_int else 0.0
334
+ low_range = False
335
+ if is_int and vmax > vmin and dtype_max > 0:
336
+ narrow = (vmax - vmin) < NARROW_RANGE_FRAC * dtype_max
337
+ if narrow:
338
+ # Among narrow images, spare fluorescence (dark background + sparse
339
+ # bright objects) by checking for a bright tail: its max sits far
340
+ # above the 99th percentile. A dense narrow band (values packed in a
341
+ # thin range) has none, so only that is flagged as low dynamic range.
342
+ p99 = float(np.percentile(arr, 99))
343
+ bright_tail = (vmax - p99) / (vmax - vmin)
344
+ low_range = bright_tail < BRIGHT_TAIL_FRAC
345
+ return PixelReport(True, True, vmin, vmax, dtype_max, low_range)
346
+
347
 
348
 
349
  def array_nbytes(path):
 
354
  """
355
  if tifffile is not None:
356
  try:
357
+ with _open_tiff(path) as tif:
358
  series = tif.series[0]
359
  return int(np.prod(series.shape)) * int(np.dtype(series.dtype).itemsize)
360
  except Exception: # noqa: BLE001 - fall through to PIL
app.py CHANGED
@@ -22,8 +22,8 @@ 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, TIFF_EXTENSIONS,
27
  )
28
 
29
  HF_TOKEN = os.getenv("HF_TOKEN")
@@ -345,22 +345,28 @@ def _human_bytes(n):
345
 
346
 
347
  def check_image(img_path):
348
- """Check a file from its header only (no pixel decode).
349
-
350
- Returns a rejection message if the file cannot be used, otherwise None:
351
- * unreadable / unsupported format
352
- * dimensions over MAX_SIZE
353
- * full array over MAX_READ_BYTES (a small time-lapse can still be huge)
354
- Also emits an advisory warning over WARN_SIZE, since the model resizes every
355
- input to RECOMMENDED_SIZE and a larger image only loses detail.
 
 
 
356
  """
 
 
 
357
  w, h = image_size(img_path)
358
  if w <= 0 or h <= 0:
359
  # Neither reader could parse the header, so say so rather than failing
360
  # silently further down.
361
  ext = os.path.splitext(img_path)[1].lower() or "(no extension)"
362
  return (f"Could not read {ext} as an image. Supported formats: "
363
- f"TIFF / OME-TIFF (8/16/32-bit, stacks, multi-channel), PNG, JPG, and other formats suported by tiffile. "
364
  f"Please export to TIFF/PNG/JPG (e.g. from ImageJ/Fiji) first.")
365
 
366
  if w > MAX_SIZE or h > MAX_SIZE:
@@ -383,8 +389,35 @@ def check_image(img_path):
383
  gr.Warning(f"Image is {w}×{h} and will be resized to "
384
  f"{RECOMMENDED_SIZE}×{RECOMMENDED_SIZE}, so fine detail could be lost. "
385
  f"You can try cropping the image for better results.",
386
- # f"For best results, try cropping the image to focus on the region of interest.",
387
  duration=None, title="⚠️ Large image")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  return None
389
 
390
 
 
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, pixel_stats,
26
+ RECOMMENDED_SIZE, WARN_SIZE, MAX_SIZE, MIN_SIZE, MAX_READ_BYTES, TIFF_EXTENSIONS,
27
  )
28
 
29
  HF_TOKEN = os.getenv("HF_TOKEN")
 
345
 
346
 
347
  def check_image(img_path):
348
+ """Validate an uploaded file, cheapest checks first.
349
+
350
+ Returns a rejection message if the file cannot be used, otherwise None.
351
+ Reject: empty file; unreadable / unsupported format; dimensions over
352
+ MAX_SIZE; full array over MAX_READ_BYTES (a small time-lapse can still be
353
+ huge); corrupt pixels; non-finite (NaN/inf) pixels. Advisory ``gr.Warning``
354
+ (does not reject): larger than WARN_SIZE; smaller than MIN_SIZE; blank
355
+ (uniform) image; very narrow dynamic range.
356
+
357
+ Only the header is touched until the size guards pass; pixel checks decode
358
+ image data afterwards, when doing so is bounded by the memory guard.
359
  """
360
+ if os.path.exists(img_path) and os.path.getsize(img_path) == 0:
361
+ return "This file is empty (0 bytes). Please upload a valid image file."
362
+
363
  w, h = image_size(img_path)
364
  if w <= 0 or h <= 0:
365
  # Neither reader could parse the header, so say so rather than failing
366
  # silently further down.
367
  ext = os.path.splitext(img_path)[1].lower() or "(no extension)"
368
  return (f"Could not read {ext} as an image. Supported formats: "
369
+ f"TIFF / OME-TIFF (8/16/32-bit, stacks, multi-channel), PNG, JPG, and other formats supported by tifffile. "
370
  f"Please export to TIFF/PNG/JPG (e.g. from ImageJ/Fiji) first.")
371
 
372
  if w > MAX_SIZE or h > MAX_SIZE:
 
389
  gr.Warning(f"Image is {w}×{h} and will be resized to "
390
  f"{RECOMMENDED_SIZE}×{RECOMMENDED_SIZE}, so fine detail could be lost. "
391
  f"You can try cropping the image for better results.",
 
392
  duration=None, title="⚠️ Large image")
393
+
394
+ if 0 < min(w, h) < MIN_SIZE:
395
+ gr.Warning(f"Image is only {w}×{h} and will be upscaled to "
396
+ f"{RECOMMENDED_SIZE}×{RECOMMENDED_SIZE}, so results may be unreliable. "
397
+ f"A larger image may work better.",
398
+ duration=None, title="⚠️ Very small image")
399
+
400
+ # Size guards passed, so pixel decoding is bounded. Check problems that
401
+ # the header cannot reveal.
402
+ rep = pixel_stats(img_path)
403
+ if not rep.decoded:
404
+ return ("This image appears to be corrupted or truncated and could not be read. "
405
+ "Please re-export it (e.g. from ImageJ/Fiji) and try again.")
406
+ if not rep.finite:
407
+ return ("This image contains invalid pixel values (NaN or infinity). "
408
+ "Please clean or re-export it before uploading.")
409
+ if rep.vmin == rep.vmax:
410
+ if rep.vmax == 0:
411
+ kind = "completely black"
412
+ elif rep.dtype_max and rep.vmax >= rep.dtype_max:
413
+ kind = "completely white"
414
+ else:
415
+ kind = "a single uniform value"
416
+ gr.Warning(f"This image is {kind}, it has no visible content, so results will not be meaningful.",
417
+ duration=None, title="⚠️ Blank image")
418
+ elif rep.low_range:
419
+ gr.Warning("This image may use a very narrow intensity range (low contrast). Signal may be too weak for reliable results; consider adjusting acquisition or contrast before uploading.",
420
+ duration=None, title="⚠️ Low dynamic range")
421
  return None
422
 
423