Add optional DirectML inference for Hecate 9.6 µm on Windows
#2
by MohskiBroski - opened
- DIRECTML.md +67 -0
- directml_comparison.png +0 -0
- directml_validation.json +129 -0
- fetch_example.py +67 -0
- hecate_directml.py +228 -0
- requirements-directml.txt +4 -0
- requirements-verify.txt +3 -0
- verify_directml.py +163 -0
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 |
+

|
| 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()
|