Add optional DirectML inference for Hecate 9.6 µm on Windows

#2
DIRECTML.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hecate ink detection on a laptop's integrated graphics
2
+
3
+ `hecate_directml.py` makes GPU-accelerated Hecate 9.6 µm ink detection usable on a 16 GB Windows laptop with integrated graphics and shared system memory. The tested prediction path requires no discrete graphics card or rented GPU compute.
4
+
5
+ This lowers the hardware barrier for contributors who already own a laptop but lack a dedicated GPU. They can run the released detector and test rendered regions locally, with faster feedback than the CPU path. The implementation uses ONNX Runtime DirectML to reach the laptop's existing graphics hardware; it preserves the released model and probability-image format.
6
+
7
+ The demonstrated machine has a Ryzen 5 8540U with **Radeon 740M integrated graphics**, Windows 11 and **16 GB RAM**. A real PHerc0139 crop took **3.54 s**, compared with **13.02 s** for the original CPU predictor: **3.68× faster**, with all 9,216 output pixels identical. These timings cover tiled prediction and blending after model/session loading. They exclude export and initialization.
8
+
9
+ ## Install and run
10
+
11
+ Use Windows and a DirectX 12 GPU. The tested machine has Windows 11, a Ryzen 5 8540U, Radeon 740M integrated graphics, and 16 GB RAM. Other DirectML devices have not been tested here.
12
+
13
+ Keep the helper beside the released `hecate.py`, `requirements.txt`, and `hecate_9.6um.pth`. These commands reproduce the tested Python 3.11 / CPU PyTorch environment. Use a separate environment from installations containing `onnxruntime` or `onnxruntime-gpu`:
14
+
15
+ ```powershell
16
+ python -m venv .venv
17
+ .venv\Scripts\python -m pip install torch==2.10.0 --index-url https://download.pytorch.org/whl/cpu
18
+ .venv\Scripts\python -m pip install -r requirements.txt -r requirements-directml.txt
19
+ .venv\Scripts\python hecate_directml.py export --checkpoint hecate_9.6um.pth --output hecate_9.6um.onnx
20
+ .venv\Scripts\python hecate_directml.py predict --model hecate_9.6um.onnx --input surface.zarr --spacing-um 9.6 --output ink.png
21
+ ```
22
+
23
+ Export creates a roughly 513 MB ONNX file and a small `.onnx.json` sidecar. Keep both. The sidecar binds the graph to its checkpoint hash. Export once and reuse it; the measured export took 9.94 s. Allow about 5 GB of free system commit capacity for the verification process. Run export separately from other model jobs.
24
+
25
+ Inputs must be unnormalized uint8 surface renders in Z,Y,X order, already sampled near 9.6 µm in all axes. A local Zarr array, a Zarr group containing `0`, or `.npy` is accepted. `--array` selects another array within a group. The spacing argument checks the declared spacing; it does not resample a raw scan.
26
+
27
+ `--reverse` reverses the full depth before selecting the central 16 planes. `--stride` defaults to 32; `--device-id` selects the DirectML adapter, with 0 as the default. `--profile` records executed providers. CPU fallback is disabled; an unsupported graph fails instead of silently running on CPU.
28
+
29
+ The output is a grayscale PNG with `round(255 * probability)`, plus JSON settings and timings. There is no per-image contrast scaling. The source Hann overlap, empty-column mask and padded-edge behavior are preserved. Accumulators use temporary disk files, approximately eight bytes per output pixel, plus one byte per pixel for the output buffer. Set `TMP` to a drive with adequate space for large surfaces.
30
+
31
+ This helper supports **9.6 µm, FP32, batch size one, 2D output**. It does not implement training, the 2.4 µm checkpoint or 3D export. DirectML acceleration changes execution, not the detector's accuracy.
32
+
33
+ ## Reproduce the comparison
34
+
35
+ The source was pinned to Hecate revision `9cb86e500e944b11a06a7020403cde5dffb5bcb2`. The checkpoint SHA-256 is `809f4f10f7cb7afa19b4bee0f7d2ab31edd7e11b664f9f210cc9c17f22fcfe5d`.
36
+
37
+ The example comes from PHerc0139, segment `20260112000000-w043_2026011217`, native scan `20250728140407`, 9.362 µm surface volume. `fetch_example.py` downloads four hash-checked chunks from the official server and reconstructs the exact comparison crop. CT data and model weights are not bundled with this contribution. The [data terms](https://dl.ash2txt.org/LICENSE.txt) apply to the downloaded data.
38
+
39
+ ```powershell
40
+ .venv\Scripts\python -m pip install -r requirements-verify.txt
41
+ .venv\Scripts\python fetch_example.py --output pherc0139_crop.npy
42
+ .venv\Scripts\python verify_directml.py --checkpoint hecate_9.6um.pth --input pherc0139_crop.npy --output check
43
+ ```
44
+
45
+ The crop is 16×96×96. Its depth spacing is 9.6 µm; its effective XY spacing is 9.6059 µm after rounding the original 512-pixel extent to 499 pixels. Exact depth coordinates, interpolation and source hashes are in `fetch_example.py`. The reconstructed `.npy` SHA-256 is `04224e26f565c4a5c1ff7f018e4904a381e7d0d6a217a6ade09ab6ec313e312a`.
46
+
47
+ The check runs the original CPU predictor, exports the model, and runs DirectML in separate processes. It requires each comparison to differ by at most one uint8 level, an exact repeated GPU image, exact equality between the array API and Zarr-to-PNG command, and cleanup after injected inference/writer errors. The observed differences were zero in every case:
48
+
49
+ | Input | CPU prediction | DirectML prediction | Maximum pixel difference |
50
+ |---|---:|---:|---:|
51
+ | Real crop, four overlapping tiles | 13.018 s | 3.538 s | 0 |
52
+ | Reversed depth | 12.973 s | 3.477 s | 0 |
53
+ | Real crop cut to 45×53, with padding | 3.299 s | 0.923 s | 0 |
54
+ | Real crop with nine empty columns | 13.231 s | 3.571 s | 0 |
55
+ | Blank negative control | 0.004 s | 0.009 s | 0 |
56
+
57
+ The fresh Zarr-to-PNG command took 5.313 s including session initialization. The graph profile contains only DirectML dispatches. The CPU reference used four threads; both paths ran in FP32 on the same laptop. Raw measurements and source/code hashes are in `directml_validation.json`.
58
+
59
+ ![CPU and integrated-GPU pixel comparison](https://huggingface.co/scrollprize/hecate/resolve/468a7bceb71f00edc4a2aca41fcd7ef89d79a870/directml_comparison.png)
60
+
61
+ The altered and blank inputs are boundary controls, not additional independent scroll examples. This is an exposed backend-equivalence comparison, not held-out accuracy validation or a claim of newly recovered letters.
62
+
63
+ ## Implementation and attribution
64
+
65
+ The helper reuses `hecate.py` for Zarr opening, tile origins and PNG writing. It uses the same intensity division and floored Hann blend. At 9.6 µm, the attention and output use the same XY grid. The validity mask is constant through depth for each column, so masking empty columns after inference gives the source result. This argument does not extend to the 2.4 µm spatial interpolation path.
66
+
67
+ Hecate and its released weights are by the Vesuvius Challenge team. This optional backend was developed and tested with Codex during Mohamad Al-Zawahreh's scroll-recovery work. It does not retrain or replace the model. Tested software: Python 3.11.9, PyTorch 2.10.0+cpu, ONNX 1.23.2 and ONNX Runtime DirectML 1.24.4.
directml_comparison.png ADDED
directml_validation.json ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "status": "PASS",
3
+ "checkpoint_sha256": "809f4f10f7cb7afa19b4bee0f7d2ab31edd7e11b664f9f210cc9c17f22fcfe5d",
4
+ "input_sha256": "04224e26f565c4a5c1ff7f018e4904a381e7d0d6a217a6ade09ab6ec313e312a",
5
+ "hecate_sha256": "c232c18a1a86cfb91257a00db13288202bb8ae33732ba26f7291a9b8adb8da59",
6
+ "helper_sha256": "69b28298711a90019e2a4e9ec67a81b287391a9c0231e41edf3374817894c152",
7
+ "export_seconds": 9.94456380000338,
8
+ "rows": [
9
+ {
10
+ "case": "overlap",
11
+ "shape": [
12
+ 16,
13
+ 96,
14
+ 96
15
+ ],
16
+ "max_uint8_difference": 0,
17
+ "different_pixels": 0,
18
+ "pixels": 9216,
19
+ "cpu_seconds": 13.018276199931279,
20
+ "directml_seconds": 3.5383093000855297,
21
+ "tiles": 4,
22
+ "model_forwards": 4,
23
+ "forward_seconds": [
24
+ 0.9426679000025615,
25
+ 0.8602820000378415,
26
+ 0.8697611999232322,
27
+ 0.8601702000014484
28
+ ]
29
+ },
30
+ {
31
+ "case": "reverse",
32
+ "shape": [
33
+ 16,
34
+ 96,
35
+ 96
36
+ ],
37
+ "max_uint8_difference": 0,
38
+ "different_pixels": 0,
39
+ "pixels": 9216,
40
+ "cpu_seconds": 12.973447300028056,
41
+ "directml_seconds": 3.476844499935396,
42
+ "tiles": 4,
43
+ "model_forwards": 4,
44
+ "forward_seconds": [
45
+ 0.8666426000418141,
46
+ 0.8924879000987858,
47
+ 0.8558254999807104,
48
+ 0.856082099955529
49
+ ]
50
+ },
51
+ {
52
+ "case": "padded_edge",
53
+ "shape": [
54
+ 16,
55
+ 45,
56
+ 53
57
+ ],
58
+ "max_uint8_difference": 0,
59
+ "different_pixels": 0,
60
+ "pixels": 2385,
61
+ "cpu_seconds": 3.299313399940729,
62
+ "directml_seconds": 0.9228492999682203,
63
+ "tiles": 1,
64
+ "model_forwards": 1,
65
+ "forward_seconds": [
66
+ 0.880084800068289
67
+ ]
68
+ },
69
+ {
70
+ "case": "empty_columns",
71
+ "shape": [
72
+ 16,
73
+ 96,
74
+ 96
75
+ ],
76
+ "max_uint8_difference": 0,
77
+ "different_pixels": 0,
78
+ "pixels": 9216,
79
+ "cpu_seconds": 13.231093699927442,
80
+ "directml_seconds": 3.571005600038916,
81
+ "tiles": 4,
82
+ "model_forwards": 4,
83
+ "forward_seconds": [
84
+ 0.8853788999840617,
85
+ 0.8660517999669537,
86
+ 0.9252147999359295,
87
+ 0.8839931000256911
88
+ ]
89
+ },
90
+ {
91
+ "case": "blank_control",
92
+ "shape": [
93
+ 16,
94
+ 96,
95
+ 96
96
+ ],
97
+ "max_uint8_difference": 0,
98
+ "different_pixels": 0,
99
+ "pixels": 9216,
100
+ "cpu_seconds": 0.0044531000312417746,
101
+ "directml_seconds": 0.00914159999229014,
102
+ "tiles": 4,
103
+ "model_forwards": 0,
104
+ "forward_seconds": []
105
+ }
106
+ ],
107
+ "exact_repeat": true,
108
+ "zarr_cli_exact": true,
109
+ "failure_cleanup": true,
110
+ "cpu_fallback_disabled": true,
111
+ "executed_providers": {
112
+ "DmlExecutionProvider": 17
113
+ },
114
+ "environment": {
115
+ "python": "3.11.9",
116
+ "torch": "2.10.0+cpu",
117
+ "os": "Windows 11",
118
+ "processor": "AMD Ryzen 5 8540U",
119
+ "gpu": "Radeon 740M integrated",
120
+ "ram_gib": 16,
121
+ "onnx": "1.23.2",
122
+ "onnxruntime": "1.24.4",
123
+ "cpu_threads": 4
124
+ },
125
+ "scope": "Backend equivalence on a real exposed crop and explicit edge controls; no new text or model-accuracy claim.",
126
+ "cpu_reference_provenance": "CPU references completed in a separate process; exact retained input, checkpoint, source CPU code and images were reused for final DirectML comparison. Export reused after a Windows temporary-mapping cleanup-only change.",
127
+ "fresh_cli_seconds_including_initialization": 5.313350200071,
128
+ "publication_note": "No raw CT or model weights included. fetch_example.py reconstructs the hash-checked input from official chunks."
129
+ }
fetch_example.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fetch a small PHerc0139 example from the official server; no CT is redistributed.
2
+
3
+ Data terms: https://dl.ash2txt.org/LICENSE.txt
4
+ This reconstructs the exposed backend-comparison input, not held-out ink labels.
5
+ """
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ from pathlib import Path
10
+ from urllib.request import urlopen
11
+
12
+ import numpy as np
13
+ from scipy.ndimage import map_coordinates
14
+
15
+ BASE = ('https://vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com/'
16
+ 'PHerc0139/segments/20260112000000-w043_2026011217/surface-volumes/'
17
+ '9.362um-1.2m-113keV-volume-20250728140407.zarr/0')
18
+ CHUNKS = {
19
+ (35, 20): 'e1594169b84799d3556d2907c94be7aa145f53f37741b3fabc674b1b187d29c1',
20
+ (35, 21): 'f3781ae4d3391ab433457874c2d57901ded5e9ef4a7a81bc4165bf4b6b1b1ca8',
21
+ (36, 20): '7e103b25b0851a6b340ab03e128c5d006a5ac1bebb7c6ea7084ca2a9772150a4',
22
+ (36, 21): 'b982a846f80eb78a472d760994446bad9824864e8e42c9d97e69e9ee005776cc',
23
+ }
24
+ DEPTH = [4.4291245739319605, 5.454546492325465, 6.479968410718972,
25
+ 7.505390329112477, 8.530812247505981, 9.55623416589949,
26
+ 10.581656084292995, 11.6070780026865, 12.632499921080004,
27
+ 13.65792183947351, 14.683343757867018, 15.708765676260523,
28
+ 16.734187594654028, 17.759609513047533, 18.785031431441038,
29
+ 19.810453349834543]
30
+ EXPECTED = '04224e26f565c4a5c1ff7f018e4904a381e7d0d6a217a6ade09ab6ec313e312a'
31
+
32
+
33
+ def fetch(output: Path) -> None:
34
+ if output.exists():
35
+ raise FileExistsError(output)
36
+ with urlopen(BASE + '/.zarray', timeout=30) as response:
37
+ meta = json.load(response)
38
+ expected = {'shape': [28, 6120, 8120], 'chunks': [28, 128, 128], 'dtype': '|u1',
39
+ 'compressor': None, 'filters': None, 'order': 'C', 'dimension_separator': '/'}
40
+ if any(meta.get(k) != v for k, v in expected.items()):
41
+ raise ValueError('The public source metadata changed')
42
+ block = np.empty((28, 256, 256), dtype=np.uint8)
43
+ for (y, x), digest in CHUNKS.items():
44
+ with urlopen(f'{BASE}/0/{y}/{x}', timeout=30) as response:
45
+ raw = response.read(28 * 128 * 128 + 1)
46
+ if hashlib.sha256(raw).hexdigest() != digest:
47
+ raise ValueError(f'Source chunk changed: {y}/{x}')
48
+ block[:, (y-35)*128:(y-34)*128, (x-20)*128:(x-19)*128] = np.frombuffer(raw, np.uint8).reshape(28, 128, 128)
49
+ # The original 512-pixel crop began at Y=4320, X=2440. Its resampled
50
+ # extent was 499 pixels; this example takes [192:288,192:288] from it.
51
+ xy = (np.arange(192, 288) + .5) * 512 / 499 - .5
52
+ coordinates = np.array(np.broadcast_arrays(np.array(DEPTH)[:, None, None],
53
+ (xy - 160)[None, :, None], (xy - 120)[None, None, :]))
54
+ image = map_coordinates(block.astype(np.float32), coordinates, order=1, prefilter=False)
55
+ image = image.clip(0, 255).astype(np.uint8)
56
+ output.parent.mkdir(parents=True, exist_ok=True)
57
+ with output.open('xb') as stream:
58
+ np.save(stream, image, allow_pickle=False)
59
+ if hashlib.sha256(output.read_bytes()).hexdigest() != EXPECTED:
60
+ raise ValueError('The reconstructed example differs from the recorded input')
61
+ print(f'{output}: {EXPECTED}')
62
+
63
+
64
+ if __name__ == '__main__':
65
+ parser = argparse.ArgumentParser(description=__doc__)
66
+ parser.add_argument('--output', type=Path, default=Path('pherc0139_crop.npy'))
67
+ fetch(parser.parse_args().output)
hecate_directml.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional Windows DirectML inference for Hecate's 9.6 um 2D ink map.
2
+
3
+ Keep this file beside the released hecate.py. Export once, then reuse the graph.
4
+ CPU/CUDA inference and the released weights are unchanged.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ from collections import Counter
10
+ from contextlib import contextmanager
11
+ import hashlib
12
+ import json
13
+ from pathlib import Path
14
+ import tempfile
15
+ import time
16
+
17
+ import numpy as np
18
+
19
+
20
+ @contextmanager
21
+ def mapped_array(path: Path, dtype: str, shape: tuple):
22
+ """Close the Windows file mapping even when inference or writing raises."""
23
+ array = np.memmap(path, mode='w+', dtype=dtype, shape=shape)
24
+ try:
25
+ yield array
26
+ finally:
27
+ array._mmap.close()
28
+
29
+
30
+ def sha256(path: Path) -> str:
31
+ with path.open('rb') as stream:
32
+ return hashlib.file_digest(stream, 'sha256').hexdigest()
33
+
34
+
35
+ def export_model(checkpoint: Path, output: Path) -> None:
36
+ import onnx
37
+ import torch
38
+ from hecate import load_model
39
+
40
+ sidecar = output.with_suffix('.onnx.json')
41
+ if output.suffix != '.onnx' or output.exists() or sidecar.exists():
42
+ raise ValueError('Choose new .onnx and .onnx.json output paths')
43
+ model = load_model(checkpoint, 'cpu')
44
+ if model.sampling_um != 9.6 or tuple(model.patch_size) != (16, 64, 64):
45
+ raise ValueError('This backend supports only Hecate 9.6 um / 16x64x64')
46
+ output.parent.mkdir(parents=True, exist_ok=True)
47
+ checkpoint_hash = sha256(checkpoint)
48
+ with tempfile.TemporaryDirectory(dir=output.parent) as tmp:
49
+ graph_path = Path(tmp) / 'model.onnx'
50
+ torch.onnx.export(
51
+ model, (torch.zeros(1, 1, 16, 64, 64),), str(graph_path),
52
+ input_names=['volume'], output_names=['logits'], opset_version=18,
53
+ dynamo=False, external_data=False, do_constant_folding=True,
54
+ )
55
+ # A small sidecar avoids loading and serializing another 513 MB protobuf
56
+ # while the traced PyTorch model is still resident.
57
+ onnx.checker.check_model(str(graph_path))
58
+ metadata = {
59
+ 'hecate_backend_contract': '9.6um-2d-fp32-v1',
60
+ 'checkpoint_sha256': checkpoint_hash,
61
+ 'graph_sha256': sha256(graph_path),
62
+ }
63
+ metadata_path = Path(tmp) / 'model.onnx.json'
64
+ metadata_path.write_text(json.dumps(metadata, indent=2) + '\n', encoding='utf-8')
65
+ graph_path.replace(output)
66
+ metadata_path.replace(sidecar)
67
+
68
+
69
+ def load_session(graph: Path, device_id: int = 0, profile_prefix: str | None = None):
70
+ import onnxruntime as ort
71
+
72
+ if device_id < 0:
73
+ raise ValueError('device_id must be nonnegative')
74
+ if 'DmlExecutionProvider' not in ort.get_available_providers():
75
+ raise RuntimeError('Install onnxruntime-directml in a Windows environment')
76
+ metadata = json.loads(graph.with_suffix('.onnx.json').read_text(encoding='utf-8'))
77
+ if metadata.get('hecate_backend_contract') != '9.6um-2d-fp32-v1' or metadata.get('graph_sha256') != sha256(graph):
78
+ raise ValueError('Use the unchanged graph and sidecar exported by this helper')
79
+ options = ort.SessionOptions()
80
+ options.enable_mem_pattern = False
81
+ options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
82
+ options.intra_op_num_threads = 1
83
+ options.inter_op_num_threads = 1
84
+ options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
85
+ options.add_session_config_entry('session.disable_cpu_ep_fallback', '1')
86
+ if profile_prefix:
87
+ options.enable_profiling = True
88
+ options.profile_file_prefix = profile_prefix
89
+ session = ort.InferenceSession(str(graph), sess_options=options,
90
+ providers=[('DmlExecutionProvider', {'device_id': device_id})])
91
+ inputs, outputs = session.get_inputs(), session.get_outputs()
92
+ if (len(inputs) != 1 or inputs[0].name != 'volume'
93
+ or inputs[0].shape != [1, 1, 16, 64, 64] or inputs[0].type != 'tensor(float)'
94
+ or len(outputs) != 1 or outputs[0].name != 'logits'
95
+ or outputs[0].shape != [1, 1, 64, 64] or outputs[0].type != 'tensor(float)'):
96
+ raise ValueError('Unexpected model input/output contract')
97
+ return session
98
+
99
+
100
+ def predict_patch(session, raw: np.ndarray) -> np.ndarray:
101
+ if raw.shape != (16, 64, 64) or raw.dtype != np.uint8:
102
+ raise ValueError('Expected uint8 16x64x64 CT patch')
103
+ image = (raw.astype(np.float32) / 255)[None, None]
104
+ logits = session.run(['logits'], {'volume': image})[0]
105
+ if logits.shape != (1, 1, 64, 64) or not np.isfinite(logits).all():
106
+ raise FloatingPointError('Invalid model output')
107
+ return (1 / (1 + np.exp(-logits)))[0, 0]
108
+
109
+
110
+ def predict(session, volume, output, reverse: bool = False, stride: int = 32) -> dict:
111
+ """Stream uint8 ZYX input into an HxW uint8 probability array.
112
+
113
+ At 9.6 um, attention and output use the same XY grid. Its validity mask is
114
+ constant through depth for each column. Nonempty columns therefore match
115
+ unmasked attention exactly; empty columns are zeroed before blending.
116
+ This equivalence does not justify using this helper for the 2.4 um model.
117
+ """
118
+ import torch
119
+ from hecate import axis_starts
120
+
121
+ if len(volume.shape) != 3 or np.dtype(volume.dtype) != np.dtype('uint8'):
122
+ raise ValueError('Input must be an unnormalized uint8 ZYX surface volume')
123
+ depth, height, width = volume.shape
124
+ if depth < 16 or height < 1 or width < 1 or not 0 < stride <= 64:
125
+ raise ValueError('Need depth >= 16, positive XY dimensions and stride 1..64')
126
+ if output.shape != (height, width) or output.dtype != np.uint8:
127
+ raise ValueError('Output must be an HxW uint8 array')
128
+ z0 = depth // 2 - 8
129
+ zs = slice(depth - z0 - 16, depth - z0) if reverse else slice(z0, z0 + 16)
130
+ axis = torch.hann_window(64, periodic=False)
131
+ window = axis[:, None] * axis[None, :]
132
+ window = (window / window.max()).clamp_min(.001).numpy()
133
+ ys, xs = axis_starts(height, 64, stride), axis_starts(width, 64, stride)
134
+ forward_seconds = []
135
+ with (tempfile.TemporaryDirectory(prefix='hecate-dml-') as tmp,
136
+ mapped_array(Path(tmp) / 'sum', 'float32', (height, width)) as numerator,
137
+ mapped_array(Path(tmp) / 'weight', 'float32', (height, width)) as denominator):
138
+ for y in ys:
139
+ for x in xs:
140
+ ph, pw = min(64, height - y), min(64, width - x)
141
+ region = np.s_[y:y+ph, x:x+pw]
142
+ denominator[region] += window[:ph, :pw]
143
+ raw = np.asarray(volume[zs, y:y+ph, x:x+pw])
144
+ if reverse:
145
+ raw = raw[::-1]
146
+ if not raw.any():
147
+ continue
148
+ patch = np.zeros((16, 64, 64), dtype=np.uint8)
149
+ patch[:, :ph, :pw] = raw
150
+ tick = time.perf_counter()
151
+ probability = predict_patch(session, patch)
152
+ forward_seconds.append(time.perf_counter() - tick)
153
+ weighted = (probability * patch.any(0)) * window
154
+ numerator[region] += weighted[:ph, :pw]
155
+ for y in range(0, height, 128):
156
+ for x in range(0, width, 256):
157
+ region = np.s_[y:y+128, x:x+256]
158
+ probability = numerator[region] / denominator[region]
159
+ if not np.isfinite(probability).all():
160
+ raise FloatingPointError('Non-finite blended output')
161
+ output[region] = np.rint(probability.clip(0, 1) * 255).astype(np.uint8)
162
+ return {'tiles': len(ys) * len(xs), 'model_forwards': len(forward_seconds),
163
+ 'forward_seconds': forward_seconds}
164
+
165
+
166
+ def finish_profile(session) -> dict:
167
+ path = Path(session.end_profiling())
168
+ providers = Counter(event.get('args', {}).get('provider')
169
+ for event in json.loads(path.read_text(encoding='utf-8'))
170
+ if event.get('args', {}).get('provider'))
171
+ if providers and set(providers) != {'DmlExecutionProvider'}:
172
+ raise RuntimeError(f'Unexpected executed providers: {dict(providers)}')
173
+ return {'path': str(path), 'executed_providers': dict(providers)}
174
+
175
+
176
+ def main() -> None:
177
+ parser = argparse.ArgumentParser(description=__doc__)
178
+ sub = parser.add_subparsers(dest='command', required=True)
179
+ export = sub.add_parser('export', help='Export the unchanged released checkpoint once')
180
+ export.add_argument('--checkpoint', type=Path, required=True)
181
+ export.add_argument('--output', type=Path, required=True)
182
+ run = sub.add_parser('predict', help='Run the exported model on a Windows GPU')
183
+ run.add_argument('--model', type=Path, required=True)
184
+ run.add_argument('--input', required=True, help='uint8 ZYX surface .npy or local Zarr')
185
+ run.add_argument('--array', default='0')
186
+ run.add_argument('--output', type=Path, required=True, help='New probability .png')
187
+ run.add_argument('--spacing-um', type=float, required=True)
188
+ run.add_argument('--reverse', action='store_true')
189
+ run.add_argument('--stride', type=int, default=32)
190
+ run.add_argument('--device-id', type=int, default=0)
191
+ run.add_argument('--profile', action='store_true')
192
+ args = parser.parse_args()
193
+ if args.command == 'export':
194
+ export_model(args.checkpoint, args.output)
195
+ return
196
+ if not np.isfinite(args.spacing_um) or abs(args.spacing_um / 9.6 - 1) > .02:
197
+ parser.error('Resample the surface render to 9.6 um first; this script does not resample')
198
+ if args.output.suffix.lower() != '.png' or args.output.exists() or args.output.with_suffix('.json').exists():
199
+ parser.error('Choose new .png and matching .json output paths')
200
+ from hecate import open_volume, write_png
201
+ volume = open_volume(args.input, args.array)
202
+ args.output.parent.mkdir(parents=True, exist_ok=True)
203
+ start = time.perf_counter()
204
+ session = load_session(args.model, args.device_id,
205
+ str(args.output.with_suffix('.profile')) if args.profile else None)
206
+ with tempfile.TemporaryDirectory(dir=args.output.parent, prefix='hecate-dml-') as tmp:
207
+ partial = Path(tmp) / 'prediction.png'
208
+ with mapped_array(Path(tmp) / 'prediction', 'uint8', volume.shape[-2:]) as result:
209
+ stats = predict(session, volume, result, args.reverse, args.stride)
210
+ write_png(result, partial)
211
+ partial.replace(args.output)
212
+ metadata = {
213
+ 'sampling_um': args.spacing_um, 'shape_yx': list(volume.shape[-2:]),
214
+ 'reverse_z': args.reverse, 'central_depth': 16, 'stride': args.stride,
215
+ 'precision': 'fp32', 'provider': 'DmlExecutionProvider', 'device_id': args.device_id,
216
+ 'cpu_fallback_disabled': True,
217
+ 'checkpoint_sha256': json.loads(args.model.with_suffix('.onnx.json').read_text(encoding='utf-8'))['checkpoint_sha256'],
218
+ 'probabilities': 'uint8 = round(255 * probability); no min-max scaling',
219
+ 'elapsed_seconds': time.perf_counter() - start, **stats,
220
+ }
221
+ if args.profile:
222
+ metadata['profile'] = finish_profile(session)
223
+ args.output.with_suffix('.json').write_text(json.dumps(metadata, indent=2) + '\n', encoding='utf-8')
224
+ print(args.output)
225
+
226
+
227
+ if __name__ == '__main__':
228
+ main()
requirements-directml.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Install in the same environment as Hecate's requirements.txt.
2
+ # Use a separate environment from onnxruntime / onnxruntime-gpu installations.
3
+ onnxruntime-directml==1.24.4
4
+ onnx==1.23.2
requirements-verify.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ -r requirements-directml.txt
2
+ Pillow>=10,<13
3
+ scipy>=1.11,<2
verify_directml.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compare the optional backend with released Hecate on an actual CT crop."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import gc
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ import tempfile
13
+ from unittest.mock import patch
14
+
15
+ import numpy as np
16
+ from PIL import Image
17
+
18
+ import hecate_directml as dml
19
+
20
+
21
+ def sha(path: Path) -> str:
22
+ with path.open('rb') as stream:
23
+ return hashlib.file_digest(stream, 'sha256').hexdigest()
24
+
25
+
26
+ def check_failure_cleanup(source: np.ndarray, folder: Path) -> None:
27
+ class BrokenSession:
28
+ def run(self, *args, **kwargs):
29
+ raise RuntimeError('injected inference failure')
30
+
31
+ real_temporary_directory = tempfile.TemporaryDirectory
32
+ with real_temporary_directory(dir=folder) as parent:
33
+ def directory(*args, **kwargs):
34
+ return real_temporary_directory(dir=parent)
35
+ with patch.object(dml.tempfile, 'TemporaryDirectory', side_effect=directory):
36
+ try:
37
+ dml.predict(BrokenSession(), source, np.zeros(source.shape[-2:], np.uint8))
38
+ except RuntimeError as exc:
39
+ assert str(exc) == 'injected inference failure'
40
+ else:
41
+ raise AssertionError('The injected failure did not propagate')
42
+ assert not list(Path(parent).iterdir()), 'Temporary mappings survived the error'
43
+ # The outer PNG buffer must close on errors too.
44
+ try:
45
+ with real_temporary_directory(dir=parent) as tmp:
46
+ with dml.mapped_array(Path(tmp) / 'prediction', 'uint8', source.shape[-2:]):
47
+ raise RuntimeError('injected writer failure')
48
+ except RuntimeError as exc:
49
+ assert str(exc) == 'injected writer failure'
50
+ assert not list(Path(parent).iterdir()), 'Output mapping survived the error'
51
+
52
+
53
+ def main() -> None:
54
+ parser = argparse.ArgumentParser(description=__doc__)
55
+ parser.add_argument('--checkpoint', type=Path, required=True)
56
+ parser.add_argument('--input', type=Path, required=True, help='Real uint8 ZYX crop, at least 16x96x96')
57
+ parser.add_argument('--output', type=Path, required=True, help='New verification directory')
58
+ parser.add_argument('--stage', choices=['all', 'cpu', 'export', 'directml'], default='all')
59
+ args = parser.parse_args()
60
+ if args.stage == 'all':
61
+ args.output.mkdir(parents=True, exist_ok=False)
62
+ # End each process to release the CPU convolution/export allocator before
63
+ # loading the next model. gc.collect() does not release those native arenas.
64
+ for stage in ('cpu', 'export', 'directml'):
65
+ subprocess.run([sys.executable, str(Path(__file__)), '--checkpoint', str(args.checkpoint),
66
+ '--input', str(args.input), '--output', str(args.output), '--stage', stage], check=True)
67
+ return
68
+ import torch
69
+ import hecate
70
+ torch.set_num_threads(4)
71
+ torch.set_num_interop_threads(1)
72
+ graph = args.output / 'hecate.onnx'
73
+ if args.stage == 'export':
74
+ start = time.perf_counter()
75
+ dml.export_model(args.checkpoint, graph)
76
+ (args.output / 'export.json').write_text(json.dumps({'seconds': time.perf_counter() - start}), encoding='utf-8')
77
+ return
78
+ import zarr
79
+ source = np.load(args.input, allow_pickle=False)
80
+ assert source.dtype == np.uint8 and source.ndim == 3 and min(source.shape[-2:]) >= 96 and source.shape[0] >= 16
81
+ source = source[:, :96, :96]
82
+ assert source.any(), 'A blank array is not a real-data demonstration'
83
+ cases = [('overlap', source, False), ('reverse', source, True),
84
+ ('padded_edge', source[:, :45, :53], False)]
85
+ masked = source.copy()
86
+ masked[:, :, :9] = 0
87
+ cases.append(('empty_columns', masked, False))
88
+ cases.append(('blank_control', np.zeros_like(source), False))
89
+ reference, cpu_seconds = {}, {}
90
+ if args.stage == 'cpu':
91
+ model = hecate.load_model(args.checkpoint, 'cpu')
92
+ for name, volume, reverse in cases:
93
+ value = np.zeros(volume.shape[-2:], np.uint8)
94
+ start = time.perf_counter()
95
+ hecate.predict(model, volume, value, reverse=reverse)
96
+ cpu_seconds[name] = time.perf_counter() - start
97
+ Image.fromarray(value).save(args.output / f'{name}_cpu.png')
98
+ (args.output / 'cpu.json').write_text(json.dumps(cpu_seconds), encoding='utf-8')
99
+ return
100
+ cpu_seconds = json.loads((args.output / 'cpu.json').read_text())
101
+ export_seconds = json.loads((args.output / 'export.json').read_text())['seconds']
102
+ reference = {name: np.asarray(Image.open(args.output / f'{name}_cpu.png')) for name, _, _ in cases}
103
+ check_failure_cleanup(source, args.output)
104
+ session = dml.load_session(graph, profile_prefix=str(args.output / 'dispatch'))
105
+ rows = []
106
+ for name, volume, reverse in cases:
107
+ value = np.zeros(volume.shape[-2:], np.uint8)
108
+ start = time.perf_counter()
109
+ stats = dml.predict(session, volume, value, reverse=reverse)
110
+ elapsed = time.perf_counter() - start
111
+ difference = np.abs(value.astype(np.int16) - reference[name].astype(np.int16))
112
+ row = {'case': name, 'shape': list(volume.shape), 'max_uint8_difference': int(difference.max()),
113
+ 'different_pixels': int(np.count_nonzero(difference)), 'pixels': int(value.size),
114
+ 'cpu_seconds': cpu_seconds[name], 'directml_seconds': elapsed, **stats}
115
+ Image.fromarray(value).save(args.output / f'{name}_directml.png')
116
+ rows.append(row)
117
+ print(json.dumps(row), flush=True)
118
+ assert row['max_uint8_difference'] <= 1, row
119
+ if name == 'empty_columns':
120
+ assert not value[:, :9].any()
121
+ if name == 'blank_control':
122
+ assert not value.any() and stats['model_forwards'] == 0
123
+ repeated = np.zeros(source.shape[-2:], np.uint8)
124
+ dml.predict(session, source, repeated)
125
+ assert np.array_equal(repeated, np.asarray(Image.open(args.output / 'overlap_directml.png')))
126
+ profile = dml.finish_profile(session)
127
+ assert set(profile['executed_providers']) == {'DmlExecutionProvider'}
128
+ del session
129
+ gc.collect()
130
+ volume_path = args.output / 'surface.zarr'
131
+ options = {'zarr_format': 2} if int(zarr.__version__.split('.')[0]) >= 3 else {}
132
+ volume = zarr.open_array(str(volume_path), mode='w', shape=source.shape,
133
+ chunks=(16, 32, 32), dtype='uint8', **options)
134
+ volume[:] = source
135
+ cli_png = args.output / 'cli.png'
136
+ command = [sys.executable, str(Path(dml.__file__)), 'predict', '--model', str(graph),
137
+ '--input', str(volume_path), '--spacing-um', '9.6', '--output', str(cli_png), '--profile']
138
+ subprocess.run(command, check=True)
139
+ assert np.array_equal(np.asarray(Image.open(cli_png)), repeated)
140
+ cli = json.loads(cli_png.with_suffix('.json').read_text())
141
+ assert set(cli['profile']['executed_providers']) == {'DmlExecutionProvider'}
142
+ assert cli['checkpoint_sha256'] == sha(args.checkpoint)
143
+ # Refuse accidental output replacement and a wrong physical spacing.
144
+ for change in ('existing_output', 'wrong_spacing'):
145
+ bad = command.copy()
146
+ if change == 'wrong_spacing':
147
+ bad[bad.index('--spacing-um') + 1] = '2.4'
148
+ bad[bad.index('--output') + 1] = str(args.output / 'wrong_spacing.png')
149
+ result = subprocess.run(bad, capture_output=True, text=True)
150
+ assert result.returncode != 0, change
151
+ result = {'status': 'PASS', 'checkpoint_sha256': sha(args.checkpoint), 'input_sha256': sha(args.input),
152
+ 'hecate_sha256': sha(Path(hecate.__file__)), 'helper_sha256': sha(Path(dml.__file__)),
153
+ 'export_seconds': export_seconds, 'rows': rows, 'exact_repeat': True,
154
+ 'zarr_cli_exact': True, 'failure_cleanup': True, 'cpu_fallback_disabled': True,
155
+ 'executed_providers': profile['executed_providers'],
156
+ 'environment': {'python': sys.version.split()[0], 'torch': torch.__version__},
157
+ 'scope': 'Backend equivalence on a real exposed crop and explicit edge controls; no new text or model-accuracy claim.'}
158
+ (args.output / 'result.json').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8')
159
+ print('PASS: real-data parity, reversed depth, padding, empty columns, replay and Zarr CLI', flush=True)
160
+
161
+
162
+ if __name__ == '__main__':
163
+ main()