Spaces:
Running on Zero
Running on Zero
Commit Β·
cd351de
1
Parent(s): b51b5c2
add input standardization
Browse files- _utils/image_io.py +358 -0
- 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 = "
|
| 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 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 1159 |
),
|
| 1160 |
css=CSS,
|
| 1161 |
) as demo:
|
|
@@ -1180,11 +1407,18 @@ with gr.Blocks(
|
|
| 1180 |
)
|
| 1181 |
|
| 1182 |
# ε
¨ε±ηΆζ
|
| 1183 |
-
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
| 1202 |
"""
|
| 1203 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1204 |
|
| 1205 |
with gr.Row():
|
| 1206 |
with gr.Column(scale=1):
|
|
|
|
| 1207 |
annotator = BBoxAnnotator(
|
| 1208 |
-
|
| 1209 |
categories=["cell"],
|
| 1210 |
)
|
| 1211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1212 |
# Example Images Gallery
|
|
|
|
| 1213 |
example_gallery = gr.Gallery(
|
| 1214 |
-
|
| 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 |
-
|
| 1235 |
-
|
|
|
|
|
|
|
| 1236 |
type="filepath"
|
| 1237 |
)
|
|
|
|
|
|
|
| 1238 |
|
| 1239 |
|
| 1240 |
with gr.Column(scale=2):
|
|
|
|
| 1241 |
seg_output = gr.Image(
|
| 1242 |
type="pil",
|
| 1243 |
-
|
| 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 |
-
|
| 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 |
-
#
|
|
|
|
| 1312 |
def add_to_gallery(img_path, current_imgs):
|
| 1313 |
if not img_path:
|
| 1314 |
-
return current_imgs
|
| 1315 |
-
|
| 1316 |
-
|
| 1317 |
-
|
| 1318 |
-
return current_imgs
|
| 1319 |
-
|
| 1320 |
-
|
| 1321 |
-
|
| 1322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1336 |
-
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
| 1387 |
"""
|
| 1388 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1389 |
|
| 1390 |
with gr.Row():
|
| 1391 |
with gr.Column(scale=1):
|
|
|
|
| 1392 |
count_annotator = BBoxAnnotator(
|
| 1393 |
-
|
| 1394 |
categories=["cell"],
|
| 1395 |
)
|
| 1396 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1397 |
# Example gallery with "add" functionality
|
|
|
|
| 1398 |
with gr.Row():
|
| 1399 |
count_example_gallery = gr.Gallery(
|
| 1400 |
-
|
| 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 |
-
|
| 1423 |
-
|
| 1424 |
-
|
| 1425 |
-
|
| 1426 |
-
|
| 1427 |
-
|
|
|
|
|
|
|
| 1428 |
|
| 1429 |
|
| 1430 |
with gr.Column(scale=2):
|
|
|
|
| 1431 |
count_output = gr.Image(
|
| 1432 |
-
|
| 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 |
-
|
| 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 |
-
#
|
|
|
|
| 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 |
-
|
| 1488 |
-
|
| 1489 |
-
|
| 1490 |
-
|
| 1491 |
-
|
| 1492 |
-
|
| 1493 |
-
|
| 1494 |
-
|
| 1495 |
-
|
| 1496 |
-
|
| 1497 |
-
|
| 1498 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
π€
|
| 1581 |
|
| 1582 |
"""
|
| 1583 |
)
|
| 1584 |
|
| 1585 |
with gr.Row():
|
| 1586 |
with gr.Column(scale=1):
|
|
|
|
| 1587 |
track_zip_upload = gr.File(
|
| 1588 |
-
|
| 1589 |
file_types=[".zip"]
|
| 1590 |
)
|
| 1591 |
|
| 1592 |
-
# First frame annotation for bounding box
|
| 1593 |
-
|
| 1594 |
-
|
| 1595 |
-
|
| 1596 |
-
|
| 1597 |
-
|
|
|
|
|
|
|
| 1598 |
|
| 1599 |
# Example ZIP gallery
|
|
|
|
| 1600 |
track_example_gallery = gr.Gallery(
|
| 1601 |
-
|
| 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 |
-
|
| 1623 |
file_types=[".zip"],
|
| 1624 |
type="filepath"
|
| 1625 |
)
|
| 1626 |
|
| 1627 |
with gr.Column(scale=2):
|
|
|
|
| 1628 |
track_first_frame_preview = gr.Image(
|
| 1629 |
-
|
| 1630 |
type="filepath",
|
| 1631 |
# height=400,
|
| 1632 |
elem_classes="uniform-height",
|
|
@@ -1646,10 +1981,12 @@ with gr.Blocks(
|
|
| 1646 |
interactive=False
|
| 1647 |
)
|
| 1648 |
|
| 1649 |
-
|
| 1650 |
-
|
| 1651 |
-
|
| 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,
|
| 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,
|
| 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 |
)
|