Skip to content

Commit f88d03e

Browse files
committed
Set correct SOPClassUID for multi-frame files
Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
1 parent d0ae90d commit f88d03e

File tree

2 files changed

+83
-50
lines changed

2 files changed

+83
-50
lines changed

monailabel/datastore/utils/convert_htj2k.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,10 +1046,16 @@ def convert_single_frame_dicom_series_to_multiframe(
10461046
# Uncompressed numpy arrays
10471047
encoded_frames_bytes = None
10481048

1049+
# Save ImageOrientationPatient and ImagePositionPatient BEFORE creating output_ds
1050+
# The shallow copy + delattr will affect the original datasets objects
1051+
# Save these values now so we can use them in functional groups later
1052+
original_image_orientation = datasets[0].ImageOrientationPatient if hasattr(datasets[0], "ImageOrientationPatient") else None
1053+
original_image_positions = [ds.ImagePositionPatient if hasattr(ds, "ImagePositionPatient") else None for ds in datasets]
1054+
10491055
# Create SIMPLE multi-frame DICOM file (like the user's example)
10501056
# Use first dataset as template, keeping its metadata
10511057
logger.info(f" Creating simple multi-frame DICOM from {total_frame_count} frames...")
1052-
output_ds = datasets[0].copy() # Start from first dataset
1058+
output_ds = datasets[0].copy() # shallow copy
10531059

10541060
# CRITICAL: Set SOP Instance UID to match the SeriesInstanceUID (which will be the filename)
10551061
# This ensures the file's internal SOP Instance UID matches its filename
@@ -1110,10 +1116,20 @@ def convert_single_frame_dicom_series_to_multiframe(
11101116
if hasattr(output_ds, "ImageOrientationPatient"):
11111117
delattr(output_ds, "ImageOrientationPatient")
11121118
logger.info(f" ✓ Removed top-level ImageOrientationPatient (use SharedFunctionalGroupsSequence only)")
1113-
1114-
# CRITICAL: Set correct SOPClassUID for Enhanced multi-frame CT
1115-
output_ds.SOPClassUID = "1.2.840.10008.5.1.4.1.1.2.1" # Enhanced CT Image Storage
1116-
logger.info(f" ✓ Set SOPClassUID to Enhanced CT Image Storage")
1119+
# Set correct SOPClassUID for multi-frame (Enhanced/Multiframe) conversion
1120+
sopclass_map = {
1121+
"1.2.840.10008.5.1.4.1.1.2": ("1.2.840.10008.5.1.4.1.1.2.1", "Enhanced CT Image Storage"), # CT -> Enhanced CT
1122+
"1.2.840.10008.5.1.4.1.1.4": ("1.2.840.10008.5.1.4.1.1.4.1", "Enhanced MR Image Storage"), # MR -> Enhanced MR
1123+
"1.2.840.10008.5.1.4.1.1.6.1": ("1.2.840.10008.5.1.4.1.1.3.1", "Ultrasound Multi-frame Image Storage"), # US -> Ultrasound Multi-frame
1124+
}
1125+
1126+
original_sopclass = getattr(datasets[0], "SOPClassUID", None)
1127+
if original_sopclass and str(original_sopclass) in sopclass_map:
1128+
new_uid, desc = sopclass_map[str(original_sopclass)]
1129+
output_ds.SOPClassUID = new_uid
1130+
logger.info(f" ✓ Set SOPClassUID to {desc}")
1131+
else:
1132+
logger.info(f" Keeping original SOPClassUID: {original_sopclass}")
11171133

11181134
# Keep pixel spacing and slice thickness
11191135
if hasattr(datasets[0], "PixelSpacing"):
@@ -1164,8 +1180,9 @@ def convert_single_frame_dicom_series_to_multiframe(
11641180
# PlanePositionSequence - ImagePositionPatient for this frame
11651181
# This is MANDATORY for Enhanced CT multi-frame
11661182
plane_pos_item = DicomDataset()
1167-
if hasattr(ds_frame, "ImagePositionPatient"):
1168-
plane_pos_item.ImagePositionPatient = ds_frame.ImagePositionPatient
1183+
# Use saved value (before it was deleted from datasets)
1184+
if original_image_positions[frame_idx] is not None:
1185+
plane_pos_item.ImagePositionPatient = original_image_positions[frame_idx]
11691186
else:
11701187
# If missing, use default (0,0,frame_idx * spacing)
11711188
# This shouldn't happen for valid CT series, but ensures MPR compatibility
@@ -1199,8 +1216,9 @@ def convert_single_frame_dicom_series_to_multiframe(
11991216

12001217
# PlaneOrientationSequence - MANDATORY for Enhanced CT multi-frame
12011218
shared_orient_item = DicomDataset()
1202-
if hasattr(datasets[0], "ImageOrientationPatient"):
1203-
shared_orient_item.ImageOrientationPatient = datasets[0].ImageOrientationPatient
1219+
# Use saved value (before it was deleted from datasets)
1220+
if original_image_orientation is not None:
1221+
shared_orient_item.ImageOrientationPatient = original_image_orientation
12041222
else:
12051223
# If missing, use standard axial orientation
12061224
# This ensures MPR button is enabled in OHIF
@@ -1260,7 +1278,7 @@ def convert_single_frame_dicom_series_to_multiframe(
12601278

12611279
# Save as single multi-frame file
12621280
output_file = os.path.join(study_output_dir, f"{series_uid}.dcm")
1263-
output_ds.save_as(output_file, write_like_original=False)
1281+
output_ds.save_as(output_file, enforce_file_format=False)
12641282

12651283
logger.info(f" ✓ Saved multi-frame file: {output_file}")
12661284
processed_series += 1

tests/unit/datastore/test_convert_htj2k.py

Lines changed: 55 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -785,25 +785,8 @@ def test_transcode_dicom_to_htj2k_multiframe_metadata(self):
785785
# Verify top-level metadata matches first frame
786786
first_original = original_datasets[0]
787787

788-
# Check ImagePositionPatient (top-level should match first frame)
789-
self.assertTrue(hasattr(ds_multiframe, "ImagePositionPatient"), "Should have ImagePositionPatient")
790-
np.testing.assert_array_almost_equal(
791-
np.array([float(x) for x in ds_multiframe.ImagePositionPatient]),
792-
np.array([float(x) for x in first_original.ImagePositionPatient]),
793-
decimal=6,
794-
err_msg="Top-level ImagePositionPatient should match first original file",
795-
)
796-
print(f"✓ ImagePositionPatient matches first frame: {ds_multiframe.ImagePositionPatient}")
797-
798-
# Check ImageOrientationPatient
799-
self.assertTrue(hasattr(ds_multiframe, "ImageOrientationPatient"), "Should have ImageOrientationPatient")
800-
np.testing.assert_array_almost_equal(
801-
np.array([float(x) for x in ds_multiframe.ImageOrientationPatient]),
802-
np.array([float(x) for x in first_original.ImageOrientationPatient]),
803-
decimal=6,
804-
err_msg="ImageOrientationPatient should match original",
805-
)
806-
print(f"✓ ImageOrientationPatient matches original: {ds_multiframe.ImageOrientationPatient}")
788+
# Check ImagePositionPatient is NOT there at top level DICOM file
789+
self.assertFalse(hasattr(ds_multiframe, "ImagePositionPatient"), "Should not have ImagePositionPatient at top level")
807790

808791
# Check PixelSpacing
809792
self.assertTrue(hasattr(ds_multiframe, "PixelSpacing"), "Should have PixelSpacing")
@@ -826,6 +809,37 @@ def test_transcode_dicom_to_htj2k_multiframe_metadata(self):
826809
)
827810
print(f"✓ SliceThickness matches original: {ds_multiframe.SliceThickness}")
828811

812+
# Check SOPClassUID conversion to Enhanced/Multi-frame
813+
self.assertTrue(hasattr(ds_multiframe, "SOPClassUID"), "Should have SOPClassUID")
814+
self.assertTrue(hasattr(first_original, "SOPClassUID"), "Original should have SOPClassUID")
815+
816+
# Map of single-frame to enhanced/multi-frame SOPClassUIDs
817+
sopclass_map = {
818+
"1.2.840.10008.5.1.4.1.1.2": "1.2.840.10008.5.1.4.1.1.2.1", # CT -> Enhanced CT
819+
"1.2.840.10008.5.1.4.1.1.4": "1.2.840.10008.5.1.4.1.1.4.1", # MR -> Enhanced MR
820+
"1.2.840.10008.5.1.4.1.1.6.1": "1.2.840.10008.5.1.4.1.1.3.1", # US -> Ultrasound Multi-frame
821+
}
822+
823+
original_sopclass = str(first_original.SOPClassUID)
824+
multiframe_sopclass = str(ds_multiframe.SOPClassUID)
825+
826+
if original_sopclass in sopclass_map:
827+
expected_sopclass = sopclass_map[original_sopclass]
828+
self.assertEqual(
829+
multiframe_sopclass,
830+
expected_sopclass,
831+
f"SOPClassUID should be converted from {original_sopclass} to {expected_sopclass}"
832+
)
833+
print(f"✓ SOPClassUID converted: {original_sopclass} -> {multiframe_sopclass}")
834+
else:
835+
# If not in map, should remain unchanged
836+
self.assertEqual(
837+
multiframe_sopclass,
838+
original_sopclass,
839+
"SOPClassUID should remain unchanged if not in conversion map"
840+
)
841+
print(f"✓ SOPClassUID unchanged: {multiframe_sopclass}")
842+
829843
# Check for PerFrameFunctionalGroupsSequence
830844
self.assertTrue(
831845
hasattr(ds_multiframe, "PerFrameFunctionalGroupsSequence"),
@@ -868,30 +882,31 @@ def test_transcode_dicom_to_htj2k_multiframe_metadata(self):
868882
except AssertionError as e:
869883
mismatches.append(f"Frame {frame_idx}: {e}")
870884

871-
# Check PlaneOrientationSequence
872-
self.assertTrue(
885+
# PlaneOrientationSequence should ONLY be in SharedFunctionalGroupsSequence, not per-frame
886+
self.assertFalse(
873887
hasattr(frame_item, "PlaneOrientationSequence"),
874-
f"Frame {frame_idx} should have PlaneOrientationSequence",
888+
f"Frame {frame_idx} should not have PlaneOrientationSequence",
875889
)
876-
plane_orient = frame_item.PlaneOrientationSequence[0]
877-
self.assertTrue(
878-
hasattr(plane_orient, "ImageOrientationPatient"),
879-
f"Frame {frame_idx} should have ImageOrientationPatient in PlaneOrientationSequence",
880-
)
881-
882-
# Verify ImageOrientationPatient matches original
883-
multiframe_iop = np.array([float(x) for x in plane_orient.ImageOrientationPatient])
884-
original_iop = np.array([float(x) for x in original_ds.ImageOrientationPatient])
885890

886-
try:
887-
np.testing.assert_array_almost_equal(
888-
multiframe_iop,
889-
original_iop,
890-
decimal=6,
891-
err_msg=f"Frame {frame_idx} ImageOrientationPatient should match original",
892-
)
893-
except AssertionError as e:
894-
mismatches.append(f"Frame {frame_idx}: {e}")
891+
# Verify ImageOrientationPatient in SharedFunctionalGroupsSequence matches original
892+
shared_fg = ds_multiframe.SharedFunctionalGroupsSequence[0]
893+
self.assertTrue(
894+
hasattr(shared_fg, "PlaneOrientationSequence"),
895+
"SharedFunctionalGroupsSequence should have PlaneOrientationSequence",
896+
)
897+
plane_orient = shared_fg.PlaneOrientationSequence[0]
898+
multiframe_iop = np.array([float(x) for x in plane_orient.ImageOrientationPatient])
899+
original_iop = np.array([float(x) for x in original_datasets[0].ImageOrientationPatient]) # Use first frame
900+
901+
try:
902+
np.testing.assert_array_almost_equal(
903+
multiframe_iop,
904+
original_iop,
905+
decimal=6,
906+
err_msg="SharedFunctionalGroupsSequence ImageOrientationPatient should match original", # Remove frame_idx
907+
)
908+
except AssertionError as e:
909+
mismatches.append(f"Shared orientation: {e}") # Remove frame_idx reference
895910

896911
# Report any mismatches
897912
if mismatches:

0 commit comments

Comments
 (0)