Derive the acquisition-side geometry: acquisition_affine(s) for fid/rawdata - #221
Merged
Merged
Conversation
gdevenyi
marked this pull request as draft
August 22, 2026 02:00
…awdata A fid or rawdata.jobN dataset carried no geometry of any kind -- none of ACQ_grad_matrix, ACQ_patient_pos, ACQ_slice_offset, ACQ_read_offset or ACQ_phase1_offset was read -- so raw k-space could not be related to anatomy without re-parsing acqp by hand, although every parameter needed is right there (spec 5.4, 12). The reconstructed 2dseq has had a patient-frame affine since isi-nmr#156; this is its acquisition-side counterpart. Dataset.acquisition_affines() returns one 4x4 voxel-index -> patient transform per slice, Dataset.acquisition_affine(index) one of them, in the same Visu/DICOM frame the 2dseq affine uses, so the two are directly comparable. ACQ_grad_matrix gives the read, phase and slice directions, the ACQ_*_offset the slice centre along them, ACQ_fov the extent; index N/2 of the k-space image is the field-of-view centre and the in-plane image axes run against the gradient directions, as ParaVision's own reconstruction has it. acqp is the primary source; where it lacks a parameter the method's slice-package geometry (PVM_SPackArr*, PVM_Fov) stands in, with a warning. The derivation was fitted and validated against VisuCorePosition / VisuCoreOrientation of the matching 2dseq on 1,507 acquisitions (PV5.1, 6.0, 6.0.1, 7.0.0, 360.3.4-3.7; 1,341 Head_Supine, 221 Head_Prone), and FILE_FORMAT.md 5.4/5.6/12 now record what came out of that: * Which frame the matrix is in depends on the version. On PV5.1/6/7 ACQ_grad_matrix is written in the ParaVision subject frame, so only VISU_DICOM_PV_MATRIX = diag(-1,-1,1) remains and ACQ_patient_pos cancels: Head_Supine and Head_Prone share one and the same map. On PV360 the matrix is in the magnet frame and the declared position's magnet -> subject map (spec 5.6; ParaVision's GTB_ObjPosMatrix) comes first, P = VISU_DICOM_PV_MATRIX . M_pos: Head_Prone gives diag(-1,1,-1), Head_Supine diag(1,-1,-1), and each reproduces its scans -- a reader applies the position once on PV360 and never on PV5.1-7. Head_Left/Right and Foot_* do not occur in the corpus; their rows of the table are untested. The frame is that of the *declared* position, as Visu's is. * ACQ_grad_matrix is stored per *acquisition position*, while the offset arrays are per slice: spec 5.4 notes the matrix is built from the slice order, and the three-package scouts confirm it -- read per slice id, their sagittal and coronal slices land 5.7 mm off; undone with ACQ_obj_order (PVM_ObjOrderList once NI > NSLICES) every one is exact. * From PV6 on the 3-D partition grid is centred between partitions, half a step in from the field-of-view edge; PV5.1 puts partition N/2 at the centre like the in-plane axes. Result: every 2-D slice of the corpus reproduces its VisuCorePosition exactly (1,412/1,412 at 1e-3 mm once ParaVision's cropping of an anti-aliased field of view is accounted for) and every 3-D volume to within half a partition (91/91). The spectroscopic acquisitions are refused with UnsupportedDatasetType, as is a PV360 acquisition without a known subject position. Objects (NI) map onto slices by a method-dependent nesting that only the reconstruction's VisuFGOrderDesc records -- a sequential ACQ_obj_order is ambiguous (DtiStandard is slice-minor where MGE is slice-major) -- so the API is per slice; for NI == NSLICES object k is slice k. Closes isi-nmr#166 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHhfFXMNZKMuPm3ppdzFXe
gdevenyi
force-pushed
the
fix/acquisition-geometry
branch
from
August 22, 2026 02:17
1de56ab to
6f070f9
Compare
gdevenyi
marked this pull request as ready for review
August 22, 2026 15:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #166. Investigated against
FILE_FORMAT.md§5.4 / §5.6 / §12 (and §5.2, §7.2, §4.2) and against the full local corpus — the spec's coordinate-system section was revised from the same evidence, and this PR follows it; the maintainer's request for documented method-level fallbacks is in.API
Same Visu/DICOM patient frame as
Dataset.affineof the 2dseq, so the two are directly comparable:ACQ_grad_matrix(read/phase/slice direction cosines, C-order per spec 2.3),ACQ_read_offset/ACQ_phase1_offset/ACQ_slice_offset(slice centre),ACQ_fov(extent),ACQ_slice_sepn/ACQ_slice_thick(lone-slice step). Voxel(i, j[, k])of the Fourier-transformed k-space matrix (PVM_EncMatrixpoints per axis) is ataffine @ (i, j, k, 1); index N/2 is the field-of-view centre and the in-plane image axes run against the gradient directions, as ParaVision's own reconstruction has it. A 3-D volume gets one affine whose third column is the partition step. The slice column of a 2-D slice is the step to its neighbour in the same package (physical progression); for a lone slice it is the slice normal — which Visu instead completes right-handedly, so that sign can differ fromVisuCoreOrientation[2]while the positions agree.acqp first, method as fallback (per your comment):
PVM_SPackArrGradOrient(repeated per package byPVM_SPackArrNSlices),PVM_SPackArr{Read,Phase1}Offset,PVM_SPackArrSliceOffsetspread byPVM_SPackArrSliceDistance,PVM_Fov,PVM_SliceThick— each with aRuntimeWarningnaming the substitution. Caveat, stated in the code: in the corpusPVM_SPackArrGradOrientequalsACQ_grad_matrixon every PV6/PV7 scan, but differs by a read-axis sign on 28/105 PV5.1 and 57/57 PV360 scans, so the orientation fallback is trustworthy only where acqp would have agreed. In 1,543 corpus experiments no acqp geometry parameter other thanACQ_slice_sepn(PV360 MSME/MGE/RARE) was ever missing, so the fallback is there for the custom-sequence case you described, not because the corpus needed it.Spectroscopic acquisitions (
ACQ_dim1, or anyACQ_dim_desc≠Spatial) raiseUnsupportedDatasetType. Reports are unchanged (not added toCOMPUTED_REPORT_PROPERTIES— every fid reference would change; say so if you want it there).Per slice, not per object. The issue proposed
acquisition_affine(object=k). The NI objects map onto slices by a method-dependent nesting that only the reconstruction'sVisuFGOrderDescrecords: MGE is slice-major (obj = slice·NE + echo,ACQ_obj_order = [63 64 … 71, 45 …]), T1_EPI slice-minor (obj = movie·NSLICES + slice,[0 6 12 …]), and DtiStandard's sequential[0 1 2 … 13]is slice-minor too — indistinguishable from slice-major by acqp alone. So the API is per slice; for the commonNI == NSLICESobject k is slice k after brukerapi'sACQ_obj_orderreordering.What the corpus says — now written into FILE_FORMAT.md §5.4 / §5.6 / §12
Fitted and validated on 1,507 acquisitions with a reconstruction — PV5.1, 6.0, 6.0.1, 7.0.0, 360.3.4–3.7; 1,341
Head_Supine, 221Head_Prone; the full local corpus — by searching, per dataset, the 48 signed-permutation mapsPfor whichVisuCoreOrientation = S · ACQ_grad_matrix · PᵀwithSa signed permutation, then resolving signs withVisuCorePosition.ACQ_grad_matrixis written in the ParaVision subject frame (L→R, P→A, F→H):Head_SupineandHead_Proneadmit exactly the same map in every generation (PV6: 1150 and 97 scans; PV5: 86/19; PV7: 69/29) —P = VISU_DICOM_PV_MATRIX = diag(−1,−1,1), Visu only converts to DICOM and the position cancels. On PV360 the matrix is in the magnet frame ("transforms the logical gradient orientation directly to the physical gradient vector") and the declared position's magnet→subject map comes first,P = VISU_DICOM_PV_MATRIX · M_poswithM_posthe manual's table read assubject = M_pos · magnet(ParaVision'sGTB_ObjPosMatrix, §5.6):Head_Prone→diag(−1,1,−1), which fits every PV360 Head_Prone scan;Head_Supine→diag(1,−1,−1), which fits the one PV360 Head_Supine scan (a compressed-sensing 3-D FLASH; it was the single outlier of the first version of this PR, and itsVisuCorePositionis reproduced to 1e-5 mm once its own position is used). So a reader appliesACQ_patient_posonce on PV360 and never on PV5.1–7.Head_Left/RightandFoot_*do not occur in 1,562 acqp files; their rows of the table are implemented from the manual but untested, and a PV360 scan with an unknown position raises rather than guessing. The frame is that of the declared position, as Visu's is.ACQ_GradientMatrix == ACQ_grad_matrixin all 57 PV360 scans, so one code path.ACQ_grad_matrixis indexed by acquisition position, the offset arrays by slice id (§5.4). The three-package scouts (FLASHAngio/tripilot, 15 slices,ACQ_obj_order = [0 2 4 6 8 10 12 14 1 3 5 7 9 11 13]) prove it: read per slice id, the sagittal/coronal slices land 5.66 mm off (|(4,4)|); re-indexed withargsort(ACQ_obj_order)all 15 are exact (0.000 mm). OnceNI > NSLICES,ACQ_obj_ordercounts objects;PVM_ObjOrderList(method) is the slice order — equal toACQ_obj_orderin all 421NI == NSLICESscans, and in the 57NI > NSLICES2-D scans the matrices are all identical anyway.S = diag(−1,−1,1)for every untransposed reconstruction and[[0,−1,0],[−1,0,0],[0,0,±1]]forRECO_transposition = 1: the read and phase image axes run against the mapped gradient directions, and the first pixel is at exactly −FOV/2 (index N/2 at the centre, N-independent) — soVisuCorePosition = P·c + (FOV_r/2)·P r̂ + (FOV_p/2)·P p̂,cthe offsets alongACQ_grad_matrix. PV360'sACQ_AtsCenterDistance(356/369 mm in the corpus) does not enter: the acqp offsets reproduceVisuCorePositionwithout it.pv_version.Result with the implemented method (
Dataset.acquisition_affines()vs the 2dseq, same checks as the new test): 2-D — PV5.1 87/87, PV6 1203/1203, PV7 75/75, PV360 47/47 slice positions exact at 1e-3 mm, in-plane axes equal up toRECO_transposition; 3-D — 91/91 within half a partition (PV5 18, PV6 40, PV7 23, PV360 10). Positions compare after accounting for ParaVision's cropping of an anti-aliased field of view (PVM_AntiAlias: the k-space image is the acquiredACQ_fov, the 2dseq the croppedVisuCoreExtent; the centre is shared).Tests
test_geometry.py: synthetic acquisitions built from real scans' numbers (write_acquisitionhelper) — PV5.1 0.2H2/13 (interleaved EPI: every slice on itsVisuCorePosition), PV6 lego phantom/3 (axial/sagittal/coronal acquired 0,2,1 — fails if the matrix is read per slice id), PV5.1 0.2H2/1 package offsets, PV5.1 0.2H2/10 vs PV6 3-D grid, PV360 T2_TurboRARE Head_Prone (tilted, offset:(10.1949, 10.9375, −5.4053)✓; declared Head_Supine it comes out rotated by π about the bore, an unknown position raises), the PV360 Head_Supine CS volume (VisuCorePositionat the far end of the reversed slab ✓), the method fallback with its warning, and the spectroscopic refusal. Plustest_acquisition_affine_agrees_with_the_reconstruction, parametrized over every fid/rawdata.job0of the CI corpus that has apdata/1/2dseq(129 checked, 22 spectroscopic skips) — the issue's acceptance test, run in CI.Suite: 2358 passed, 26 skipped (master: 2251 / 4).
🤖 Generated with Claude Code
https://claude.ai/code/session_01NHhfFXMNZKMuPm3ppdzFXe