|
| 1 | +# Custom segmentation function |
| 2 | + |
| 3 | +Not using Cellpose? Run **your own** per-tile function — no need to edit the |
| 4 | +package. You write one function; patchworks handles everything around it |
| 5 | +(tiling, halos, skipping empty tiles, the zarr-native merge, global |
| 6 | +relabelling, resume, logs). |
| 7 | + |
| 8 | +This applies the same whether you call the API directly (`tile_process`) or |
| 9 | +run via the [Snakemake cluster workflow](snakemake.md) — see [Wiring it into |
| 10 | +the cluster workflow](#wiring-it-into-the-cluster-workflow) below for the |
| 11 | +config side. |
| 12 | + |
| 13 | +## The contract |
| 14 | + |
| 15 | +Your function is called **once per tile**: |
| 16 | + |
| 17 | +```python |
| 18 | +labels = segment(tile) # plus any kwargs you configure |
| 19 | +``` |
| 20 | + |
| 21 | +| | What you get / must return | |
| 22 | +| --- | --- | |
| 23 | +| **Input `tile`** | A NumPy array of **one** tile, with the overlap halo already included. The channel and pyramid level are already selected, so it is purely spatial: `(z, y, x)` for a 3-D run, `(y, x)` for 2-D. Dtype is the image's (e.g. `uint16`). | |
| 24 | +| **Return** | An integer **label** array (not a boolean mask), **same shape** as `tile`. `0` = background; each object a distinct positive integer. | |
| 25 | +| **Labels** | Only need to be unique **within the tile**. Don't try to make them globally unique — the merge step stitches objects across tile borders and renumbers everything to a contiguous `1..N` (`sequential_labels: true`). | |
| 26 | +| **Shape** | Must match the input exactly — patchworks trims the halo off your output, so a wrong shape is an error. Don't crop or resize inside the function. | |
| 27 | + |
| 28 | +That is the whole interface. Anything that turns an image tile into a label |
| 29 | +image works: classic image processing, StarDist, a trained model, an external |
| 30 | +binary you shell out to, … |
| 31 | + |
| 32 | +## Minimal example (no GPU, no deps beyond scikit-image) |
| 33 | + |
| 34 | +```python |
| 35 | +# my_seg.py |
| 36 | +import numpy as np |
| 37 | +from skimage.measure import label |
| 38 | + |
| 39 | +def segment(tile: np.ndarray, sigma: float = 2.0) -> np.ndarray: |
| 40 | + """Threshold + connected components. Returns int32 labels (0 = bg).""" |
| 41 | + from skimage.filters import gaussian, threshold_otsu |
| 42 | + |
| 43 | + smooth = gaussian(tile, sigma=sigma, preserve_range=True) |
| 44 | + thr = threshold_otsu(smooth) if smooth.max() > smooth.min() else np.inf |
| 45 | + return label(smooth > thr).astype("int32") |
| 46 | +``` |
| 47 | + |
| 48 | +```python |
| 49 | +from patchworks import tile_process |
| 50 | +from my_seg import segment |
| 51 | + |
| 52 | +tile_process("image.zarr", segment, write_to="labels.zarr") |
| 53 | +``` |
| 54 | + |
| 55 | +## Growing labels afterwards (dilation) |
| 56 | + |
| 57 | +To grow every label by a few pixels after segmentation, wrap your function |
| 58 | +with [`patchworks.dilate_labels`](../api/postprocess.md): |
| 59 | + |
| 60 | +```python |
| 61 | +from patchworks import tile_process, dilate_labels |
| 62 | +from patchworks.plugins.dog import dog_label_fn |
| 63 | + |
| 64 | +fn = dog_label_fn(low_sigma=1.0, high_sigma=3.0, threshold=0.02) |
| 65 | +fn = dilate_labels(fn, iterations=2) # grow each label by 2 px, then run |
| 66 | +result = tile_process("image.zarr", fn, tile_shape=(1, 2048, 2048), |
| 67 | + overlap=8, write_to="labels.zarr") |
| 68 | +``` |
| 69 | + |
| 70 | +`dilate_labels` wraps any `(tile) -> labels` function — the same contract |
| 71 | +above — so it works with `dog_label_fn`, `cellpose_fn`, or your own |
| 72 | +`segment`. It dilates each tile's labels before the halo is trimmed and |
| 73 | +tiles are merged, so `overlap` must still cover the dilation amount. On the |
| 74 | +cluster, set `dilate: N` in the config instead — see [Configure the |
| 75 | +run](snakemake.md#3-configure-the-run). |
| 76 | + |
| 77 | +## Real example: StarDist 3-D, with model caching |
| 78 | + |
| 79 | +Heavy models must be loaded **once**, not per tile. On SLURM each tile is its |
| 80 | +own process so this matters less, but for local runs one process segments many |
| 81 | +tiles — cache the model at module level (or with `functools.lru_cache`): |
| 82 | + |
| 83 | +```python |
| 84 | +# stardist_seg.py |
| 85 | +import numpy as np |
| 86 | + |
| 87 | +_MODEL = None |
| 88 | + |
| 89 | +def _model(): |
| 90 | + global _MODEL |
| 91 | + if _MODEL is None: # loaded once per worker process |
| 92 | + from stardist.models import StarDist3D |
| 93 | + _MODEL = StarDist3D.from_pretrained("3D_demo") |
| 94 | + return _MODEL |
| 95 | + |
| 96 | +def segment(tile: np.ndarray, prob_thresh: float = 0.5) -> np.ndarray: |
| 97 | + from csbdeep.utils import normalize |
| 98 | + |
| 99 | + labels, _ = _model().predict_instances( |
| 100 | + normalize(tile), prob_thresh=prob_thresh |
| 101 | + ) |
| 102 | + return labels.astype("int32") |
| 103 | +``` |
| 104 | + |
| 105 | +Using a GPU? Just let your framework see it — nothing extra needed. |
| 106 | + |
| 107 | +## Test it before you submit |
| 108 | + |
| 109 | +Run your function on one real tile first — it catches shape/dtype bugs in |
| 110 | +seconds instead of after a queue wait (or a long local run). Output must be |
| 111 | +integer, same shape, `0` for background: |
| 112 | + |
| 113 | +```python |
| 114 | +from patchworks import load_ome_zarr |
| 115 | +from my_seg import segment |
| 116 | + |
| 117 | +img = load_ome_zarr("results/image.zarr", channel=0, level=0) |
| 118 | +tile = img[:, :512, :512].compute() # a small spatial block |
| 119 | +out = segment(tile) |
| 120 | + |
| 121 | +assert out.shape == tile.shape, (out.shape, tile.shape) |
| 122 | +assert out.dtype.kind in "iu" # integer labels, not a float mask |
| 123 | +print("objects in tile:", int(out.max())) |
| 124 | +``` |
| 125 | + |
| 126 | +## Wiring it into the cluster workflow |
| 127 | + |
| 128 | +Point the config at your module and function: |
| 129 | + |
| 130 | +```yaml |
| 131 | +method: "custom" |
| 132 | +label_name: "my_labels" |
| 133 | +custom: |
| 134 | + module: "my_seg" # import name |
| 135 | + function: "segment" # default is "segment" |
| 136 | + kwargs: # optional — forwarded as segment(tile, **kwargs) |
| 137 | + sigma: 1.5 |
| 138 | +``` |
| 139 | +
|
| 140 | +Same for the StarDist example above: |
| 141 | +
|
| 142 | +```yaml |
| 143 | +method: "custom" |
| 144 | +label_name: "stardist" |
| 145 | +custom: |
| 146 | + module: "stardist_seg" |
| 147 | + function: "segment" |
| 148 | + kwargs: |
| 149 | + prob_thresh: 0.5 |
| 150 | +``` |
| 151 | +
|
| 152 | +For getting the module importable on a compute node, dependency/GPU |
| 153 | +checklist, and troubleshooting, see [Custom functions on the |
| 154 | +cluster](snakemake.md#custom-segmentation-function) in the cluster guide. |
0 commit comments