dicube 0.2.2__cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

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.
dicube/core/image.py ADDED
@@ -0,0 +1,349 @@
1
+ # core/image.py
2
+ import warnings
3
+ from typing import Optional
4
+
5
+ import numpy as np
6
+
7
+ from ..dicom import (
8
+ CommonTags,
9
+ DicomMeta,
10
+ DicomStatus,
11
+ )
12
+ from .pixel_header import PixelDataHeader
13
+ from ..storage.pixel_utils import get_float_data
14
+ from spacetransformer import Space
15
+ from ..validation import (
16
+ validate_not_none,
17
+ validate_parameter_type,
18
+ validate_array_shape,
19
+ validate_string_not_empty
20
+ )
21
+ from ..exceptions import (
22
+ DataConsistencyError,
23
+ MetaDataError
24
+ )
25
+
26
+
27
+ class DicomCubeImage:
28
+ """A class representing a DICOM image with associated metadata and space information.
29
+
30
+ This class handles DICOM image data along with its pixel header, metadata, and space information.
31
+ It provides methods for file I/O and data manipulation.
32
+
33
+ Attributes:
34
+ raw_image (np.ndarray): The raw image data array.
35
+ pixel_header (PixelDataHeader): Pixel data header containing metadata about the image pixels.
36
+ dicom_meta (DicomMeta, optional): DICOM metadata associated with the image.
37
+ space (Space, optional): Spatial information describing the image dimensions and orientation.
38
+ dicom_status (DicomStatus): DICOM status enumeration. Defaults to DicomStatus.CONSISTENT.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ raw_image: np.ndarray,
44
+ pixel_header: PixelDataHeader,
45
+ dicom_meta: Optional[DicomMeta] = None,
46
+ space: Optional[Space] = None,
47
+ dicom_status: DicomStatus = DicomStatus.CONSISTENT,
48
+ ):
49
+ """Initialize a DicomCubeImage instance.
50
+
51
+ Args:
52
+ raw_image (np.ndarray): Raw image data array.
53
+ pixel_header (PixelDataHeader): Pixel data header information.
54
+ dicom_meta (DicomMeta, optional): DICOM metadata. Defaults to None.
55
+ space (Space, optional): Spatial information. Defaults to None.
56
+ """
57
+ # Validate required parameters using validation utilities
58
+ validate_not_none(raw_image, "raw_image", "DicomCubeImage constructor", DataConsistencyError)
59
+ validate_not_none(pixel_header, "pixel_header", "DicomCubeImage constructor", DataConsistencyError)
60
+ validate_array_shape(raw_image, min_dims=2, name="raw_image", context="DicomCubeImage constructor")
61
+ validate_parameter_type(pixel_header, PixelDataHeader, "pixel_header", "DicomCubeImage constructor", DataConsistencyError)
62
+
63
+ # Validate optional parameters if provided
64
+ if dicom_meta is not None:
65
+ validate_parameter_type(dicom_meta, DicomMeta, "dicom_meta", "DicomCubeImage constructor", MetaDataError)
66
+ if space is not None:
67
+ validate_parameter_type(space, Space, "space", "DicomCubeImage constructor", DataConsistencyError)
68
+
69
+ self.raw_image = raw_image
70
+ self.pixel_header = pixel_header
71
+ self.dicom_meta = dicom_meta
72
+ self.space = space
73
+ self.dicom_status = dicom_status
74
+ self._validate_shape()
75
+
76
+
77
+ def _generate_uids(self):
78
+ """Generate necessary UIDs for DICOM metadata.
79
+
80
+ Returns:
81
+ dict: Dictionary containing generated UIDs.
82
+ """
83
+ from pydicom.uid import generate_uid
84
+
85
+ return {
86
+ 'study_uid': generate_uid(),
87
+ 'series_uid': generate_uid(),
88
+ 'sop_uid': generate_uid(),
89
+ 'frame_uid': generate_uid()
90
+ }
91
+
92
+ def _set_patient_info(self, meta: DicomMeta, patient_name: str, patient_id: str):
93
+ """Set patient information in DICOM metadata.
94
+
95
+ Args:
96
+ meta (DicomMeta): The metadata object to update.
97
+ patient_name (str): Patient name.
98
+ patient_id (str): Patient ID.
99
+ """
100
+ meta.set_shared_item(CommonTags.PatientName, {'Alphabetic': patient_name})
101
+ meta.set_shared_item(CommonTags.PatientID, patient_id)
102
+ meta.set_shared_item(CommonTags.PatientBirthDate, "19700101")
103
+ meta.set_shared_item(CommonTags.PatientSex, "O")
104
+
105
+ def _set_study_info(self, meta: DicomMeta, uids: dict, modality: str):
106
+ """Set study information in DICOM metadata.
107
+
108
+ Args:
109
+ meta (DicomMeta): The metadata object to update.
110
+ uids (dict): Dictionary containing generated UIDs.
111
+ modality (str): Image modality.
112
+ """
113
+ import datetime
114
+
115
+ now = datetime.datetime.now()
116
+ date_str = now.strftime("%Y%m%d")
117
+ time_str = now.strftime("%H%M%S")
118
+
119
+ meta.set_shared_item(CommonTags.StudyInstanceUID, uids['study_uid'])
120
+ meta.set_shared_item(CommonTags.StudyDate, date_str)
121
+ meta.set_shared_item(CommonTags.StudyTime, time_str)
122
+ meta.set_shared_item(CommonTags.StudyID, "1")
123
+ meta.set_shared_item(CommonTags.StudyDescription, f"Default {modality} Study")
124
+
125
+ def _set_series_info(self, meta: DicomMeta, uids: dict, modality: str):
126
+ """Set series information in DICOM metadata.
127
+
128
+ Args:
129
+ meta (DicomMeta): The metadata object to update.
130
+ uids (dict): Dictionary containing generated UIDs.
131
+ modality (str): Image modality.
132
+ """
133
+ meta.set_shared_item(CommonTags.SeriesInstanceUID, uids['series_uid'])
134
+ meta.set_shared_item(CommonTags.SeriesNumber, "1")
135
+ meta.set_shared_item(
136
+ CommonTags.SeriesDescription, f"Default {modality} Series"
137
+ )
138
+
139
+ def _set_image_info(self, meta: DicomMeta, uids: dict, num_slices: int):
140
+ """Set image-specific information in DICOM metadata.
141
+
142
+ Args:
143
+ meta (DicomMeta): The metadata object to update.
144
+ uids (dict): Dictionary containing generated UIDs.
145
+ num_slices (int): Number of slices in the image.
146
+ """
147
+ from pydicom.uid import generate_uid
148
+
149
+ if num_slices > 1:
150
+ sop_uids = [generate_uid() for _ in range(num_slices)]
151
+ instance_numbers = [str(i + 1) for i in range(num_slices)]
152
+ meta.set_nonshared_item(CommonTags.SOPInstanceUID, sop_uids)
153
+ meta.set_nonshared_item(CommonTags.InstanceNumber, instance_numbers)
154
+ else:
155
+ meta.set_shared_item(CommonTags.SOPInstanceUID, uids['sop_uid'])
156
+ meta.set_shared_item(CommonTags.InstanceNumber, "1")
157
+
158
+ meta.set_shared_item(CommonTags.FrameOfReferenceUID, uids['frame_uid'])
159
+
160
+ def _set_space_info(self, meta: DicomMeta, num_slices: int):
161
+ """Set spatial information in DICOM metadata.
162
+
163
+ Args:
164
+ meta (DicomMeta): The metadata object to update.
165
+ num_slices (int): Number of slices in the image.
166
+ """
167
+ if self.space is not None:
168
+ # Set orientation information
169
+ orientation = self.space.to_dicom_orientation()
170
+ meta.set_shared_item(
171
+ CommonTags.ImageOrientationPatient, list(orientation)
172
+ )
173
+ meta.set_shared_item(CommonTags.PixelSpacing, list(self.space.spacing[:2]))
174
+ meta.set_shared_item(
175
+ CommonTags.SliceThickness, float(self.space.spacing[2])
176
+ )
177
+
178
+ # Set position information
179
+ if num_slices > 1:
180
+ positions = []
181
+ for i in range(num_slices):
182
+ # Calculate position for each slice using space's z_orientation
183
+ pos = np.array(self.space.origin) + i * self.space.spacing[
184
+ 2
185
+ ] * np.array(self.space.z_orientation)
186
+ positions.append(pos.tolist())
187
+ meta.set_nonshared_item(CommonTags.ImagePositionPatient, positions)
188
+ else:
189
+ meta.set_shared_item(
190
+ CommonTags.ImagePositionPatient, list(self.space.origin)
191
+ )
192
+ else:
193
+ # If no space information, set default values
194
+ meta.set_shared_item(
195
+ CommonTags.ImageOrientationPatient, [1, 0, 0, 0, 1, 0]
196
+ )
197
+ meta.set_shared_item(CommonTags.PixelSpacing, [1.0, 1.0])
198
+ meta.set_shared_item(CommonTags.SliceThickness, 1.0)
199
+ if num_slices > 1:
200
+ positions = [[0, 0, i] for i in range(num_slices)]
201
+ meta.set_nonshared_item(CommonTags.ImagePositionPatient, positions)
202
+ else:
203
+ meta.set_shared_item(CommonTags.ImagePositionPatient, [0, 0, 0])
204
+
205
+ def _set_pixel_info(self, meta: DicomMeta):
206
+ """Set pixel data information in DICOM metadata.
207
+
208
+ Args:
209
+ meta (DicomMeta): The metadata object to update.
210
+ """
211
+ # Image dimensions
212
+ shape = self.raw_image.shape
213
+ if len(shape) == 3:
214
+ meta.set_shared_item(CommonTags.Rows, shape[1])
215
+ meta.set_shared_item(CommonTags.Columns, shape[2])
216
+ else:
217
+ meta.set_shared_item(CommonTags.Rows, shape[0])
218
+ meta.set_shared_item(CommonTags.Columns, shape[1])
219
+
220
+ # Pixel characteristics
221
+ meta.set_shared_item(CommonTags.SamplesPerPixel, 1)
222
+ meta.set_shared_item(CommonTags.PhotometricInterpretation, "MONOCHROME2")
223
+ meta.set_shared_item(CommonTags.BitsAllocated, 16)
224
+ meta.set_shared_item(CommonTags.BitsStored, 16)
225
+ meta.set_shared_item(CommonTags.HighBit, 15)
226
+ meta.set_shared_item(CommonTags.PixelRepresentation, 0)
227
+
228
+ # Rescale Information from pixel_header
229
+ if self.pixel_header.RescaleSlope is not None:
230
+ meta.set_shared_item(
231
+ CommonTags.RescaleSlope, float(self.pixel_header.RescaleSlope)
232
+ )
233
+ if self.pixel_header.RescaleIntercept is not None:
234
+ meta.set_shared_item(
235
+ CommonTags.RescaleIntercept, float(self.pixel_header.RescaleIntercept)
236
+ )
237
+
238
+ def init_meta(
239
+ self,
240
+ modality: str = "OT",
241
+ patient_name: str = "ANONYMOUS^",
242
+ patient_id: str = "0000000",
243
+ ) -> DicomMeta:
244
+ """Initialize a basic DicomMeta when none is provided.
245
+
246
+ Sets required DICOM fields with default values.
247
+
248
+ Args:
249
+ modality (str): Image modality, such as CT/MR/PT. Defaults to "OT".
250
+ patient_name (str): Patient name. Defaults to "ANONYMOUS^".
251
+ patient_id (str): Patient ID. Defaults to "0000000".
252
+
253
+ Returns:
254
+ DicomMeta: A new DicomMeta instance with basic required fields.
255
+ """
256
+ # Validate input parameters
257
+ validate_string_not_empty(modality, "modality", "init_meta operation", MetaDataError)
258
+ validate_string_not_empty(patient_name, "patient_name", "init_meta operation", MetaDataError)
259
+ validate_string_not_empty(patient_id, "patient_id", "init_meta operation", MetaDataError)
260
+
261
+ try:
262
+ # Create empty DicomMeta
263
+ num_slices = self.raw_image.shape[0] if len(self.raw_image.shape) == 3 else 1
264
+ meta = DicomMeta({}, [f"slice_{i:04d}.dcm" for i in range(num_slices)])
265
+
266
+ # Generate necessary UIDs
267
+ uids = self._generate_uids()
268
+
269
+ # Set metadata sections
270
+ self._set_patient_info(meta, patient_name, patient_id)
271
+ self._set_study_info(meta, uids, modality)
272
+ self._set_series_info(meta, uids, modality)
273
+ self._set_image_info(meta, uids, num_slices)
274
+ self._set_space_info(meta, num_slices)
275
+ self._set_pixel_info(meta)
276
+
277
+ # Set modality
278
+ meta.set_shared_item(CommonTags.Modality, modality)
279
+
280
+ # Validate initialization success
281
+ if meta is None:
282
+ raise MetaDataError(
283
+ "DicomMeta initialization returned None",
284
+ context="init_meta operation",
285
+ suggestion="Check DicomMeta constructor parameters and dependencies"
286
+ )
287
+
288
+ self.dicom_meta = meta
289
+ return meta
290
+
291
+ except Exception as e:
292
+ if isinstance(e, MetaDataError):
293
+ raise
294
+ raise MetaDataError(
295
+ f"Failed to initialize DicomMeta: {str(e)}",
296
+ context="init_meta operation",
297
+ suggestion="Verify image data and metadata parameters are valid"
298
+ ) from e
299
+
300
+ @property
301
+ def shape(self):
302
+ """Get the shape of the raw image.
303
+
304
+ Returns:
305
+ tuple: The shape of the raw image array.
306
+ """
307
+ return self.raw_image.shape
308
+
309
+ @property
310
+ def dtype(self):
311
+ """Get the data type of the raw image.
312
+
313
+ Returns:
314
+ numpy.dtype: The data type of the raw image array.
315
+ """
316
+ return self.raw_image.dtype
317
+
318
+ def _validate_shape(self):
319
+ """Validate that the image shape matches the space shape if both are present.
320
+
321
+ Both raw_image and space are now in (z,y,x) format internally.
322
+
323
+ Raises:
324
+ DataConsistencyError: If space shape doesn't match image dimensions.
325
+ """
326
+ if self.space and self.raw_image.ndim >= 3:
327
+ expected_shape = tuple(self.space.shape)
328
+ if self.raw_image.shape[-len(expected_shape) :] != expected_shape:
329
+ raise DataConsistencyError(
330
+ f"Space shape mismatch with image dimensions",
331
+ context="DicomCubeImage shape validation",
332
+ details={
333
+ "space_shape": expected_shape,
334
+ "image_shape": self.raw_image.shape,
335
+ "image_dims": self.raw_image.ndim
336
+ },
337
+ suggestion="Ensure space dimensions match the image array dimensions"
338
+ )
339
+
340
+ def get_fdata(self, dtype="float32") -> np.ndarray:
341
+ """Get image data as floating point array with slope/intercept applied.
342
+
343
+ Args:
344
+ dtype (str): Output data type, must be one of: float16, float32, float64. Defaults to "float32".
345
+
346
+ Returns:
347
+ np.ndarray: Floating point image data with rescale factors applied.
348
+ """
349
+ return get_float_data(self.raw_image, self.pixel_header, dtype)