Skip to content

ENH: add volume_geometry_index for 3D volume geometry validation#126

Merged
fedorov merged 8 commits intomainfrom
add-geometry-checks
Mar 25, 2026
Merged

ENH: add volume_geometry_index for 3D volume geometry validation#126
fedorov merged 8 commits intomainfrom
add-geometry-checks

Conversation

@fedorov
Copy link
Copy Markdown
Member

@fedorov fedorov commented Mar 24, 2026

Add a new series-level index that checks geometric consistency of single-frame CT, MR, and PT series. Each row contains boolean columns for orientation, spacing, dimensions, and slice position checks, plus a composite valid_3d_volume flag.

Key decisions informed by sensitivity analysis against 303k series:

  • sliceIntervalTolerance set to 0.2mm (matches dcm2niix kSliceTolerance)
  • Removed minInstanceCount check (not a geometry property)
  • All derived columns use snake_case per project convention

fedorov and others added 2 commits March 24, 2026 10:18
Add a new series-level index that checks geometric consistency of
single-frame CT, MR, and PT series. Each row contains boolean columns
for orientation, spacing, dimensions, and slice position checks, plus
a composite valid_3d_volume flag.

Key decisions informed by sensitivity analysis against 303k series:
- sliceIntervalTolerance set to 0.2mm (matches dcm2niix kSliceTolerance)
- Removed minInstanceCount check (not a geometry property)
- All derived columns use snake_case per project convention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@fedorov fedorov force-pushed the add-geometry-checks branch from b1807b9 to 623d8c6 Compare March 24, 2026 14:22
# This table contains one row per DICOM series from IDC
# for single-frame CT, MR, and PT SOP classes, with boolean
# columns indicating whether each geometric consistency check
# passes. The checks validate that the series represents a
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Validate" may not be the best term here, rephrase.

# TRUE if all individual checks pass, indicating the series forms a valid 3D volume
single_orientation AND orthogonal_orientation AND unique_slice_positions
AND consistent_in_plane_row AND consistent_in_plane_col AND consistent_pixel_spacing
AND consistent_image_dimensions AND uniform_slice_spacing AS valid_3d_volume
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

regularly_spaced_3d_volume?

@fedorov
Copy link
Copy Markdown
Member Author

fedorov commented Mar 24, 2026

From @pieper

I think that's generally it, and it looks very useful, but it looks like there are some useful checks that are missing.  What you really want is to detect that the ImagePositionPatient points are equally spaced and on a straight line.  In general that should be parallel to the cross product of the image orientation but it could be in the other direction or it could be at a different angle (for gantry tilt).   The positions should be sorted along the line and the slice spacing should be calculated based on the fist and the last, so you minimize numerical issues, and then the spacing between each adjacent pair should be compared to that spacing.  It's also very valid in scans for there to be variable spacing (for valid pracitical reasons).  In Slicer that's where the acquisition geometry correction transform comes in.  So if you are adding a table you might want to indicate that some acquisitions are "valid" with some extra work.  A lot of this is described in this commit: Slicer/Slicer@3328b81

fedorov and others added 6 commits March 24, 2026 15:24
Compute expected slice spacing as (max_position - min_position) / (N - 1)
and compare each adjacent interval against it, rather than comparing
MAX(interval) - MIN(interval). This is more numerically robust per the
approach used in 3D Slicer. Recovers 19 additional series at 0.2mm
tolerance with no regressions.

Reference: Slicer/Slicer@3328b8121

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…scriptions

- Rename composite column to regularly_spaced_3d_volume to better reflect
  what it tests (regular spacing, not general validity)
- Rephrase table description to characterize geometric properties rather
  than "validate" — series that fail checks may still be usable with
  resampling or acquisition geometry correction
- Add clarification that the composite flag means the series can be loaded
  directly into a 3D array without resampling

Addresses review feedback from @fedorov and @pieper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add approach overview explaining the geometric projection strategy
- Document key SQL patterns (WITH/CTE, LEAD, window functions,
  SAFE_CAST, SAFE_DIVIDE, COUNT DISTINCT, ANY_VALUE)
- Add header comments for each CTE explaining its role in the pipeline
- Expand inline comments to explain the geometric meaning of each
  computation (dot products, cross products, projections)
- Reference DICOM tag numbers for key attributes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace absolute sliceIntervalTolerance (0.2mm) with a relative
relativeSliceTolerance (2% of expected spacing). The absolute threshold
was inappropriate for preclinical/small-animal imaging in IDC (~1,400
mouse MR series with ~0.1mm spacing), where 0.2mm is 2x the actual
spacing. Empirical analysis confirmed floating-point jitter is
sub-micrometer, so no absolute floor is needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tighten relativeSliceTolerance from 2% to 1% to match the default
_DEFAULT_SPACING_RELATIVE_TOLERANCE in highdicom's get_volume_positions.
Empirical analysis shows only 131 additional series (out of 296k) fail
at 1% vs 2% — the distribution is bimodal with no meaningful population
in between.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…cquisitions

Add a numeric column reporting the angle (in degrees) between the slice
normal and the nearest cardinal axis. 0° = pure axial/sagittal/coronal,
higher values indicate oblique acquisition or gantry tilt. ~27k series
(9%) in IDC have non-trivial obliquity.

This information was previously invisible in the boolean checks since
the projection-based approach correctly handles oblique geometry — a
tilted series passes all regularity checks. The new column lets users
identify tilted series that may need special handling (e.g., reformatting
to true axial).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@fedorov
Copy link
Copy Markdown
Member Author

fedorov commented Mar 25, 2026

Analysis: comparison with highdicom and 3D Slicer geometry handling

highdicom get_volume_positions (spatial.py#L3550)

Spacing computation — identical approach. Both highdicom and this query compute expected spacing as (last_position - first_position) / (N - 1), then compare each adjacent interval against it.

Regularity check — same logic, same default tolerance. highdicom uses np.isclose(spacings, spacing, rtol=rtol, atol=atol).all() with default rtol = _DEFAULT_SPACING_RELATIVE_TOLERANCE = 1e-2 (1%) and atol = 0.0. With atol=0, np.isclose simplifies to abs(a - b) <= rtol * abs(b) — pure relative tolerance. Our SQL uses the same formula: MAX(ABS(slice_interval - expected_spacing)) < relativeSliceTolerance * ABS(expected_spacing) with relativeSliceTolerance = 0.01 (1%), aligned to highdicom's default.

Additional features in highdicom not in this index:

  • allow_missing_positions: finds minimum consecutive spacing and checks all others are integer multiples (handles missing slices). Our query flags these via uniform_slice_spacing = false.
  • atol option: callers can choose absolute instead of relative tolerance.
  • spacing_hint: callers can provide expected spacing to validate against.
  • Perpendicularity check (lines 3851–3862): verifies the first-to-last span vector is parallel to the slice normal. Our consistent_in_plane_row/col checks achieve a related but stricter result — they detect per-slice lateral jitter, while highdicom's check only tests the overall span direction.

3D Slicer AcquisitionModeling (DICOMScalarVolumePlugin.py)

Slicer operates after loading a volume. Its strategy is:

  1. Compute "original corners" — the 4 corners of each slice as implied by the reader's IJK-to-RAS matrix (regular grid assumption)
  2. Compute "target corners" — the 4 corners from actual per-instance DICOM headers (IPP, IOP, PixelSpacing)
  3. Compare: maxError = abs(originalCorners - targetCorners).max()
  4. If maxError > 1e-3 mm (cornerEpsilon): create a grid transform to correct each slice position

This handles all irregular geometry cases uniformly (missing slices, gantry tilt, variable spacing) via a per-slice displacement field, rather than classifying properties individually as we do.

Key differences from our approach:

  • Slicer uses a single holistic metric (max corner displacement in mm); we decompose into independent boolean checks — more informative (you can see which property fails)
  • Slicer's epsilon (1e-3 mm) is much tighter — it tests whether the reader's regular-grid approximation is numerically accurate, not whether the data is "clinically acceptable"
  • Slicer corrects rather than classifies; our index classifies so downstream consumers decide what to do

Reference for the first-to-last spacing approach: Slicer commit 3328b812, also documented in the test at DICOMReaders.py test_MissingSlices.

Checks in this index vs what each tool covers

Our check highdicom Slicer
single_orientation Caller checks before calling get_volume_positions Implicit (corner mismatch if IOPs differ)
orthogonal_orientation Not explicit Not explicit (assumes orthogonal)
unique_slice_positions allow_duplicate_positions parameter Implicit (duplicate positions → zero displacement)
consistent_in_plane_row/col Perpendicularity check (span direction) Corner comparison detects lateral shifts
consistent_pixel_spacing Not in this function Corner comparison detects in-plane scale changes
consistent_image_dimensions Not in this function Not checked (assumes uniform)
uniform_slice_spacing np.isclose(spacings, spacing, rtol=0.01) Corner comparison detects spacing irregularity
obliquity_degrees Not computed Not computed (handles tilt via correction transform)
regularly_spaced_3d_volume Returns spacing, positions or None, None maxError ≤ cornerEpsilon

@fedorov
Copy link
Copy Markdown
Member Author

fedorov commented Mar 25, 2026

OHIF Viewer geometry validation

OHIF checks reconstructability in isDisplaySetReconstructable.js with additional message-level checks in checkSingleFrames.ts. Here's how it compares:

Tolerance constants

const spacingTolerance = 0.2;   // 20% relative tolerance
const iopTolerance = 0.01;      // absolute tolerance per IOP component

Note: OHIF's spacingTolerance = 0.2 is a 20% relative tolerance (used as Math.abs(spacing - averageSpacing) < averageSpacing * spacingTolerance), much looser than highdicom's 1% and our 1%. There's even a TODO comment in the source: // TODO: Is 10% a reasonable spacingTolerance for spacing?

Spacing check

OHIF computes averageSpacing = euclideanDistance(firstIPP, lastIPP) / (N - 1) — same first-to-last approach as ours and highdicom. Then for each adjacent pair:

  1. Check if |spacing - averageSpacing| < averageSpacing * 0.2 (20% relative tolerance)
  2. If that fails, check if the gap is an integer multiple of averageSpacing (missing frames detection)
  3. If neither, flag as IRREGULAR_SPACING

Key difference: OHIF uses Euclidean distance between IPP vectors (_getPerpendicularDistance), not projection onto the slice normal. This means it measures 3D distance including any in-plane shift, not purely the along-normal component. For non-tilted acquisitions these are identical, but for gantry-tilted CT the Euclidean distance slightly overestimates the true slice spacing.

Orientation check

_isSameOrientation compares all 6 IOP components with absolute tolerance of 0.01 per component — equivalent to our single_orientation check (which compares the full IOP string for exact equality, stricter than OHIF).

Position shift check

areAllImagePositionsEqual.ts predicts each slice's position as previousPosition + scanAxisNormal * averageSpacing and checks if the actual position deviates by more than averageSpacing. This catches lateral shifts — similar to our consistent_in_plane_row/col but with a much larger tolerance (one full slice spacing vs our 0.1mm).

Dimension/component checks

checkSingleFrames.ts validates Rows, Columns, and SamplesPerPixel consistency — equivalent to our consistent_image_dimensions.

Comparison table

Check OHIF Our index highdicom Slicer
Orientation consistency IOP components within 0.01 absolute IOP string exact match Caller checks Implicit (corner mismatch)
Spacing regularity 20% relative (Euclidean distance) 1% relative (normal projection) 1% relative (normal projection) Corner comparison (1e-3 mm absolute)
Missing frames Detects integer multiples of expected spacing Not detected (flags as uniform_slice_spacing = false) allow_missing_positions parameter Grid transform correction
Position shifts Predicted vs actual > averageSpacing In-plane projection range < 0.1mm Span direction check (1e-3) Corner comparison
Dimension consistency Rows, Columns, SamplesPerPixel Rows, Columns Not in this function Not checked
Obliquity detection Not computed obliquity_degrees column Not computed Not computed (corrects via transform)
Orthogonality Not explicit Cross product magnitude check Not explicit Not explicit

Notable observations

  1. OHIF is the most permissive at 20% spacing tolerance — 20x looser than highdicom/ours. The TODO in their code suggests this may be intentionally loose to maximize what can be displayed.
  2. OHIF's missing frame detection is a useful feature we don't have — it distinguishes "regularly spaced with gaps" from "truly irregular spacing" by checking if gaps are integer multiples of the expected spacing.
  3. OHIF uses Euclidean IPP distance rather than projection onto the slice normal, which is slightly less accurate for oblique/tilted acquisitions.

@fedorov fedorov merged commit c68d4ea into main Mar 25, 2026
10 checks passed
@fedorov fedorov deleted the add-geometry-checks branch March 25, 2026 01:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant