grgngl commited on
Commit
9cb86e5
·
verified ·
1 Parent(s): 3adfe18

Add tested tiled 3D probability Zarr export

Browse files
Files changed (3) hide show
  1. README.md +18 -2
  2. hecate.py +74 -29
  3. 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 writes only the 2D map.
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, and lossless PNG decoding. Float32 and CUDA bfloat16 were tested. The checkpoints load strictly, including their saved EMA batch-normalization buffers.
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
- if tuple(output.shape) != (h, w) or output.dtype != np.uint8:
317
- raise ValueError('Output must be an HxW uint8 array')
 
 
 
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
- probability = model(image, valid).float().sigmoid()[:,0]
 
 
 
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
- for y in range(0,h,256):
361
- prob = numerator[y:y+256]/denominator[y:y+256]
362
- if not np.isfinite(prob).all():
363
- raise FloatingPointError('Non-finite inference output')
364
- output[y:y+256] = np.rint(prob.clip(0,1)*255).astype(np.uint8)
 
 
 
 
 
 
 
 
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', required=True, help='2D probability PNG')
 
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
- output = Path(args.output)
401
- if output.suffix.lower() != '.png':
402
- parser.error('--output must end in .png')
403
- if output.exists():
404
- parser.error('Output already exists; choose a new filename')
 
 
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.parent.mkdir(parents=True,exist_ok=True)
410
- with tempfile.TemporaryDirectory(prefix='hecate-') as tmp:
411
- result = np.memmap(Path(tmp)/'prediction',mode='w+',dtype='uint8',shape=volume.shape[-2:])
412
- predict(model,volume,result,args.reverse,args.stride,args.batch_size,args.precision)
413
- partial = output.with_suffix('.partial')
414
- write_png(result,partial)
415
- partial.replace(output)
416
- del result
417
- output.with_suffix('.json').write_text(json.dumps({
 
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
- },indent=2)+'\n')
424
- print(output)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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