Spaces:
Running on Zero
Running on Zero
Commit ·
5c4d9d8
1
Parent(s): 7b5645e
fix bug and clean up
Browse files- _utils/image_io.py +106 -8
- 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(
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
| 38 |
)
|
| 39 |
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
def _read_tiff(path):
|
| 42 |
"""Read a TIFF-family file into (array, axes). Raises if not readable."""
|
| 43 |
-
with
|
| 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
|
| 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 |
-
|
| 247 |
-
"""
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
"""
|
| 349 |
-
|
| 350 |
-
Returns a rejection message if the file cannot be used, otherwise None
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
|