| """Image standardization. |
| |
| Microscopy TIFFs come in many flavors: 16-bit or float pixels, single or |
| multi channel, and multi-page Z / time stacks. ``standardize_image`` collapses |
| any of those variants to a single canonical 8-bit RGB PNG used for BOTH the |
| on-screen preview and the model, so what you see is what gets segmented. |
| |
| Which axis means what is read from the file's own metadata (``series.axes`` |
| from tifffile: 'C'=channel, 'S'=RGB samples, 'Z'/'T'=stack, 'Y'/'X'=spatial) |
| rather than guessed from the array shape -- guessing cannot tell a 3-channel |
| (C, Y, X) image apart from a 3-slice (Z, Y, X) stack. Only when a file names |
| no axes at all (tifffile reports 'Q' = unknown) do we fall back to the dominant |
| convention: the first unknown axis of size 2-4 is the channel axis. |
| |
| Reduction policy: |
| |
| * multi-page / Z / time stacks -> one frame (default: the first) |
| * more than 3 channels -> first 3 channels (as R, G, B) |
| * intensity -> 0-255 -> 1st-99th percentile auto-contrast for |
| 16-bit/float input (outlier-robust: a hot / |
| saturated pixel would make plain min/max |
| scaling collapse the real signal to black) |
| |
| Standard 8-bit inputs (PNG/JPG/8-bit TIFF) are passed through unchanged in |
| value; only channel layout is normalized. |
| """ |
|
|
| import os |
| import tempfile |
| from typing import NamedTuple |
|
|
| import numpy as np |
| from PIL import Image |
|
|
| try: |
| import tifffile |
| except ImportError: |
| tifffile = None |
|
|
| |
| |
| RECOMMENDED_SIZE = 512 |
| WARN_SIZE = 3072 |
| MAX_SIZE = 4096 |
| |
| |
| |
| MAX_READ_BYTES = 300 * 1024 ** 2 |
|
|
| |
| _SPATIAL = ("Y", "X") |
| _CHANNEL = ("C", "S") |
| _UNKNOWN = ("Q", "I") |
| |
|
|
|
|
| def _read_array(path): |
| """Load an image file into (array, axes) where axes names each dimension. |
| |
| axes uses tifffile's convention ('C' channel, 'S' RGB samples, 'Z'/'T' |
| stack, 'Y'/'X' spatial, 'Q' unknown). Indexed / palette images (ImageJ |
| "8-bit Color", palette PNG/GIF) are expanded through their color lookup |
| table so we return true RGB, not bare indices. |
| """ |
| ext = os.path.splitext(path)[1].lower() |
| if ext in (".tif", ".tiff") and tifffile is not None: |
| with tifffile.TiffFile(path) as tif: |
| series = tif.series[0] |
| page = tif.pages[0] |
| arr = np.asarray(series.asarray()) |
| axes = str(series.axes) |
| is_palette = getattr(page, "photometric", None) == tifffile.PHOTOMETRIC.PALETTE |
| if is_palette and page.colormap is not None: |
| colormap = np.asarray(page.colormap) |
| rgb = np.moveaxis(colormap[:, arr], 0, -1) |
| |
| arr = np.round(rgb / 257.0).astype(np.uint8) |
| axes = axes + "S" |
| return arr, axes |
| |
| |
| img = Image.open(path) |
| if img.mode in ("P", "PA"): |
| img = img.convert("RGB") |
| arr = np.asarray(img) |
| return arr, ("YXS" if arr.ndim == 3 else "YX") |
|
|
|
|
| def _shape_axes(path): |
| """Return (shape, axes) from metadata only - no pixel decode.""" |
| ext = os.path.splitext(path)[1].lower() |
| if ext in (".tif", ".tiff") and tifffile is not None: |
| try: |
| with tifffile.TiffFile(path) as tif: |
| series = tif.series[0] |
| return tuple(series.shape), str(series.axes) |
| except Exception: |
| pass |
| with Image.open(path) as img: |
| w, h = img.size |
| if img.mode in ("P", "PA", "RGB", "RGBA", "CMYK", "YCbCr", "LAB", "HSV"): |
| return (h, w, len(img.getbands())), "YXS" |
| return (h, w), "YX" |
|
|
|
|
| def _plan_axes(shape, axes): |
| """Drop size-1 axes, infer a channel axis when the file names none, and move |
| it last. |
| |
| Returns (shape, axes, guessed) as lists + a flag saying whether the channel |
| axis had to be inferred rather than read from the file. |
| """ |
| axes = list(axes) |
| shape = list(shape) |
| if len(axes) != len(shape): |
| axes = ["Q"] * (len(shape) - 2) + ["Y", "X"] |
|
|
| pairs = [(a, d) for a, d in zip(axes, shape) if d != 1] |
| axes = [a for a, _ in pairs] |
| shape = [d for _, d in pairs] |
|
|
| guessed = False |
| if not any(a in _CHANNEL for a in axes): |
| |
| |
| for i, (a, d) in enumerate(zip(axes, shape)): |
| if a in _UNKNOWN and d in (2, 3, 4): |
| axes[i] = "C" |
| guessed = True |
| break |
|
|
| ci = next((i for i, a in enumerate(axes) if a in _CHANNEL), None) |
| if ci is not None: |
| axes.append(axes.pop(ci)) |
| shape.append(shape.pop(ci)) |
| return shape, axes, guessed |
|
|
|
|
| def _reduce_to_hwc(arr, axes, frame=0): |
| """Collapse a labelled array to 2D (H, W) or 3D (H, W, C). |
| |
| The channel axis (named by the file, or inferred by ``_plan_axes`` only when |
| the file names none) is moved last and kept whole. The remaining stack axes |
| (Z / time / page) are indexed: the first by ``frame``, any deeper ones by 0. |
| """ |
| axes = list(axes) |
|
|
| |
| for i in range(len(axes) - 1, -1, -1): |
| if arr.shape[i] == 1: |
| arr = arr.reshape(arr.shape[:i] + arr.shape[i + 1:]) |
| axes.pop(i) |
|
|
| _, planned, _ = _plan_axes(arr.shape, axes) |
|
|
| |
| if "C" in planned and not any(a in _CHANNEL for a in axes): |
| for i, (a, d) in enumerate(zip(axes, arr.shape)): |
| if a in _UNKNOWN and d in (2, 3, 4): |
| axes[i] = "C" |
| break |
| ci = next((i for i, a in enumerate(axes) if a in _CHANNEL), None) |
| if ci is not None: |
| arr = np.moveaxis(arr, ci, -1) |
| axes.append(axes.pop(ci)) |
|
|
| |
| target = 3 if ci is not None else 2 |
| first = True |
| while len(axes) > target: |
| idx = int(frame) if first else 0 |
| idx = max(0, min(idx, arr.shape[0] - 1)) |
| arr = arr[idx] |
| axes.pop(0) |
| first = False |
|
|
| return arr |
|
|
|
|
| class ImageInfo(NamedTuple): |
| """How a file's dimensions were interpreted (read from metadata only). |
| |
| frames - length of the first stack (Z/T) axis, 1 if not a stack |
| channels - length of the channel axis, 1 if single-channel |
| axes - the file's own axes string, e.g. 'CZYX' ('Q' = unnamed) |
| guessed - True if the channel axis was inferred rather than read |
| shape - the file's raw shape as stored, e.g. (1, 4, 1, 1024, 1024) |
| width - pixels along the X axis (0 if unknown) |
| height - pixels along the Y axis (0 if unknown) |
| """ |
| frames: int |
| channels: int |
| axes: str |
| guessed: bool |
| shape: tuple |
| width: int |
| height: int |
|
|
|
|
| def inspect_image(path): |
| """Describe a file's structure from metadata only (no pixel decode).""" |
| try: |
| shape, axes = _shape_axes(path) |
| planned_shape, planned_axes, guessed = _plan_axes(shape, axes) |
| has_c = bool(planned_axes) and planned_axes[-1] in _CHANNEL |
| channels = int(planned_shape[-1]) if has_c else 1 |
| target = 3 if has_c else 2 |
| frames = int(planned_shape[0]) if len(planned_axes) > target else 1 |
|
|
| |
| |
| width = height = 0 |
| for a, d in zip(axes, shape): |
| if a == "Y": |
| height = int(d) |
| elif a == "X": |
| width = int(d) |
| if not (width and height): |
| width, height = image_size(path) |
|
|
| return ImageInfo(frames, channels, str(axes), guessed, tuple(shape), width, height) |
| except Exception: |
| return ImageInfo(1, 1, "", False, (), 0, 0) |
|
|
|
|
| def count_frames(path): |
| """Return how many frames a file's stack (Z/T) axis has (1 if not a stack).""" |
| return inspect_image(path).frames |
|
|
|
|
| def array_nbytes(path): |
| """Bytes that opening this file's full array would allocate. |
| |
| Computed from shape + dtype in the header - no pixel decode - so it is safe |
| to call on a file that is too large to open. Returns 0 if unknown. |
| """ |
| ext = os.path.splitext(path)[1].lower() |
| if ext in (".tif", ".tiff") and tifffile is not None: |
| try: |
| with tifffile.TiffFile(path) as tif: |
| series = tif.series[0] |
| return int(np.prod(series.shape)) * int(np.dtype(series.dtype).itemsize) |
| except Exception: |
| pass |
| try: |
| with Image.open(path) as img: |
| w, h = img.size |
| return int(w) * int(h) * len(img.getbands()) |
| except Exception: |
| return 0 |
|
|
|
|
| def image_size(path): |
| """Return an image's (width, height) by reading only its header. |
| |
| Never decodes pixel data, so this is safe to call as a size guard on a file |
| that would be too large to load. Returns (0, 0) if the size cannot be |
| determined, so callers treat it as "unknown" and proceed. |
| """ |
| ext = os.path.splitext(path)[1].lower() |
| if ext in (".tif", ".tiff") and tifffile is not None: |
| try: |
| with tifffile.TiffFile(path) as tif: |
| page = tif.pages[0] |
| return int(page.imagewidth), int(page.imagelength) |
| except Exception: |
| pass |
| try: |
| with Image.open(path) as img: |
| return int(img.size[0]), int(img.size[1]) |
| except Exception: |
| return 0, 0 |
|
|
|
|
| def _to_rgb(arr): |
| """Turn a 2D or (H, W, C) array into exactly 3 channels.""" |
| if arr.ndim == 2: |
| return np.stack([arr] * 3, axis=-1) |
|
|
| channels = arr.shape[2] |
| if channels == 1: |
| return np.repeat(arr, 3, axis=2) |
| if channels == 2: |
| |
| return np.concatenate([arr, np.zeros_like(arr[:, :, :1])], axis=2) |
| return arr[:, :, :3] |
|
|
|
|
| def _load_rgb(path, frame=0): |
| """Read a file and reduce it to an (H, W, 3) array plus its source dtype.""" |
| raw, axes = _read_array(path) |
| arr = _reduce_to_hwc(raw, axes, frame=frame) |
| arr = _to_rgb(arr) |
| return arr, raw.dtype |
|
|
|
|
| def _to_uint8(arr, stretch): |
| """Map pixel values to uint8. |
| |
| When ``stretch`` is True (non-8-bit input) a 1st-99th percentile auto- |
| contrast stretch is applied - outlier-robust, so a hot/saturated pixel does |
| not collapse the visible signal to black. Otherwise values are only clipped, |
| so standard 8-bit images are unchanged. |
| """ |
| arr = arr.astype(np.float32) |
|
|
| if not stretch: |
| return np.clip(arr, 0, 255).astype(np.uint8) |
|
|
| lo, hi = np.percentile(arr, (1, 99)) |
| if hi <= lo: |
| lo, hi = float(arr.min()), float(arr.max()) |
| if hi <= lo: |
| return np.zeros(arr.shape, dtype=np.uint8) |
|
|
| arr = np.clip((arr - lo) / (hi - lo), 0.0, 1.0) |
| return (arr * 255.0).astype(np.uint8) |
|
|
|
|
| def _save_png(arr, out_path=None, out_dir=None, base=None, suffix="_std.png"): |
| """Save an (H, W, 3) uint8 array as a PNG and return the path.""" |
| if out_path is None: |
| if out_dir is not None: |
| os.makedirs(out_dir, exist_ok=True) |
| out_path = os.path.join(out_dir, (base or "image") + suffix) |
| else: |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) |
| out_path = tmp.name |
| tmp.close() |
| Image.fromarray(arr, mode="RGB").save(out_path) |
| return out_path |
|
|
|
|
| def standardize_image(path, frame=0, out_dir=None, out_path=None): |
| """Standardize any image/TIFF to a canonical 8-bit RGB PNG. |
| |
| Used for both the preview and the model: one frame, <=3 channels, with a |
| 1st-99th percentile auto-contrast stretch for 16-bit/float input (8-bit is |
| passed through unchanged). |
| |
| Args: |
| path: path to the input image (TIFF, PNG, JPG, ...). |
| frame: which frame of a stack to use (0-based; ignored for non-stacks). |
| out_dir: optional directory for the output PNG. |
| out_path: optional explicit output file path (overrides out_dir). |
| |
| Returns: |
| Path to the standardized PNG. On any failure the original ``path`` is |
| returned unchanged so callers degrade gracefully. |
| """ |
| try: |
| arr, dtype = _load_rgb(path, frame=frame) |
| arr = _to_uint8(arr, stretch=(dtype != np.uint8)) |
| base = os.path.splitext(os.path.basename(path))[0] |
| return _save_png(arr, out_path=out_path, out_dir=out_dir, base=base, suffix="_std.png") |
| except Exception as e: |
| print(f"⚠️ standardize_image failed for {path}: {e}; using original file") |
| return path |
|
|