Add tested tiled 3D probability Zarr export
Browse files- README.md +18 -2
- hecate.py +74 -29
- requirements.txt +1 -0
README.md
CHANGED
|
@@ -117,9 +117,25 @@ The script selects the central input depth, slides over XY with half-patch overl
|
|
| 117 |
|
| 118 |
The default is float32 with batch size one. On compatible NVIDIA GPUs, `--precision bf16` enables the mixed precision used for production inference; increase `--batch-size` if memory permits. `--device cpu` also works, but this remains a large 3D network. Temporary disk-backed accumulators use approximately nine bytes per output pixel; set `TMPDIR` to a disk with enough free space for large surfaces.
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
### Accessing the 3D output
|
| 121 |
|
| 122 |
-
The same file provides a small Python API. `model(image)` returns 2D logits; `model.forward_3d(image)` returns 3D logits without executing the learned projection. The CLI
|
| 123 |
|
| 124 |
```python
|
| 125 |
import numpy as np
|
|
@@ -138,7 +154,7 @@ np.save("ink_3d.npy", ink_3d)
|
|
| 138 |
|
| 139 |
The 2.4 µm model excludes its first and last input planes from the network and pads their 3D logits with −20; these planes are not valid learned predictions. Its interior 3D logits are interpolated to the input grid. The 9.6 µm model uses all sixteen planes. The Python API returns model predictions without applying an additional CT-background threshold.
|
| 140 |
|
| 141 |
-
The standalone runtime was checked against the original inference implementation on real CT crops with both released checkpoints: 2D and 3D patch logits, tiled forward/reverse predictions, padded edges, blank inputs,
|
| 142 |
|
| 143 |
## Sources and acknowledgements
|
| 144 |
|
|
|
|
| 117 |
|
| 118 |
The default is float32 with batch size one. On compatible NVIDIA GPUs, `--precision bf16` enables the mixed precision used for production inference; increase `--batch-size` if memory permits. `--device cpu` also works, but this remains a large 3D network. Temporary disk-backed accumulators use approximately nine bytes per output pixel; set `TMPDIR` to a disk with enough free space for large surfaces.
|
| 119 |
|
| 120 |
+
### Saving a 3D probability volume
|
| 121 |
+
|
| 122 |
+
Use `--output-3d` to save a compressed uint8 Zarr:
|
| 123 |
+
|
| 124 |
+
```bash
|
| 125 |
+
python hecate.py --checkpoint hecate_9.6um.pth --input surface.zarr --spacing-um 9.6 --output-3d ink_3d.zarr --reverse --device cuda
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
Add `--output prediction.png` to also save the 2D map. When only `--output-3d` is requested, attention and the 2D projection are not executed. Requesting both outputs runs two inference passes to keep the implementation and memory use simple.
|
| 129 |
+
|
| 130 |
+
The 3D Zarr is a **single-resolution array**, not a multiscale OME-Zarr. It stores `round(255 × probability)` with lossless Zstandard compression. Its ZYX shape and voxel order match the input, including after reversed inference: predictions are flipped back before saving. No cropping or per-volume min–max scaling is applied.
|
| 131 |
+
|
| 132 |
+
Inference tiles over XY and evaluates only the checkpoint's **central depth window**, not sliding windows through the entire Z axis. Unevaluated planes, including the finer model's excluded edge planes, are zero. The Zarr attributes record the half-open `evaluated_z_interval`, spacing, orientation, and inference settings so those zeros can be distinguished from evaluated predictions. Empty CT columns are zeroed; no additional voxel-intensity threshold is applied. Source world-coordinate transforms are not copied; the output is aligned to the input's voxel indices.
|
| 133 |
+
|
| 134 |
+
Temporary disk use for 3D blending is approximately `(4 × input_patch_depth + 4) × height × width` bytes, in addition to the compressed output. Set `TMPDIR` appropriately for large renders.
|
| 135 |
+
|
| 136 |
### Accessing the 3D output
|
| 137 |
|
| 138 |
+
The same file provides a small Python API. `model(image)` returns 2D logits; `model.forward_3d(image)` returns 3D logits without executing the learned projection. The CLI can write either output or both.
|
| 139 |
|
| 140 |
```python
|
| 141 |
import numpy as np
|
|
|
|
| 154 |
|
| 155 |
The 2.4 µm model excludes its first and last input planes from the network and pads their 3D logits with −20; these planes are not valid learned predictions. Its interior 3D logits are interpolated to the input grid. The 9.6 µm model uses all sixteen planes. The Python API returns model predictions without applying an additional CT-background threshold.
|
| 156 |
|
| 157 |
+
The standalone runtime was checked against the original inference implementation on real CT crops with both released checkpoints: 2D and 3D patch logits, tiled forward/reverse predictions, padded edges, blank inputs, lossless PNG decoding, and compressed 3D Zarr round trips. Tiled 3D exports matched the original model in both depth directions, and checks confirmed that 3D-only inference bypasses attention. Float32 and CUDA bfloat16 were tested. The checkpoints load strictly, including their saved EMA batch-normalization buffers.
|
| 158 |
|
| 159 |
## Sources and acknowledgements
|
| 160 |
|
hecate.py
CHANGED
|
@@ -302,8 +302,8 @@ def open_volume(path, array='0'):
|
|
| 302 |
|
| 303 |
|
| 304 |
@torch.inference_mode()
|
| 305 |
-
def predict(model, volume, output, reverse=False, stride=None, batch_size=1, precision='fp32'):
|
| 306 |
-
"""Tiled 2D uint8 probabilities into a writable HxW array (can be a memmap).
|
| 307 |
|
| 308 |
Reads only individual CT patches. Accumulators use temporary disk-backed
|
| 309 |
arrays, so a full surface volume never needs to fit in RAM or GPU memory.
|
|
@@ -313,8 +313,11 @@ def predict(model, volume, output, reverse=False, stride=None, batch_size=1, pre
|
|
| 313 |
stride = p//2 if stride is None else stride
|
| 314 |
if depth < d or not 0 < stride <= p or batch_size < 1:
|
| 315 |
raise ValueError('Insufficient depth, invalid stride, or invalid batch size')
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
| 318 |
device = next(model.parameters()).device
|
| 319 |
if precision not in ('fp32', 'bf16') or (precision == 'bf16' and device.type != 'cuda'):
|
| 320 |
raise ValueError('bf16 requires CUDA; otherwise use fp32')
|
|
@@ -327,7 +330,7 @@ def predict(model, volume, output, reverse=False, stride=None, batch_size=1, pre
|
|
| 327 |
win = win.to(device)
|
| 328 |
origins = [(y, x) for y in axis_starts(h,p,stride) for x in axis_starts(w,p,stride)]
|
| 329 |
with tempfile.TemporaryDirectory(prefix='hecate-') as tmp:
|
| 330 |
-
numerator = np.memmap(Path(tmp)/'sum', mode='w+', dtype='float32', shape=(h,w))
|
| 331 |
denominator = np.memmap(Path(tmp)/'weight', mode='w+', dtype='float32', shape=(h,w))
|
| 332 |
for start in range(0, len(origins), batch_size):
|
| 333 |
active, patches = [], []
|
|
@@ -351,17 +354,28 @@ def predict(model, volume, output, reverse=False, stride=None, batch_size=1, pre
|
|
| 351 |
image = torch.from_numpy(normalized[:,None]).to(device)
|
| 352 |
valid = support[:,None,None].expand(-1,1,d,-1,-1)
|
| 353 |
with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=precision=='bf16'):
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
| 355 |
weighted = (probability*support*win).cpu().numpy()
|
| 356 |
for pred,(y,x,ph,pw) in zip(weighted,active):
|
| 357 |
-
numerator[y:y+ph,x:x+pw] += pred[:ph,:pw]
|
| 358 |
if start == 0 or start+batch_size >= len(origins) or start % 100 == 0:
|
| 359 |
print(f'{min(start+batch_size,len(origins))}/{len(origins)} patches', flush=True)
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
del numerator, denominator
|
| 366 |
|
| 367 |
|
|
@@ -389,7 +403,8 @@ def main():
|
|
| 389 |
parser.add_argument('--checkpoint', required=True)
|
| 390 |
parser.add_argument('--input', required=True, help='uint8 ZYX .npy or local Zarr')
|
| 391 |
parser.add_argument('--array', default='0', help='Array within a Zarr group')
|
| 392 |
-
parser.add_argument('--output',
|
|
|
|
| 393 |
parser.add_argument('--spacing-um', type=float, required=True, help='Actual isotropic render spacing, not raw scan spacing')
|
| 394 |
parser.add_argument('--reverse', action='store_true', help='Reverse full Z before selecting the central input planes')
|
| 395 |
parser.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu')
|
|
@@ -397,31 +412,61 @@ def main():
|
|
| 397 |
parser.add_argument('--stride', type=int, help='XY stride; default half the trained patch width')
|
| 398 |
parser.add_argument('--precision', choices=('fp32','bf16'), default='fp32')
|
| 399 |
args = parser.parse_args()
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
|
|
|
|
|
|
| 405 |
model = load_model(args.checkpoint,args.device)
|
| 406 |
if abs(args.spacing_um/model.sampling_um-1) > .02:
|
| 407 |
parser.error(f'Resample the render to {model.sampling_um} um first (no automatic resampling)')
|
| 408 |
volume = open_volume(args.input,args.array)
|
| 409 |
-
output
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
|
|
|
| 418 |
'checkpoint':Path(args.checkpoint).name,'sampling_um':args.spacing_um,
|
| 419 |
'shape_yx':list(volume.shape[-2:]),'reverse_z':args.reverse,
|
| 420 |
'central_depth':model.patch_size[0],'precision':args.precision,
|
| 421 |
'stride':args.stride or model.patch_size[1]//2,
|
| 422 |
'probabilities':'uint8 = round(255 * probability); no min-max scaling'
|
| 423 |
-
}
|
| 424 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
|
| 426 |
|
| 427 |
if __name__ == '__main__':
|
|
|
|
| 302 |
|
| 303 |
|
| 304 |
@torch.inference_mode()
|
| 305 |
+
def predict(model, volume, output, reverse=False, stride=None, batch_size=1, precision='fp32', three_d=False):
|
| 306 |
+
"""Tiled 2D or 3D uint8 probabilities into a writable HxW array (can be a memmap).
|
| 307 |
|
| 308 |
Reads only individual CT patches. Accumulators use temporary disk-backed
|
| 309 |
arrays, so a full surface volume never needs to fit in RAM or GPU memory.
|
|
|
|
| 313 |
stride = p//2 if stride is None else stride
|
| 314 |
if depth < d or not 0 < stride <= p or batch_size < 1:
|
| 315 |
raise ValueError('Insufficient depth, invalid stride, or invalid batch size')
|
| 316 |
+
expected_shape = (depth,h,w) if three_d else (h,w)
|
| 317 |
+
if tuple(output.shape) != expected_shape or output.dtype != np.uint8:
|
| 318 |
+
raise ValueError(f'Output must be a uint8 array of shape {expected_shape}')
|
| 319 |
+
if three_d:
|
| 320 |
+
output[:] = 0
|
| 321 |
device = next(model.parameters()).device
|
| 322 |
if precision not in ('fp32', 'bf16') or (precision == 'bf16' and device.type != 'cuda'):
|
| 323 |
raise ValueError('bf16 requires CUDA; otherwise use fp32')
|
|
|
|
| 330 |
win = win.to(device)
|
| 331 |
origins = [(y, x) for y in axis_starts(h,p,stride) for x in axis_starts(w,p,stride)]
|
| 332 |
with tempfile.TemporaryDirectory(prefix='hecate-') as tmp:
|
| 333 |
+
numerator = np.memmap(Path(tmp)/'sum', mode='w+', dtype='float32', shape=(d,h,w) if three_d else (h,w))
|
| 334 |
denominator = np.memmap(Path(tmp)/'weight', mode='w+', dtype='float32', shape=(h,w))
|
| 335 |
for start in range(0, len(origins), batch_size):
|
| 336 |
active, patches = [], []
|
|
|
|
| 354 |
image = torch.from_numpy(normalized[:,None]).to(device)
|
| 355 |
valid = support[:,None,None].expand(-1,1,d,-1,-1)
|
| 356 |
with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=precision=='bf16'):
|
| 357 |
+
logits = model.forward_3d(image) if three_d else model(image, valid)
|
| 358 |
+
probability = logits.float().sigmoid()[:,0]
|
| 359 |
+
if three_d:
|
| 360 |
+
support = support[:,None]
|
| 361 |
weighted = (probability*support*win).cpu().numpy()
|
| 362 |
for pred,(y,x,ph,pw) in zip(weighted,active):
|
| 363 |
+
numerator[...,y:y+ph,x:x+pw] += pred[..., :ph,:pw]
|
| 364 |
if start == 0 or start+batch_size >= len(origins) or start % 100 == 0:
|
| 365 |
print(f'{min(start+batch_size,len(origins))}/{len(origins)} patches', flush=True)
|
| 366 |
+
if three_d and model.margin:
|
| 367 |
+
numerator[:model.margin] = 0
|
| 368 |
+
numerator[-model.margin:] = 0
|
| 369 |
+
for y in range(0,h,128):
|
| 370 |
+
for x in range(0,w,256):
|
| 371 |
+
prob = numerator[...,y:y+128,x:x+256]/denominator[y:y+128,x:x+256]
|
| 372 |
+
if not np.isfinite(prob).all():
|
| 373 |
+
raise FloatingPointError('Non-finite inference output')
|
| 374 |
+
values = np.rint(prob.clip(0,1)*255).astype(np.uint8)
|
| 375 |
+
if three_d:
|
| 376 |
+
output[zs,y:y+128,x:x+256] = values[::-1] if reverse else values
|
| 377 |
+
else:
|
| 378 |
+
output[y:y+128,x:x+256] = values
|
| 379 |
del numerator, denominator
|
| 380 |
|
| 381 |
|
|
|
|
| 403 |
parser.add_argument('--checkpoint', required=True)
|
| 404 |
parser.add_argument('--input', required=True, help='uint8 ZYX .npy or local Zarr')
|
| 405 |
parser.add_argument('--array', default='0', help='Array within a Zarr group')
|
| 406 |
+
parser.add_argument('--output', help='2D probability PNG (optional with --output-3d)')
|
| 407 |
+
parser.add_argument('--output-3d', help='Compressed uint8 3D probability Zarr')
|
| 408 |
parser.add_argument('--spacing-um', type=float, required=True, help='Actual isotropic render spacing, not raw scan spacing')
|
| 409 |
parser.add_argument('--reverse', action='store_true', help='Reverse full Z before selecting the central input planes')
|
| 410 |
parser.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu')
|
|
|
|
| 412 |
parser.add_argument('--stride', type=int, help='XY stride; default half the trained patch width')
|
| 413 |
parser.add_argument('--precision', choices=('fp32','bf16'), default='fp32')
|
| 414 |
args = parser.parse_args()
|
| 415 |
+
if not args.output and not args.output_3d:
|
| 416 |
+
parser.error('Specify --output and/or --output-3d')
|
| 417 |
+
output = Path(args.output) if args.output else None
|
| 418 |
+
output3d = Path(args.output_3d) if args.output_3d else None
|
| 419 |
+
for path,suffix in ((output,'.png'),(output3d,'.zarr')):
|
| 420 |
+
if path is not None and (path.suffix.lower()!=suffix or path.exists()):
|
| 421 |
+
parser.error(f'Choose a new output filename ending in {suffix}: {path}')
|
| 422 |
model = load_model(args.checkpoint,args.device)
|
| 423 |
if abs(args.spacing_um/model.sampling_um-1) > .02:
|
| 424 |
parser.error(f'Resample the render to {model.sampling_um} um first (no automatic resampling)')
|
| 425 |
volume = open_volume(args.input,args.array)
|
| 426 |
+
if output is not None:
|
| 427 |
+
output.parent.mkdir(parents=True,exist_ok=True)
|
| 428 |
+
with tempfile.TemporaryDirectory(prefix='hecate-') as tmp:
|
| 429 |
+
result = np.memmap(Path(tmp)/'prediction',mode='w+',dtype='uint8',shape=volume.shape[-2:])
|
| 430 |
+
predict(model,volume,result,args.reverse,args.stride,args.batch_size,args.precision)
|
| 431 |
+
partial = output.with_suffix('.partial')
|
| 432 |
+
write_png(result,partial)
|
| 433 |
+
partial.replace(output)
|
| 434 |
+
del result
|
| 435 |
+
metadata = {
|
| 436 |
'checkpoint':Path(args.checkpoint).name,'sampling_um':args.spacing_um,
|
| 437 |
'shape_yx':list(volume.shape[-2:]),'reverse_z':args.reverse,
|
| 438 |
'central_depth':model.patch_size[0],'precision':args.precision,
|
| 439 |
'stride':args.stride or model.patch_size[1]//2,
|
| 440 |
'probabilities':'uint8 = round(255 * probability); no min-max scaling'
|
| 441 |
+
}
|
| 442 |
+
if output is not None:
|
| 443 |
+
output.with_suffix('.json').write_text(json.dumps(metadata,indent=2)+'\n')
|
| 444 |
+
print(output)
|
| 445 |
+
if output3d is not None:
|
| 446 |
+
import zarr
|
| 447 |
+
from numcodecs import Blosc
|
| 448 |
+
output3d.parent.mkdir(parents=True,exist_ok=True)
|
| 449 |
+
partial = output3d.with_name(output3d.name+'.partial')
|
| 450 |
+
if partial.exists():
|
| 451 |
+
parser.error(f'Incomplete output exists: {partial}; remove it or choose another output')
|
| 452 |
+
options = {'zarr_format':2} if int(zarr.__version__.split('.')[0])>=3 else {}
|
| 453 |
+
result = zarr.open_array(str(partial),mode='w',shape=volume.shape,
|
| 454 |
+
chunks=(1,256,256),dtype='uint8',fill_value=0,
|
| 455 |
+
compressor=Blosc(cname='zstd',clevel=3,shuffle=Blosc.BITSHUFFLE),**options)
|
| 456 |
+
predict(model,volume,result,args.reverse,args.stride,args.batch_size,args.precision,three_d=True)
|
| 457 |
+
depth = volume.shape[0]
|
| 458 |
+
d = model.patch_size[0]
|
| 459 |
+
start = depth//2-d//2
|
| 460 |
+
if args.reverse:
|
| 461 |
+
start = depth-start-d
|
| 462 |
+
result.attrs.update({**metadata,'axes':['z','y','x'],
|
| 463 |
+
'spacing_um':[args.spacing_um]*3,'z_order':'same as input',
|
| 464 |
+
'evaluated_z_interval':[start+model.margin,start+d-model.margin],
|
| 465 |
+
'outside_evaluated_z':'zero; not evaluated',
|
| 466 |
+
'background':'empty CT columns zeroed; no intensity threshold'})
|
| 467 |
+
partial.rename(output3d)
|
| 468 |
+
print(output3d)
|
| 469 |
+
|
| 470 |
|
| 471 |
|
| 472 |
if __name__ == '__main__':
|
requirements.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
torch>=2.6,<3
|
| 2 |
numpy>=1.26,<3
|
| 3 |
zarr>=2.18,<4
|
|
|
|
|
|
| 1 |
torch>=2.6,<3
|
| 2 |
numpy>=1.26,<3
|
| 3 |
zarr>=2.18,<4
|
| 4 |
+
numcodecs>=0.12,<1
|