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.
Files changed (74) hide show
  1. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/CHANGELOG.md +13 -0
  2. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/PKG-INFO +1 -1
  3. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/pyproject.toml +1 -1
  4. {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
  5. {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
  6. {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
  7. {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
  8. {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
  9. aind_ibl_ephys_alignment_preprocessing-0.4.4/src/aind_ibl_ephys_alignment_preprocessing/registration_frame.py +229 -0
  10. {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
  11. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_co_asset_resolver.py +122 -0
  12. aind_ibl_ephys_alignment_preprocessing-0.4.4/tests/test_registration_frame.py +154 -0
  13. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_registration_override.py +23 -0
  14. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_sync_async_parity.py +3 -0
  15. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/uv.lock +1 -1
  16. aind_ibl_ephys_alignment_preprocessing-0.4.2/src/aind_ibl_ephys_alignment_preprocessing/registration_frame.py +0 -195
  17. aind_ibl_ephys_alignment_preprocessing-0.4.2/tests/test_registration_frame.py +0 -148
  18. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.copier-answers.yml +0 -0
  19. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  20. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  21. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/ISSUE_TEMPLATE/user-story.md +0 -0
  22. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/dependabot.yml +0 -0
  23. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/workflows/bump-call.yml +0 -0
  24. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/workflows/ci-call.yml +0 -0
  25. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/workflows/docs-ci.yml +0 -0
  26. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.github/workflows/publish-call.yml +0 -0
  27. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.gitignore +0 -0
  28. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.python-version +0 -0
  29. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/.readthedocs.yaml +0 -0
  30. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/CLAUDE.md +0 -0
  31. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/LICENSE +0 -0
  32. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/README.md +0 -0
  33. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/external_asset_references.md +0 -0
  34. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/source/_static/dark-logo.svg +0 -0
  35. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/source/_static/favicon.ico +0 -0
  36. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/source/_static/light-logo.svg +0 -0
  37. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/source/api.rst +0 -0
  38. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/source/conf.py +0 -0
  39. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/docs/source/index.md +0 -0
  40. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/scripts/run_linters_and_checks.sh +0 -0
  41. {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
  42. {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
  43. {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
  44. {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
  45. {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
  46. {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
  47. {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
  48. {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
  49. {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
  50. {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
  51. {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
  52. {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
  53. {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
  54. {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
  55. {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
  56. {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
  57. {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
  58. {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
  59. {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
  60. {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
  61. {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
  62. {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
  63. {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
  64. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/__init__.py +0 -0
  65. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_ants_domain.py +0 -0
  66. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_coverage.py +0 -0
  67. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_datapackage.py +0 -0
  68. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_datapackage_schema.py +0 -0
  69. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_example.py +0 -0
  70. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_geometry_sidecar.py +0 -0
  71. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_resample.py +0 -0
  72. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_sorting_detection.py +0 -0
  73. {aind_ibl_ephys_alignment_preprocessing-0.4.2 → aind_ibl_ephys_alignment_preprocessing-0.4.4}/tests/test_stages.py +0 -0
  74. {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.2
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
@@ -13,7 +13,7 @@ authors = [
13
13
  ]
14
14
  classifiers = ["Programming Language :: Python :: 3"]
15
15
  readme = "README.md"
16
- version = "0.4.2"
16
+ version = "0.4.4"
17
17
 
18
18
  dependencies = [
19
19
  "numpy>=1.21.0",
@@ -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
- pipeline_space_fixed_img: ANTsImage,
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=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
- pipeline_hist_domain_img: ANTsImage,
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
- pipeline_space_fixed_img=pipeline_hist_domain_img,
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
- pipeline_hist_domain_img: ANTsImage,
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
- pipeline_space_fixed_img=pipeline_hist_domain_img,
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, pipeline_img_ants, out, limits, frame
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, _, _ = await to_thread_logged(
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(asset_info.registration_dir_path, raw_img_stub)
203
- del raw_img_stub
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:ecephys_<mouseid>`` search filtered to ``tag:raw`` when a sorting
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>" (the identity after the "ecephys_" prefix).
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
- _DERIVED_SUFFIX_RE = re.compile(r"(?:^|_)(sorted|preprocessed|curated|nwb|oversplit|ccg)(?:[_-]|$)", re.IGNORECASE)
156
- # a well-formed pinned sorted_recording: ecephys_<key>_sorted_<sort_ts>
157
- _PINNED_RE = re.compile(
158
- r"^ecephys_(?P<key>\d+_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})_sorted_"
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, e.g.
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
- return (match.group("key"), match.group("ts")) if match else None
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 is not an ``ecephys_<key>`` asset, and for the
320
- derived products that share the prefix (``..._sorted_<ts>`` and friends),
321
- which the relaxed :data:`_RAW_RE` would otherwise accept.
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:ecephys_<mouseid>`` search -- the fallback pool.
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:ecephys_{mouseid})")
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:ecephys_<mouseid>`` (state-ready).
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
- pipeline_space_fixed_img: ANTsImage,
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
- A pipeline transform expects its input on the anchored geometry, so the
597
- resample runs there and the header is relabelled afterwards -- the grid is
598
- right, only its declared placement is the pipeline's. When a sidecar
599
- documents the transform's own domain there is nothing to relabel: the
600
- resample runs on the real histology grid directly.
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
- pipeline_space_fixed_img : ANTsImage
607
- Histology geometry as the pipeline anchors it.
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
- Reference histology image with correct spacing/origin/direction.
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=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
- pipeline_hist_domain_img: ANTsImage,
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
- pipeline_hist_domain_img : ANTsImage
685
- Histology geometry as the pipeline anchors it.
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
- pipeline_space_fixed_img=pipeline_hist_domain_img,
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
- pipeline_hist_domain_img: ANTsImage,
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
- pipeline_hist_domain_img : ANTsImage
724
- Histology geometry as the pipeline anchors it.
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
- pipeline_space_fixed_img=pipeline_hist_domain_img,
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, _ = base_and_pipeline_anatomical_stub(
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(asset_info.registration_dir_path, raw_img_stub)
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, pipeline_img_ants, out, reg_frame)
117
- transform_ccf_labels_to_image_space(asset_info, ref_paths, raw_img_ants, pipeline_img_ants, out, reg_frame)
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
+ )