aind-ibl-ephys-alignment-preprocessing 0.4.2__tar.gz → 0.4.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/CHANGELOG.md +13 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/PKG-INFO +1 -1
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/pyproject.toml +1 -1
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/_async/histology.py +11 -7
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/_async/pipeline.py +13 -7
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/co_asset_resolver.py +30 -18
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/histology.py +33 -19
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/pipeline.py +12 -4
- aind_ibl_ephys_alignment_preprocessing-0.4.4/src/aind_ibl_ephys_alignment_preprocessing/registration_frame.py +229 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/stages.py +4 -2
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_co_asset_resolver.py +122 -0
- aind_ibl_ephys_alignment_preprocessing-0.4.4/tests/test_registration_frame.py +154 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_registration_override.py +23 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_sync_async_parity.py +3 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/uv.lock +1 -1
- aind_ibl_ephys_alignment_preprocessing-0.4.2/src/aind_ibl_ephys_alignment_preprocessing/registration_frame.py +0 -195
- aind_ibl_ephys_alignment_preprocessing-0.4.2/tests/test_registration_frame.py +0 -148
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.copier-answers.yml +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/ISSUE_TEMPLATE/user-story.md +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/dependabot.yml +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/workflows/bump-call.yml +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/workflows/ci-call.yml +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/workflows/docs-ci.yml +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/workflows/publish-call.yml +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.gitignore +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.python-version +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.readthedocs.yaml +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/CLAUDE.md +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/LICENSE +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/README.md +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/external_asset_references.md +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/source/_static/dark-logo.svg +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/source/_static/favicon.ico +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/source/_static/light-logo.svg +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/source/api.rst +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/source/conf.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/source/index.md +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/scripts/run_linters_and_checks.sh +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/__init__.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/_async/__init__.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/_async/concurrency.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/_async/ephys.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/_async/probes.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/_constants.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/_timing.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/coverage.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/datapackage.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/datapackage_schema.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/discovery.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/ephys.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/probes.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/py.typed +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/schemas/aind-ibl-ephys-alignment-datapackage/3.1.0/datapackage.schema.json +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/schemas/aind-ibl-ephys-alignment-datapackage/3.2.0/datapackage.schema.json +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/schemas/aind-ibl-ephys-alignment-datapackage/4.0.0/datapackage.schema.json +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/schemas/aind-ibl-ephys-alignment-datapackage/4.1.0/datapackage.schema.json +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/scripts/__init__.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/scripts/export_datapackage_schema.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/scripts/run.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/types.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/src/aind_ibl_ephys_alignment_preprocessing/validation.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/__init__.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_ants_domain.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_coverage.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_datapackage.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_datapackage_schema.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_example.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_geometry_sidecar.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_resample.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_sorting_detection.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_stages.py +0 -0
- {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_timing.py +0 -0
|
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## v0.4.4 (2026-08-26)
|
|
9
|
+
|
|
10
|
+
### Fix
|
|
11
|
+
|
|
12
|
+
- **resolver**: accept asset names without the modality prefix
|
|
13
|
+
|
|
14
|
+
## v0.4.3 (2026-08-26)
|
|
15
|
+
|
|
16
|
+
### Fix
|
|
17
|
+
|
|
18
|
+
- decide the frame relatively, and never warp onto a header stub
|
|
19
|
+
- take the anatomical size from the caller, not the header stub
|
|
20
|
+
|
|
8
21
|
## v0.4.2 (2026-08-25)
|
|
9
22
|
|
|
10
23
|
### Fix
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: aind-ibl-ephys-alignment-preprocessing
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Library to prepare histology and ephys for the IBL ephys alignment GUI
|
|
5
5
|
Project-URL: Repository, https://github.com/AllenNeuralDynamics/aind-ibl-ephys-alignment-preprocessing/
|
|
6
6
|
Project-URL: Issues, https://github.com/AllenNeuralDynamics/aind-ibl-ephys-alignment-preprocessing/issues
|
|
@@ -157,7 +157,7 @@ async def write_registration_channel_images_async(
|
|
|
157
157
|
|
|
158
158
|
async def apply_ccf_inverse_tx_then_fix_domain_async(
|
|
159
159
|
ccf_space_img_moving: ANTsImage,
|
|
160
|
-
|
|
160
|
+
fixed_img: ANTsImage,
|
|
161
161
|
correct_hist_domain_img: ANTsImage,
|
|
162
162
|
asset_info: AssetInfo,
|
|
163
163
|
limits: Limits,
|
|
@@ -165,8 +165,12 @@ async def apply_ccf_inverse_tx_then_fix_domain_async(
|
|
|
165
165
|
**kwargs: Any,
|
|
166
166
|
) -> ANTsImage:
|
|
167
167
|
"""Async twin of :func:`~...histology.apply_ccf_inverse_tx_then_fix_domain`."""
|
|
168
|
+
if int(np.prod(fixed_img.shape)) <= 1:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"fixed_img is a {fixed_img.shape} header stub; ants.apply_transforms would emit a "
|
|
171
|
+
"single-voxel volume. Build it with ants_warp_domain, not ants_domain_stub."
|
|
172
|
+
)
|
|
168
173
|
pt_tx_str, pt_tx_inverted = asset_info.point_chain()
|
|
169
|
-
fixed = pipeline_space_fixed_img if frame.regrid_to_pipeline else correct_hist_domain_img
|
|
170
174
|
interpolator = str(kwargs.get("interpolator", "linear"))
|
|
171
175
|
# ``timed`` is a *sync* context manager, so it cannot share the ``async
|
|
172
176
|
# with``. Nesting it inside the semaphore is also what we want: it then
|
|
@@ -175,7 +179,7 @@ async def apply_ccf_inverse_tx_then_fix_domain_async(
|
|
|
175
179
|
with timed("histology.warp", interpolator=interpolator):
|
|
176
180
|
ccf_img_in_hist_space: ANTsImage = await to_thread_logged(
|
|
177
181
|
ants.apply_transforms,
|
|
178
|
-
fixed=
|
|
182
|
+
fixed=fixed_img,
|
|
179
183
|
moving=ccf_space_img_moving,
|
|
180
184
|
transformlist=pt_tx_str,
|
|
181
185
|
whichtoinvert=pt_tx_inverted,
|
|
@@ -192,7 +196,7 @@ async def transform_ccf_to_image_space_async(
|
|
|
192
196
|
asset_info: AssetInfo,
|
|
193
197
|
refs: ReferenceVolumes,
|
|
194
198
|
raw_hist_img: ANTsImage,
|
|
195
|
-
|
|
199
|
+
fixed_img: ANTsImage,
|
|
196
200
|
outputs: OutputDirs,
|
|
197
201
|
limits: Limits,
|
|
198
202
|
frame: RegistrationFrame,
|
|
@@ -201,7 +205,7 @@ async def transform_ccf_to_image_space_async(
|
|
|
201
205
|
logger.info("[CCF Transform] Starting CCF template -> image space transform")
|
|
202
206
|
ccf_in_hist_img = await apply_ccf_inverse_tx_then_fix_domain_async(
|
|
203
207
|
refs.ccf_25,
|
|
204
|
-
|
|
208
|
+
fixed_img=fixed_img,
|
|
205
209
|
correct_hist_domain_img=raw_hist_img,
|
|
206
210
|
asset_info=asset_info,
|
|
207
211
|
limits=limits,
|
|
@@ -219,7 +223,7 @@ async def transform_ccf_labels_to_image_space_async(
|
|
|
219
223
|
asset_info: AssetInfo,
|
|
220
224
|
ref_paths: ReferencePaths,
|
|
221
225
|
raw_hist_img: ANTsImage,
|
|
222
|
-
|
|
226
|
+
fixed_img: ANTsImage,
|
|
223
227
|
outputs: OutputDirs,
|
|
224
228
|
limits: Limits,
|
|
225
229
|
frame: RegistrationFrame,
|
|
@@ -234,7 +238,7 @@ async def transform_ccf_labels_to_image_space_async(
|
|
|
234
238
|
unq_vals = np.load(str(ref_paths.ccf_labels_lateralized_25_unq_vals))["unique_labels"]
|
|
235
239
|
ccf_labels_in_hist_img = await apply_ccf_inverse_tx_then_fix_domain_async(
|
|
236
240
|
ccf_labels_lateralized_25,
|
|
237
|
-
|
|
241
|
+
fixed_img=fixed_img,
|
|
238
242
|
correct_hist_domain_img=raw_hist_img,
|
|
239
243
|
asset_info=asset_info,
|
|
240
244
|
limits=limits,
|
|
@@ -101,6 +101,12 @@ async def _create_volumes_async(
|
|
|
101
101
|
with timed("histology.ants_domain"):
|
|
102
102
|
pipeline_img_ants = ants_warp_domain(pipeline_header, "registration-pipeline", warp_dtype)
|
|
103
103
|
raw_img_ants = ants_domain_stub(base_header, "registration")
|
|
104
|
+
# The warp's ``fixed`` sets the output grid, so it must be full extent AND in
|
|
105
|
+
# the frame the transform expects. ``raw_img_ants`` stays a stub: it only
|
|
106
|
+
# stamps the true header back on after a pipeline-frame warp.
|
|
107
|
+
ccf_fixed_ants = (
|
|
108
|
+
pipeline_img_ants if frame.regrid_to_pipeline else ants_warp_domain(base_header, "registration", warp_dtype)
|
|
109
|
+
)
|
|
104
110
|
logger.info(
|
|
105
111
|
"[Histology] Starting parallel processing: %d additional channel(s), CCF template + labels transforms",
|
|
106
112
|
num_additional,
|
|
@@ -121,14 +127,12 @@ async def _create_volumes_async(
|
|
|
121
127
|
name="process-additional-channels",
|
|
122
128
|
)
|
|
123
129
|
tg.create_task(
|
|
124
|
-
transform_ccf_to_image_space_async(
|
|
125
|
-
asset_info, ref_imgs, raw_img_ants, pipeline_img_ants, out, limits, frame
|
|
126
|
-
),
|
|
130
|
+
transform_ccf_to_image_space_async(asset_info, ref_imgs, raw_img_ants, ccf_fixed_ants, out, limits, frame),
|
|
127
131
|
name="transform-ccf-template-to-image",
|
|
128
132
|
)
|
|
129
133
|
tg.create_task(
|
|
130
134
|
transform_ccf_labels_to_image_space_async(
|
|
131
|
-
asset_info, ref_paths, raw_img_ants,
|
|
135
|
+
asset_info, ref_paths, raw_img_ants, ccf_fixed_ants, out, limits, frame
|
|
132
136
|
),
|
|
133
137
|
name="transform-ccf-labels-to-image",
|
|
134
138
|
)
|
|
@@ -192,15 +196,17 @@ async def run_pipeline_async(config: PipelineConfig, max_workers: int = 40) -> l
|
|
|
192
196
|
# Decided once, here, and handed to both branches: the volumes and the probe
|
|
193
197
|
# points enter the same image-to-template transform, so they cannot disagree
|
|
194
198
|
# about the frame it expects. The stub is header-only, so this is cheap.
|
|
195
|
-
raw_img_stub,
|
|
199
|
+
raw_img_stub, pipeline_img_stub, native_size = await to_thread_logged(
|
|
196
200
|
base_and_pipeline_anatomical_stub,
|
|
197
201
|
asset_info.zarr_volumes.registration,
|
|
198
202
|
asset_info.zarr_volumes.metadata,
|
|
199
203
|
asset_info.zarr_volumes.processing,
|
|
200
204
|
opened_zarr=(node, zarr_metadata),
|
|
201
205
|
)
|
|
202
|
-
reg_frame = resolve_registration_frame(
|
|
203
|
-
|
|
206
|
+
reg_frame = resolve_registration_frame(
|
|
207
|
+
asset_info.registration_dir_path, raw_img_stub, pipeline_img_stub, native_size
|
|
208
|
+
)
|
|
209
|
+
del raw_img_stub, pipeline_img_stub
|
|
204
210
|
|
|
205
211
|
skip_ephys_msg = " (ephys disabled)" if config.skip_ephys else ""
|
|
206
212
|
logger.info(
|
|
@@ -10,7 +10,7 @@ Design (verified live against 791094 and 781370 on 2026-07-09):
|
|
|
10
10
|
|
|
11
11
|
* **Raw recordings** -- taken from the chosen sorting's ``provenance.data_assets``,
|
|
12
12
|
which names the recording it was produced from. Falls back to a
|
|
13
|
-
``name
|
|
13
|
+
``name:<mouseid>`` search filtered to ``tag:raw`` when a sorting
|
|
14
14
|
carries no usable provenance, and warns when it does.
|
|
15
15
|
* **SmartSPIM** -- taken from the Neuroglancer image-layer source, which *is* the
|
|
16
16
|
acquisition the tracks were drawn on (unambiguous even when a mouse was imaged
|
|
@@ -146,18 +146,22 @@ class AssetResolution:
|
|
|
146
146
|
return pairs
|
|
147
147
|
|
|
148
148
|
|
|
149
|
-
# recording key: "<mouseid>_<date>_<time>"
|
|
149
|
+
# recording key: "<mouseid>_<date>_<time>" -- the session identity, which is the
|
|
150
|
+
# whole asset name under the current AIND convention and follows an `ecephys_`
|
|
151
|
+
# prefix under the older one. Both are in use, so the prefix is optional.
|
|
150
152
|
# The suffix is optional because a re-uploaded raw keeps the key and adds to it
|
|
151
153
|
# (`..._corrected`); anchoring the pattern hid those completely.
|
|
152
|
-
_RAW_RE = re.compile(r"^ecephys_(?P<key>\d+_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})(?P<suffix>_.+)?$")
|
|
154
|
+
_RAW_RE = re.compile(r"^(?:ecephys_)?(?P<key>\d+_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})(?P<suffix>_.+)?$")
|
|
153
155
|
# Suffixes that mark a *derived* product rather than another upload of the raw.
|
|
154
156
|
# Without this the relaxed pattern above would also accept `..._sorted_<ts>`.
|
|
155
|
-
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
r"
|
|
159
|
-
r"(?P<ts>\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})$"
|
|
157
|
+
# `processed` is the behavior sibling of a session: with the modality prefix gone
|
|
158
|
+
# it shares the recording key with the raw, so only the suffix separates them.
|
|
159
|
+
_DERIVED_SUFFIX_RE = re.compile(
|
|
160
|
+
r"(?:^|_)(sorted|processed|preprocessed|curated|nwb|oversplit|ccg)(?:[_-]|$)", re.IGNORECASE
|
|
160
161
|
)
|
|
162
|
+
# a pinned sorted_recording is `<raw asset name>_sorted_<sort_ts>`, so the base is
|
|
163
|
+
# whatever names the recording -- validated by _RAW_RE rather than re-spelled here.
|
|
164
|
+
_PINNED_RE = re.compile(r"^(?P<base>.+)_sorted_(?P<ts>\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})$")
|
|
161
165
|
# extract the SmartSPIM acquisition name from an s3 image-source uri
|
|
162
166
|
_SMARTSPIM_RE = re.compile(r"(SmartSPIM_[^/]+?)/")
|
|
163
167
|
|
|
@@ -215,8 +219,10 @@ def parse_pinned(sorted_recording: str) -> tuple[str, str] | None:
|
|
|
215
219
|
Parameters
|
|
216
220
|
----------
|
|
217
221
|
sorted_recording : str
|
|
218
|
-
A pinned sorting name
|
|
219
|
-
``ecephys_791094_2025-10-08_16-48-57_sorted_2026-04-24_13-43-00
|
|
222
|
+
A pinned sorting name: the raw asset's name plus ``_sorted_<ts>``, e.g.
|
|
223
|
+
``ecephys_791094_2025-10-08_16-48-57_sorted_2026-04-24_13-43-00`` or,
|
|
224
|
+
without the modality prefix,
|
|
225
|
+
``823993_2026-04-14_10-56-43_sorted_2026-04-15_12-04-52``.
|
|
220
226
|
|
|
221
227
|
Returns
|
|
222
228
|
-------
|
|
@@ -224,7 +230,12 @@ def parse_pinned(sorted_recording: str) -> tuple[str, str] | None:
|
|
|
224
230
|
``(recording_key, sort_timestamp)`` or None if malformed.
|
|
225
231
|
"""
|
|
226
232
|
match = _PINNED_RE.match(sorted_recording)
|
|
227
|
-
|
|
233
|
+
if match is None:
|
|
234
|
+
return None
|
|
235
|
+
# The base has to name a recording, which also rejects a re-sorting
|
|
236
|
+
# (`..._sorted_<ts1>_sorted_<ts2>`): its base carries a derived suffix.
|
|
237
|
+
key = raw_key_of(match.group("base"))
|
|
238
|
+
return (key, match.group("ts")) if key is not None else None
|
|
228
239
|
|
|
229
240
|
|
|
230
241
|
def _prefer_external(assets: list[CandidateAsset]) -> CandidateAsset:
|
|
@@ -316,9 +327,10 @@ def sibling_captures(asset: CandidateAsset, pool: list[CandidateAsset]) -> list[
|
|
|
316
327
|
def raw_key_of(name: str) -> str | None:
|
|
317
328
|
"""Return the recording key an asset name denotes, if it names a raw upload.
|
|
318
329
|
|
|
319
|
-
``None`` for anything that
|
|
320
|
-
derived products that share the
|
|
321
|
-
which the relaxed :data:`_RAW_RE` would
|
|
330
|
+
``None`` for anything that does not name a recording session, and for the
|
|
331
|
+
derived products that share the session name (``..._sorted_<ts>``,
|
|
332
|
+
``..._processed_<ts>`` and friends), which the relaxed :data:`_RAW_RE` would
|
|
333
|
+
otherwise accept.
|
|
322
334
|
|
|
323
335
|
Parameters
|
|
324
336
|
----------
|
|
@@ -377,7 +389,7 @@ def _resolve_raw(
|
|
|
377
389
|
recording_keys : set[str]
|
|
378
390
|
Recording keys the run needs a raw asset for.
|
|
379
391
|
raw_candidates : list[CandidateAsset]
|
|
380
|
-
Result of the ``name
|
|
392
|
+
Result of the ``name:<mouseid>`` search -- the fallback pool.
|
|
381
393
|
sortings : dict[str, CandidateAsset]
|
|
382
394
|
Resolved sortings, keyed by recording key; the provenance source.
|
|
383
395
|
pool : list[CandidateAsset]
|
|
@@ -421,7 +433,7 @@ def _resolve_raw(
|
|
|
421
433
|
|
|
422
434
|
hits = by_name_key.get(key, [])
|
|
423
435
|
if not hits:
|
|
424
|
-
warnings.append(f"{key}: no raw recording asset found (name:
|
|
436
|
+
warnings.append(f"{key}: no raw recording asset found (name:{mouseid})")
|
|
425
437
|
continue
|
|
426
438
|
chosen = _prefer_external(hits)
|
|
427
439
|
if sorting is not None and sorting.source_assets:
|
|
@@ -487,7 +499,7 @@ def _resolve_pinned_sortings(
|
|
|
487
499
|
date = key.split("_", 1)[1].rsplit("_", 1)[0] # <mouseid>_<date>_<time> -> <date>
|
|
488
500
|
# recording time is optional because sorting asset names sometimes drop it
|
|
489
501
|
fuzzy = re.compile(
|
|
490
|
-
rf"^ecephys_{re.escape(mouseid)}_{re.escape(date)}"
|
|
502
|
+
rf"^(?:ecephys_)?{re.escape(mouseid)}_{re.escape(date)}"
|
|
491
503
|
rf"(?:_\d{{2}}-\d{{2}}-\d{{2}})?_sorted_{re.escape(sort_ts)}$"
|
|
492
504
|
)
|
|
493
505
|
hits = [a for a in tagged_all if fuzzy.match(a.name)]
|
|
@@ -541,7 +553,7 @@ def resolve(
|
|
|
541
553
|
User-pinned ``sorted_recording`` names (one per recording, from the
|
|
542
554
|
manifest / bridge config).
|
|
543
555
|
raw_candidates : list[CandidateAsset]
|
|
544
|
-
Result of ``name
|
|
556
|
+
Result of ``name:<mouseid>`` (state-ready).
|
|
545
557
|
smartspim_candidates : list[CandidateAsset]
|
|
546
558
|
Result of ``name:SmartSPIM_<mouseid>`` (NOT ``tag:<mouseid>`` -- processed
|
|
547
559
|
SmartSPIM assets are sometimes tagged only ``[smartspim, processed]``).
|
|
@@ -585,7 +585,7 @@ def process_additional_channels_pipeline(
|
|
|
585
585
|
|
|
586
586
|
def apply_ccf_inverse_tx_then_fix_domain(
|
|
587
587
|
ccf_space_img_moving: ANTsImage,
|
|
588
|
-
|
|
588
|
+
fixed_img: ANTsImage,
|
|
589
589
|
correct_hist_domain_img: ANTsImage,
|
|
590
590
|
asset_info: AssetInfo,
|
|
591
591
|
frame: RegistrationFrame,
|
|
@@ -593,20 +593,23 @@ def apply_ccf_inverse_tx_then_fix_domain(
|
|
|
593
593
|
) -> ANTsImage:
|
|
594
594
|
"""Resample a CCF-space image into histology space, in the frame the transform expects.
|
|
595
595
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
596
|
+
*fixed_img* must already be the full-extent domain for that frame -- the
|
|
597
|
+
pipeline's anchored geometry when re-gridding, the volume's own otherwise.
|
|
598
|
+
A pipeline transform's output then carries the anchored header, so it is
|
|
599
|
+
relabelled afterwards from *correct_hist_domain_img*; the grid is right and
|
|
600
|
+
only its declared placement is the pipeline's. Honoring a sidecar leaves
|
|
601
|
+
nothing to relabel.
|
|
601
602
|
|
|
602
603
|
Parameters
|
|
603
604
|
----------
|
|
604
605
|
ccf_space_img_moving : ANTsImage
|
|
605
606
|
Image in CCF/template space to move into histology space.
|
|
606
|
-
|
|
607
|
-
|
|
607
|
+
fixed_img : ANTsImage
|
|
608
|
+
Full-extent output domain, in the frame *frame* selected. Its size and
|
|
609
|
+
pixel type become the output's, so a header stub will not do.
|
|
608
610
|
correct_hist_domain_img : ANTsImage
|
|
609
|
-
|
|
611
|
+
Carries the volume's true spacing/origin/direction for the relabel. Read
|
|
612
|
+
for its header only, so a stub is fine.
|
|
610
613
|
asset_info : AssetInfo
|
|
611
614
|
Registration chain paths.
|
|
612
615
|
frame : RegistrationFrame
|
|
@@ -618,11 +621,22 @@ def apply_ccf_inverse_tx_then_fix_domain(
|
|
|
618
621
|
-------
|
|
619
622
|
ANTsImage
|
|
620
623
|
Transformed image on the histology grid.
|
|
624
|
+
|
|
625
|
+
Raises
|
|
626
|
+
------
|
|
627
|
+
ValueError
|
|
628
|
+
If *fixed_img* is a header stub. ``ants.apply_transforms`` takes the
|
|
629
|
+
output size from ``fixed``, so a 1x1x1 domain silently yields a
|
|
630
|
+
single-voxel volume rather than failing.
|
|
621
631
|
"""
|
|
632
|
+
if int(np.prod(fixed_img.shape)) <= 1:
|
|
633
|
+
raise ValueError(
|
|
634
|
+
f"fixed_img is a {fixed_img.shape} header stub; ants.apply_transforms would emit a "
|
|
635
|
+
"single-voxel volume. Build it with ants_warp_domain, not ants_domain_stub."
|
|
636
|
+
)
|
|
622
637
|
pt_tx_str, pt_tx_inverted = asset_info.point_chain()
|
|
623
|
-
fixed = pipeline_space_fixed_img if frame.regrid_to_pipeline else correct_hist_domain_img
|
|
624
638
|
ccf_space_img_in_hist_space: ANTsImage = ants.apply_transforms(
|
|
625
|
-
fixed=
|
|
639
|
+
fixed=fixed_img,
|
|
626
640
|
moving=ccf_space_img_moving,
|
|
627
641
|
transformlist=pt_tx_str,
|
|
628
642
|
whichtoinvert=pt_tx_inverted,
|
|
@@ -667,7 +681,7 @@ def transform_ccf_to_image_space(
|
|
|
667
681
|
asset_info: AssetInfo,
|
|
668
682
|
refs: ReferenceVolumes,
|
|
669
683
|
raw_hist_img: ANTsImage,
|
|
670
|
-
|
|
684
|
+
fixed_img: ANTsImage,
|
|
671
685
|
outputs: OutputDirs,
|
|
672
686
|
frame: RegistrationFrame,
|
|
673
687
|
) -> None:
|
|
@@ -681,8 +695,8 @@ def transform_ccf_to_image_space(
|
|
|
681
695
|
Reference volumes.
|
|
682
696
|
raw_hist_img : ANTsImage
|
|
683
697
|
Histology image with correct spatial domain.
|
|
684
|
-
|
|
685
|
-
|
|
698
|
+
fixed_img : ANTsImage
|
|
699
|
+
Full-extent output domain in the frame *frame* selected.
|
|
686
700
|
outputs : OutputDirs
|
|
687
701
|
Output directory tree.
|
|
688
702
|
frame : RegistrationFrame
|
|
@@ -690,7 +704,7 @@ def transform_ccf_to_image_space(
|
|
|
690
704
|
"""
|
|
691
705
|
ccf_in_hist_img = apply_ccf_inverse_tx_then_fix_domain(
|
|
692
706
|
refs.ccf_25,
|
|
693
|
-
|
|
707
|
+
fixed_img=fixed_img,
|
|
694
708
|
correct_hist_domain_img=raw_hist_img,
|
|
695
709
|
asset_info=asset_info,
|
|
696
710
|
frame=frame,
|
|
@@ -706,7 +720,7 @@ def transform_ccf_labels_to_image_space(
|
|
|
706
720
|
asset_info: AssetInfo,
|
|
707
721
|
ref_paths: ReferencePaths,
|
|
708
722
|
raw_hist_img: ANTsImage,
|
|
709
|
-
|
|
723
|
+
fixed_img: ANTsImage,
|
|
710
724
|
outputs: OutputDirs,
|
|
711
725
|
frame: RegistrationFrame,
|
|
712
726
|
) -> None:
|
|
@@ -720,8 +734,8 @@ def transform_ccf_labels_to_image_space(
|
|
|
720
734
|
Reference data paths.
|
|
721
735
|
raw_hist_img : ANTsImage
|
|
722
736
|
Histology image with correct spatial domain.
|
|
723
|
-
|
|
724
|
-
|
|
737
|
+
fixed_img : ANTsImage
|
|
738
|
+
Full-extent output domain in the frame *frame* selected.
|
|
725
739
|
outputs : OutputDirs
|
|
726
740
|
Output directory tree.
|
|
727
741
|
frame : RegistrationFrame
|
|
@@ -734,7 +748,7 @@ def transform_ccf_labels_to_image_space(
|
|
|
734
748
|
unq_vals = np.load(str(ref_paths.ccf_labels_lateralized_25_unq_vals))["unique_labels"]
|
|
735
749
|
ccf_labels_in_hist_img = apply_ccf_inverse_tx_then_fix_domain(
|
|
736
750
|
ccf_labels_lateralized_25,
|
|
737
|
-
|
|
751
|
+
fixed_img=fixed_img,
|
|
738
752
|
correct_hist_domain_img=raw_hist_img,
|
|
739
753
|
asset_info=asset_info,
|
|
740
754
|
frame=frame,
|
|
@@ -80,13 +80,15 @@ def run_pipeline(config: PipelineConfig) -> list[ProcessResult]:
|
|
|
80
80
|
# Header-only stubs, and one frame decision for the whole run: every CCF
|
|
81
81
|
# product below enters the same image-to-template transform, so they must all
|
|
82
82
|
# agree on the frame it expects its input in.
|
|
83
|
-
raw_img_stub, raw_img_stub_buggy,
|
|
83
|
+
raw_img_stub, raw_img_stub_buggy, native_size = base_and_pipeline_anatomical_stub(
|
|
84
84
|
asset_info.zarr_volumes.registration,
|
|
85
85
|
asset_info.zarr_volumes.metadata,
|
|
86
86
|
asset_info.zarr_volumes.processing,
|
|
87
87
|
opened_zarr=(node, zarr_metadata),
|
|
88
88
|
)
|
|
89
|
-
reg_frame = resolve_registration_frame(
|
|
89
|
+
reg_frame = resolve_registration_frame(
|
|
90
|
+
asset_info.registration_dir_path, raw_img_stub, raw_img_stub_buggy, native_size
|
|
91
|
+
)
|
|
90
92
|
|
|
91
93
|
raw_img_path, base_header, pipeline_header, warp_dtype = write_registration_channel_images(
|
|
92
94
|
asset_info,
|
|
@@ -99,6 +101,12 @@ def run_pipeline(config: PipelineConfig) -> list[ProcessResult]:
|
|
|
99
101
|
# it is. Neither is read back from disk.
|
|
100
102
|
pipeline_img_ants = ants_warp_domain(pipeline_header, "registration-pipeline", warp_dtype)
|
|
101
103
|
raw_img_ants = ants_domain_stub(base_header, "registration")
|
|
104
|
+
# The warp's ``fixed`` sets the output grid, so it must be full extent AND in
|
|
105
|
+
# the frame the transform expects. ``raw_img_ants`` stays a stub: it only
|
|
106
|
+
# stamps the true header back on after a pipeline-frame warp.
|
|
107
|
+
ccf_fixed_ants = (
|
|
108
|
+
pipeline_img_ants if reg_frame.regrid_to_pipeline else ants_warp_domain(base_header, "registration", warp_dtype)
|
|
109
|
+
)
|
|
102
110
|
|
|
103
111
|
scratch_root = Path(config.scratch_root) if config.scratch_root is not None else Path(tempfile.mkdtemp())
|
|
104
112
|
scratch_root.mkdir(parents=True, exist_ok=True)
|
|
@@ -113,8 +121,8 @@ def run_pipeline(config: PipelineConfig) -> list[ProcessResult]:
|
|
|
113
121
|
output_voxel_size_um=config.output_voxel_size_um,
|
|
114
122
|
emit_qc=config.emit_qc,
|
|
115
123
|
)
|
|
116
|
-
transform_ccf_to_image_space(asset_info, ref_imgs, raw_img_ants,
|
|
117
|
-
transform_ccf_labels_to_image_space(asset_info, ref_paths, raw_img_ants,
|
|
124
|
+
transform_ccf_to_image_space(asset_info, ref_imgs, raw_img_ants, ccf_fixed_ants, out, reg_frame)
|
|
125
|
+
transform_ccf_labels_to_image_space(asset_info, ref_paths, raw_img_ants, ccf_fixed_ants, out, reg_frame)
|
|
118
126
|
|
|
119
127
|
processed_recordings: set[str] = set()
|
|
120
128
|
processed_results: list[ProcessResult] = []
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Decide which physical frame an image-to-template transform expects its input in.
|
|
2
|
+
|
|
3
|
+
The SmartSPIM pipeline registers an anatomical volume whose origin comes from an
|
|
4
|
+
undocumented anchoring overlay, so points must be re-gridded onto that anchored
|
|
5
|
+
geometry before the ANTs chain -- the behaviour ``aind_zarr_utils`` exists to
|
|
6
|
+
mimic. Off-pipeline registrations ship a sidecar declaring the domain they were
|
|
7
|
+
actually computed in; there the re-grid is wrong, and the declared domain is what
|
|
8
|
+
must be honored. Getting this backwards is silent: the points still land
|
|
9
|
+
somewhere, and the region names they pick up still read plausibly.
|
|
10
|
+
|
|
11
|
+
Presence of a sidecar selects the branch, but the *domain it declares* is what is
|
|
12
|
+
used, so a future off-pipeline capsule that adopts the anchored geometry stays
|
|
13
|
+
correct without a change here.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import SimpleITK as sitk
|
|
23
|
+
from aind_ants_transform_sidecar import Domain, load_package
|
|
24
|
+
from aind_registration_utils.domains import ImageDomainAxisAligned, ImageHeader
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
#: Sidecar filenames looked for beside the image-to-template affine, in order.
|
|
29
|
+
#: Deliberately narrower than the alignment GUI's list, which also tries
|
|
30
|
+
#: ``<affine stem>.json``: a hard failure on a mismatched domain must not be
|
|
31
|
+
#: reachable by an unrelated JSON that happens to sit next to the transform.
|
|
32
|
+
REGISTRATION_SIDECAR_NAMES = (
|
|
33
|
+
"ls_to_template_transform_information.json",
|
|
34
|
+
"transform_information.json",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
#: Floor for the plausibility check, in mm. The frame is chosen by which candidate
|
|
38
|
+
#: is *nearer*, so this decides nothing -- it only rejects a sidecar that belongs to
|
|
39
|
+
#: some other volume. Set well above any legitimate resampling disagreement (tens of
|
|
40
|
+
#: microns) and well below the millimetres that separate the two frames.
|
|
41
|
+
DOMAIN_SANITY_MM = 1.0
|
|
42
|
+
|
|
43
|
+
#: Voxel-scaled companion to :data:`DOMAIN_SANITY_MM`, so a registration run at a
|
|
44
|
+
#: very coarse resolution is not judged against a floor finer than its own grid.
|
|
45
|
+
#: The larger of the two applies.
|
|
46
|
+
DOMAIN_TOLERANCE_VOXELS = 4.0
|
|
47
|
+
|
|
48
|
+
_AXES = ("L", "P", "S")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class RegistrationFrame:
|
|
53
|
+
"""Which frame the registration's input points must be expressed in.
|
|
54
|
+
|
|
55
|
+
Attributes
|
|
56
|
+
----------
|
|
57
|
+
regrid_to_pipeline : bool
|
|
58
|
+
Re-grid points onto the pipeline's anchored geometry before the ANTs
|
|
59
|
+
chain. False when a sidecar shows the transform was trained in the
|
|
60
|
+
volume's own frame.
|
|
61
|
+
reason : str
|
|
62
|
+
Human-readable justification, recorded in logs.
|
|
63
|
+
sidecar_path : Path or None
|
|
64
|
+
The sidecar that decided it, when there was one.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
regrid_to_pipeline: bool
|
|
68
|
+
reason: str
|
|
69
|
+
sidecar_path: Path | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def find_registration_sidecar(registration_dir: Path) -> Path | None:
|
|
73
|
+
"""Return the transform sidecar in *registration_dir*, or None.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
registration_dir : Path
|
|
78
|
+
Directory holding the image-to-template transforms.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
Path or None
|
|
83
|
+
First matching sidecar, or None when the directory has none.
|
|
84
|
+
"""
|
|
85
|
+
for name in REGISTRATION_SIDECAR_NAMES:
|
|
86
|
+
candidate = registration_dir / name
|
|
87
|
+
if candidate.is_file():
|
|
88
|
+
return candidate
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_registration_frame(
|
|
93
|
+
registration_dir: Path,
|
|
94
|
+
native_image: sitk.Image,
|
|
95
|
+
pipeline_image: sitk.Image,
|
|
96
|
+
size_ijk: tuple[int, int, int],
|
|
97
|
+
) -> RegistrationFrame:
|
|
98
|
+
"""Decide whether points need the pipeline re-grid before the ANTs chain.
|
|
99
|
+
|
|
100
|
+
The choice is made *relatively* -- which of the two candidate frames the
|
|
101
|
+
sidecar's declared domain is nearer to -- so no threshold decides it and a
|
|
102
|
+
mis-sized tolerance cannot pick the wrong frame. The absolute check that
|
|
103
|
+
follows only asks whether the winner is plausible at all, which is what
|
|
104
|
+
catches a sidecar paired with a different volume.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
registration_dir : Path
|
|
109
|
+
Directory holding the image-to-template transforms.
|
|
110
|
+
native_image, pipeline_image : sitk.Image
|
|
111
|
+
The anatomical geometry as the volume's own header gives it, and as the
|
|
112
|
+
pipeline's anchoring overlay gives it. Only origin, spacing and
|
|
113
|
+
direction are read.
|
|
114
|
+
size_ijk : tuple[int, int, int]
|
|
115
|
+
Voxel counts in SimpleITK (x, y, z) order, shared by both. Required
|
|
116
|
+
rather than read from the images: these are header-only stubs, and
|
|
117
|
+
``AnatomicalHeader.as_sitk_stub`` returns a 1x1x1 image whose own
|
|
118
|
+
``GetSize`` would collapse each domain to a point.
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
RegistrationFrame
|
|
123
|
+
The decision and why it was made.
|
|
124
|
+
|
|
125
|
+
Raises
|
|
126
|
+
------
|
|
127
|
+
ValueError
|
|
128
|
+
If a sidecar declares no ``moving_domain``, or declares one that matches
|
|
129
|
+
neither candidate. Neither has a safe recovery: falling back to the
|
|
130
|
+
re-grid would apply the pipeline's compensation to a transform that is
|
|
131
|
+
not the pipeline's, which is the original defect.
|
|
132
|
+
"""
|
|
133
|
+
# A manifest override still gets this branch. Which *asset* holds the
|
|
134
|
+
# transforms says nothing about which *frame* they were trained in -- a
|
|
135
|
+
# re-registration run with ``pipeline=True`` would be anchored too -- so the
|
|
136
|
+
# sidecar is the only evidence, and its absence means the pipeline's
|
|
137
|
+
# convention. Do not reach for ``has_registration_override`` here.
|
|
138
|
+
sidecar_path = find_registration_sidecar(registration_dir)
|
|
139
|
+
if sidecar_path is None:
|
|
140
|
+
return RegistrationFrame(
|
|
141
|
+
regrid_to_pipeline=True,
|
|
142
|
+
reason=f"no transform sidecar in {registration_dir}; assuming a pipeline registration",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
sidecar = load_package(sidecar_path.read_text())
|
|
146
|
+
moving = sidecar.moving_domain
|
|
147
|
+
if moving is None:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"{sidecar_path} declares no moving_domain. A sidecar means the transform is off-pipeline, "
|
|
150
|
+
"so the pipeline re-grid would be wrong, but without a domain there is nothing to honor "
|
|
151
|
+
"instead. Regenerate the sidecar."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
candidates = {
|
|
155
|
+
"native": _domain_from_image(native_image, size_ijk),
|
|
156
|
+
"pipeline": _domain_from_image(pipeline_image, size_ijk),
|
|
157
|
+
}
|
|
158
|
+
distances = {name: _bbox_distance(moving, d) for name, d in candidates.items()}
|
|
159
|
+
winner = min(distances, key=lambda name: distances[name])
|
|
160
|
+
|
|
161
|
+
# Both frames describe the same acquisition, so an exact tie means the
|
|
162
|
+
# anchoring overlay was a no-op and the branches are indistinguishable.
|
|
163
|
+
tolerance = _sanity_tolerance(moving, candidates[winner])
|
|
164
|
+
if distances[winner] > tolerance:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"{sidecar_path} moving_domain matches neither frame of the anatomical image it is paired with.\n"
|
|
167
|
+
f" sidecar bbox: {_format_bbox(moving)}\n"
|
|
168
|
+
f" native bbox: {_format_bbox(candidates['native'])} (off by {distances['native']:.4g} mm)\n"
|
|
169
|
+
f" pipeline bbox: {_format_bbox(candidates['pipeline'])} (off by {distances['pipeline']:.4g} mm)\n"
|
|
170
|
+
f" plausible within {tolerance:.4g} mm\n"
|
|
171
|
+
"Either the transform was computed on a different volume, or the sidecar is stale."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
reason = (
|
|
175
|
+
f"{sidecar_path.name} moving_domain is nearest the {winner} frame "
|
|
176
|
+
f"(native {distances['native']:.4g} mm, pipeline {distances['pipeline']:.4g} mm; "
|
|
177
|
+
f"plausible within {tolerance:.4g} mm)"
|
|
178
|
+
)
|
|
179
|
+
logger.info("Registration frame: %s", reason)
|
|
180
|
+
return RegistrationFrame(
|
|
181
|
+
regrid_to_pipeline=winner == "pipeline",
|
|
182
|
+
reason=reason,
|
|
183
|
+
sidecar_path=sidecar_path,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _domain_from_image(image: sitk.Image, size_ijk: tuple[int, int, int]) -> Domain:
|
|
188
|
+
"""Describe *image*'s physical extent in the sidecar's own vocabulary."""
|
|
189
|
+
return ImageDomainAxisAligned.from_header(ImageHeader.from_sitk(image, size_ijk)).to_sidecar()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _bbox_distance(left: Domain, right: Domain) -> float:
|
|
193
|
+
"""Largest disagreement between two domains' voxel-center bboxes, in mm.
|
|
194
|
+
|
|
195
|
+
Shape is deliberately not compared: the sidecar's grid is the resolution the
|
|
196
|
+
registration ran at, which legitimately differs from any volume here.
|
|
197
|
+
"""
|
|
198
|
+
return max(abs(v) for v in _bbox_deltas(left, right).values())
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _sanity_tolerance(left: Domain, right: Domain) -> float:
|
|
202
|
+
"""How far apart two domains of the same volume may legitimately sit, in mm.
|
|
203
|
+
|
|
204
|
+
Both grids pin voxel 0's *centre* at the origin, so for ``N * s_fine ==
|
|
205
|
+
n * s_coarse`` the far bounds differ by exactly ``s_coarse - s_fine``, plus up
|
|
206
|
+
to one coarse voxel when the downsample does not divide evenly -- just under
|
|
207
|
+
two coarse voxels. This is no longer what picks the frame, so it is set well
|
|
208
|
+
above that bound; it exists to reject a sidecar belonging to another volume.
|
|
209
|
+
"""
|
|
210
|
+
coarsest = max(max(left.spacing_LPS), max(right.spacing_LPS))
|
|
211
|
+
return max(DOMAIN_SANITY_MM, DOMAIN_TOLERANCE_VOXELS * coarsest)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _bbox_deltas(left: Domain, right: Domain) -> dict[str, float]:
|
|
215
|
+
"""Signed per-bound differences between two domains' voxel-center bboxes."""
|
|
216
|
+
deltas: dict[str, float] = {}
|
|
217
|
+
for axis in _AXES:
|
|
218
|
+
lo_l, hi_l = getattr(left.bbox, axis)
|
|
219
|
+
lo_r, hi_r = getattr(right.bbox, axis)
|
|
220
|
+
deltas[f"{axis}min"] = lo_l - lo_r
|
|
221
|
+
deltas[f"{axis}max"] = hi_l - hi_r
|
|
222
|
+
return deltas
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _format_bbox(domain: Domain) -> str:
|
|
226
|
+
"""Render a domain's bbox as ``L [lo, hi] P [...] S [...]``."""
|
|
227
|
+
return " ".join(
|
|
228
|
+
f"{axis} [{getattr(domain.bbox, axis)[0]:.4g}, {getattr(domain.bbox, axis)[1]:.4g}]" for axis in _AXES
|
|
229
|
+
)
|