fctpd 0.1.0__py3-none-any.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.
fctpd/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from .enums import AnonymizationLevel, PixelEncoding, WriteMode
2
+ from .field_map import FieldMap
3
+ from .fpb_reader import FPBReader
4
+ from .fpb_writer import FPBWriter
5
+ from .fph_reader import FPHReader
6
+ from .fph_writer import FPHWriter
7
+ from .header import Header
8
+ from .marshaler import ProjectionMarshaler
9
+ from .projection import Projection
10
+ from .unmarshaler import ProjectionUnmarshaler
11
+ from .writer import WriteOptions
fctpd/enums.py ADDED
@@ -0,0 +1,31 @@
1
+ from enum import Enum
2
+
3
+
4
+ class AnonymizationLevel(Enum):
5
+ """
6
+ AnonymizationLevel describes how a Reader or Writer should treat
7
+ the sensitive entry collection.
8
+ """
9
+
10
+ High = 0
11
+ Medium = 1
12
+ Low = 2
13
+ NA = 3
14
+
15
+
16
+ class PixelEncoding(Enum):
17
+ """
18
+ PixelEncoding describes how a Writer should serialize pixel data.
19
+ """
20
+
21
+ F32 = 0
22
+ U16 = 1
23
+
24
+
25
+ class WriteMode(Enum):
26
+ """
27
+ WriteMode describes how an FPHWriter writes its projection data.
28
+ """
29
+
30
+ Single = 0
31
+ Multi = 1
fctpd/fetcher.py ADDED
@@ -0,0 +1,123 @@
1
+ from . import fields
2
+ from .field_map import FieldMap
3
+ from requests import get, Response
4
+ from typing import Any
5
+ from urllib.parse import ParseResult, urlparse
6
+ import os
7
+
8
+
9
+ class SelfValue(Any):
10
+ def __init__(self, offset: int, range: int | None) -> None:
11
+ self._offset: int = offset
12
+ self._range: int | None = range
13
+
14
+ @property
15
+ def offset(self) -> int:
16
+ return self._offset
17
+
18
+ @property
19
+ def range(self) -> int | None:
20
+ return self._range
21
+
22
+
23
+ class Fetcher:
24
+ def __init__(self) -> None:
25
+ return
26
+
27
+ def fetch(self, val: Any) -> Any:
28
+ """
29
+ Fetch either standard and URI-linked data from an input value.
30
+
31
+ Parameters
32
+ ----------
33
+ - val (Any): Value to parse.
34
+
35
+ Returns
36
+ -------
37
+ - data (Any): Data linked by the value.
38
+ """
39
+ # If we don't have a FieldMap, there's nothing to fetch
40
+ if not isinstance(val, FieldMap) and not isinstance(val, dict):
41
+ return val
42
+
43
+ # If the URI is absent, we cannot treat the value as a
44
+ # URI-addressed value; simply return it
45
+ if fields.URI not in val:
46
+ return val
47
+
48
+ # Set the byte offset and range
49
+ uri: str = val[fields.URI]
50
+ offset: int = val[fields.ByteOffset] if fields.ByteOffset in val else 0
51
+ range: int | None = val[fields.ByteRange] if fields.ByteRange in val else None
52
+
53
+ # Validate whether the URI is valid
54
+ uri_parse: ParseResult = urlparse(uri)
55
+ if not uri_parse.scheme:
56
+ raise ValueError("invalid URI-addressed value: URI is invalid")
57
+
58
+ # Match on the scheme
59
+ match uri_parse.scheme:
60
+ case "file":
61
+ return self._fetch_file(uri_parse.path, offset, range)
62
+
63
+ case "http" | "https":
64
+ return self._fetch_http(uri, offset, range)
65
+
66
+ case "self":
67
+ return SelfValue(offset, range)
68
+
69
+ case _:
70
+ raise NotImplementedError(
71
+ f"URI scheme {uri_parse.scheme} not supported"
72
+ )
73
+
74
+ def _fetch_file(self, path: str, offset: int, range: int | None) -> bytes:
75
+ """
76
+ Internal fetch function for local files.
77
+
78
+ Parameters
79
+ ----------
80
+ - path (str): Local file path to data.
81
+ - offset (int): File byte offset to data.
82
+ - range: (int | None): Size of byte data to read.
83
+ """
84
+ # Check the path exists and is a file
85
+ if not os.path.exists(path) or os.path.isdir(path):
86
+ raise FileNotFoundError
87
+
88
+ # Adjust the range
89
+ if range is None:
90
+ range = os.path.getsize(path)
91
+
92
+ # Load the file pointer into memory
93
+ with open(path, "rb", buffering=0) as f:
94
+ f.seek(offset, os.SEEK_SET)
95
+ data: bytes = f.read(range)
96
+ f.close()
97
+
98
+ return data
99
+
100
+ def _fetch_http(self, uri: str, offset: int, range: int | None) -> bytes:
101
+ """
102
+ Internal fetch function for HTTP/HTTPS.
103
+
104
+ Parameters
105
+ ----------
106
+ - uri (str): HTTP-schemed URI to data.
107
+ - offset (int): File byte offset to data.
108
+ - range: (int | None): Size of byte data to read.
109
+ """
110
+
111
+ # Get the range header
112
+ range_hdr: dict[str, str] = (
113
+ {"Range": f"bytes={offset}-"}
114
+ if range is None
115
+ else {"Range": f"bytes={offset}-{offset+range}"}
116
+ )
117
+
118
+ # Perform the request
119
+ resp: Response = get(uri, headers=range_hdr)
120
+ resp.raise_for_status()
121
+
122
+ # Return the response body
123
+ return resp.content
fctpd/field_map.py ADDED
@@ -0,0 +1,104 @@
1
+ from . import fields
2
+ from .enums import AnonymizationLevel
3
+ from .timing import date
4
+ from .uid import generate_accession_number, generate_uid
5
+ from numpy import number
6
+ from re import match
7
+ from typing import Any
8
+
9
+
10
+ class FieldMap(dict[str, Any]):
11
+ """
12
+ FieldMap is a hash map with string-valued keys and
13
+ any-valued values.
14
+ """
15
+
16
+ def __init__(self, entries: dict[str, Any] | None = None) -> None:
17
+ """
18
+ Constructor for a FieldMap object.
19
+
20
+ Parameters
21
+ ----------
22
+ - entries (dict[str, Any]): Entries to initialize the FieldMap
23
+ with [default: `{}`].
24
+ """
25
+ super().__init__({} if entries is None else entries)
26
+
27
+ def anonymized(self, anon_level: AnonymizationLevel) -> FieldMap:
28
+ """
29
+ Returns an anonymized copy of the FieldMap object.
30
+
31
+ Parameters
32
+ ----------
33
+ - anon_level (AnonymizationLevel): Degree of anonymization to apply
34
+ to the copied FieldMap.
35
+
36
+ Returns
37
+ -------
38
+ - anon_fm (FieldMap): Anonymized FieldMap object.
39
+ """
40
+ # Iterate through sensitive fields and anonymize
41
+ anon_fm: FieldMap = FieldMap()
42
+ for fld in fields.SensitiveFields:
43
+ # Skip fields that are absent
44
+ if fld not in self:
45
+ continue
46
+
47
+ # Switch on anonymization level
48
+ val = self.pop(fld)
49
+ match anon_level:
50
+ case AnonymizationLevel.High:
51
+ # Leave the entry out
52
+ continue
53
+
54
+ case AnonymizationLevel.Medium:
55
+ # Switch on the value type
56
+ match val:
57
+ case str():
58
+ anon_fm[fld] = ""
59
+ case number():
60
+ anon_fm[fld] = 0
61
+
62
+ case AnonymizationLevel.Low:
63
+ # Switch on the value type
64
+ match val:
65
+ case str():
66
+ anon_fm[fld] = _anonymize_string_field(fld, val)
67
+ case number():
68
+ anon_fm[fld] = 0
69
+
70
+ case AnonymizationLevel.NA:
71
+ # Reinstate the entry
72
+ anon_fm[fld] = val
73
+
74
+ return anon_fm
75
+
76
+
77
+ def _anonymize_string_field(fld: str, val: str) -> str:
78
+ """
79
+ Internal string anonymization function.
80
+
81
+ Parameters
82
+ ----------
83
+ - fld (str): Name of the entry.
84
+ - val (str): String-typed value associated with the entry.
85
+
86
+ Returns
87
+ -------
88
+ - anonymized string value (str)
89
+ """
90
+ lower: str = fld.lower()
91
+ if "date" in lower:
92
+ return date()
93
+ elif "pregnan" in lower:
94
+ return "PregnancyUnknown"
95
+ elif "uid" in lower:
96
+ return generate_uid()
97
+ elif "time" in lower:
98
+ return "0.000"
99
+ elif fld == fields.AccessionNumber:
100
+ return generate_accession_number()
101
+ elif match(r"[\w+]Y", val):
102
+ return "000Y"
103
+ else:
104
+ return "ANON"
fctpd/fields.py ADDED
@@ -0,0 +1,212 @@
1
+ # File IO fields
2
+ ByteOffset: str = "ByteOffset"
3
+ ByteRange: str = "ByteRange"
4
+ URI: str = "URI"
5
+
6
+ # Scanner fields
7
+ Rows: str = "NumberOfRows"
8
+ Channels: str = "NumberOfChannels"
9
+ CollimatedSliceWidth: str = "CollimatedSliceWidth"
10
+ CentralRow: str = "CentralRow"
11
+ CentralChannel: str = "CentralChannel"
12
+ AcquisitionFOV: str = "AcquisitionFOV"
13
+ RowWidth: str = "RowWidth"
14
+ ChannelWidth: str = "ChannelWidth"
15
+ AnodeAngle: str = "AnodeAngle"
16
+ Manufacturer: str = "Manufacturer"
17
+ ScannerModel: str = "ScannerModel"
18
+ DistanceSourceToIsocenter: str = "DistanceSourceToIsocenter"
19
+ DistanceSourceToDetector: str = "DistanceSourceToDetector"
20
+ Modality: str = "Modality"
21
+ DetectorShape: str = "DetectorShape"
22
+ CentralChannelOffset: str = "CentralChannelOffset"
23
+ WedgeProfile: str = "WedgeProfile"
24
+
25
+ # Acquisition fields
26
+ TableSpeed: str = "TableSpeed"
27
+ RotationTime: str = "RotationTime"
28
+ ScanTime: str = "ScanTime"
29
+ TableHeight: str = "TableHeight"
30
+ TableFeedDirection: str = "TableFeedDirection"
31
+ TubeAngleDirection: str = "TubeAngleDirection"
32
+ FlyingFocalSpotMode: str = "FlyingFocalSpotMode"
33
+ Exposure: str = "Exposure"
34
+ ExposureNominal: str = "ExposureNominal"
35
+ TubeVoltage: str = "TubeVoltage"
36
+ CTDIvol: str = "CTDIvol"
37
+ CTDIw: str = "CTDIw"
38
+ PatientPosition: str = "PatientPosition"
39
+ BodyPartExamined: str = "BodyPartExamined"
40
+ StartTablePosition: str = "StartTablePosition"
41
+ EndTablePosition: str = "EndTablePosition"
42
+ StartTubeAngle: str = "StartTubeAngle"
43
+ EndTubeAngle: str = "EndTubeAngle"
44
+ ProtocolName: str = "ProtocolName"
45
+ SeriesDescription: str = "SeriesDescription"
46
+ StudyDescription: str = "StudyDescription"
47
+ NumberOfSpectra: str = "NumberOfSpectra"
48
+ SpectrumIndex: str = "SpectrumIndex"
49
+ NoiseEquivalentQuanta: str = "NoiseEquivalentQuanta"
50
+ SourcePath: str = "SourcePath"
51
+ RayGeometry: str = "RayGeometry"
52
+ NumberOfProjections: str = "NumberOfProjections"
53
+ LogarithmicEncoding: str = "LogarithmicEncoding"
54
+
55
+ # Projection-specific fields
56
+ TablePosition: str = "TablePosition"
57
+ TubeAngle: str = "TubeAngle"
58
+ SourceAngularPositionShift: str = "SourceAngularPositionShift"
59
+ SourceAxialPositionShift: str = "SourceAxialPositionShift"
60
+ SourceRadialDistanceShift: str = "SourceRadialDistanceShift"
61
+ ReadingIndex: str = "ReadingIndex"
62
+ Timestamp: str = "Timestamp"
63
+ MinimumPixelValue: str = "MinimumPixelValue"
64
+ MaximumPixelValue: str = "MaximumPixelValue"
65
+ TubeCurrent: str = "TubeCurrent"
66
+ Data: str = "Data"
67
+ View: str = "View"
68
+
69
+ # Reconstruction fields
70
+ ConeAngle: str = "ConeAngle"
71
+ ProjectionsPerRotation: str = "ProjectionsPerRotation"
72
+ TableFeedPerRotation: str = "TableFeedPerRotation"
73
+ FanAngleIncrement: str = "FanAngleIncrement"
74
+ Pitch: str = "Pitch"
75
+
76
+ # Image fields
77
+ RescaleSlope: str = "RescaleSlope"
78
+ RescaleIntercept: str = "RescaleIntercept"
79
+ HounsfieldUnitCalibration: str = "HounsfieldUnitCalibration"
80
+ PixelRepresentation: str = "PixelRepresentation"
81
+ PhotometricInterpretation: str = "PhotometricInterpretation"
82
+ HighBit: str = "HighBit"
83
+ BitsAllocated: str = "BitsAllocated"
84
+ BitsStored: str = "BitsStored"
85
+ SamplesPerPixel: str = "SamplesPerPixel"
86
+ MatrixRows: str = "MatrixRows"
87
+ MatrixColumns: str = "MatrixColumns"
88
+
89
+ # Sensitive Fields
90
+ PatientAge: str = "PatientAge"
91
+ PatientBirthDate: str = "PatientBirthDate"
92
+ PatientID: str = "PatientID"
93
+ PatientName: str = "PatientName"
94
+ PatientSex: str = "PatientSex"
95
+ PatientSize: str = "PatientSize"
96
+ PatientWeight: str = "PatientWeight"
97
+ PregnancyStatus: str = "PregnancyStatus"
98
+ RequestedProcedure: str = "RequestedProcedure"
99
+ SmokingStatus: str = "SmokingStatus"
100
+ PhysicianName: str = "PhysicianName"
101
+ AccessionNumber: str = "AccessionNumber"
102
+ InstitutionName: str = "InstitutionName"
103
+ InstitutionAddress: str = "InstitutionAddress"
104
+ SOPClassUID: str = "SOPClassUID"
105
+ SOPInstanceUID: str = "SOPInstanceUID"
106
+ StudyInstanceUID: str = "StudyInstanceUID"
107
+ SeriesInstanceUID: str = "SeriesInstanceUID"
108
+ IrradiationEventUID: str = "IrradiationEventUID"
109
+ AcquisitionDate: str = "AcquisitionDate"
110
+ SeriesDate: str = "SeriesDate"
111
+ Diagnosis: str = "Diagnosis"
112
+ EthnicGroup: str = "EthnicGroup"
113
+ AcquisitionTime: str = "AcquisitionTime"
114
+ RangeName: str = "RangeName"
115
+
116
+ # Sensitive fields
117
+ SensitiveFields: set[str] = {
118
+ PregnancyStatus,
119
+ PatientID,
120
+ PatientWeight,
121
+ PatientAge,
122
+ PatientBirthDate,
123
+ PatientName,
124
+ PatientSex,
125
+ PatientSize,
126
+ RangeName,
127
+ SmokingStatus,
128
+ PhysicianName,
129
+ AccessionNumber,
130
+ InstitutionName,
131
+ InstitutionAddress,
132
+ StudyInstanceUID,
133
+ SeriesInstanceUID,
134
+ IrradiationEventUID,
135
+ AcquisitionDate,
136
+ AcquisitionTime,
137
+ SeriesDate,
138
+ Diagnosis,
139
+ EthnicGroup,
140
+ SOPInstanceUID,
141
+ Timestamp,
142
+ }
143
+
144
+
145
+ # Required reconstruction fields
146
+ ReconFieldsRequired: set[str] = {
147
+ Rows,
148
+ Channels,
149
+ NumberOfProjections,
150
+ ProjectionsPerRotation,
151
+ CentralRow,
152
+ CentralChannel,
153
+ AcquisitionFOV,
154
+ RowWidth,
155
+ ChannelWidth,
156
+ DistanceSourceToIsocenter,
157
+ DistanceSourceToDetector,
158
+ TableSpeed,
159
+ RotationTime,
160
+ FlyingFocalSpotMode,
161
+ RayGeometry,
162
+ }
163
+
164
+ # Recommended reconstruction fields
165
+ ReconFieldsRecommended: set[str] = {
166
+ TableFeedDirection,
167
+ TubeAngleDirection,
168
+ AnodeAngle,
169
+ HounsfieldUnitCalibration,
170
+ FanAngleIncrement,
171
+ Pitch,
172
+ ScanTime,
173
+ TableHeight,
174
+ Manufacturer,
175
+ PatientPosition,
176
+ ScannerModel,
177
+ SpectrumIndex,
178
+ }
179
+
180
+
181
+ # Optional reconstruction fields
182
+ ReconFieldsOptional: set[str] = {
183
+ SourcePath,
184
+ LogarithmicEncoding,
185
+ NumberOfSpectra,
186
+ TubeVoltage,
187
+ PixelRepresentation,
188
+ HighBit,
189
+ BitsAllocated,
190
+ BitsStored,
191
+ SamplesPerPixel,
192
+ CentralChannelOffset,
193
+ StartTablePosition,
194
+ EndTablePosition,
195
+ StartTubeAngle,
196
+ EndTubeAngle,
197
+ Exposure,
198
+ ExposureNominal,
199
+ CTDIw,
200
+ CTDIvol,
201
+ CollimatedSliceWidth,
202
+ ConeAngle,
203
+ TableFeedPerRotation,
204
+ ProtocolName,
205
+ SOPClassUID,
206
+ SeriesDescription,
207
+ StudyDescription,
208
+ BodyPartExamined,
209
+ Modality,
210
+ DetectorShape,
211
+ PhotometricInterpretation,
212
+ }
fctpd/fpb_reader.py ADDED
@@ -0,0 +1,102 @@
1
+ from . import fields
2
+ from .enums import AnonymizationLevel
3
+ from .field_map import FieldMap
4
+ from .fpb_writer import _MAGIC_WORD
5
+ from .fph_reader import FPHReader
6
+ from .unmarshaler import ProjectionUnmarshaler
7
+ from json import loads
8
+ from os.path import exists
9
+ import os
10
+
11
+
12
+ class FPBReader(FPHReader):
13
+ """
14
+ FPBReader parses an .fpb file.
15
+ """
16
+
17
+ def __init__(
18
+ self, input_path: str, anon_level: AnonymizationLevel = AnonymizationLevel.High
19
+ ) -> None:
20
+ """
21
+ Constructor for the FPBReader class.
22
+
23
+ Parameters
24
+ ----------
25
+ - input_path (str): Path to the .fpb file to read.
26
+ - anon_level (AnonymizationLevel): Degree of anonymization to apply to
27
+ reading operations [default: `AnonymizationLevel.High`].
28
+ """
29
+ self._projectionMaps: list[FieldMap] = []
30
+ self.initialize(input_path, anon_level)
31
+ self._unmarshaler: ProjectionUnmarshaler = ProjectionUnmarshaler(input_path)
32
+
33
+ def initialize(
34
+ self, path: str, anon_level: AnonymizationLevel | None = None
35
+ ) -> None:
36
+ """
37
+ Initializes the FPBReader for a new file at the specified path.
38
+
39
+ Parameters
40
+ ----------
41
+ - path (str): Path to the .fpb file to read.
42
+ - anon_level (AnonymizationLevel | None): Degree of anonymization to
43
+ apply to reading operations [default: `None`].
44
+ """
45
+ # Check the file exists
46
+ if not exists(path):
47
+ raise FileNotFoundError
48
+
49
+ # Open the fpb file
50
+ with open(path, "rb") as fileptr:
51
+ # Check the magic word
52
+ fileptr.seek(-8, os.SEEK_END)
53
+ magic_word: bytes = fileptr.read(8)
54
+ if magic_word != _MAGIC_WORD:
55
+ raise SyntaxError
56
+
57
+ # Jump to the FPH block
58
+ fileptr.seek(-16, os.SEEK_END)
59
+ footer_offset: int = int.from_bytes(fileptr.read(8), byteorder="little")
60
+ fileptr.seek(footer_offset, os.SEEK_SET)
61
+
62
+ # Read the FPH block
63
+ fph_bytes: bytes = fileptr.read(os.path.getsize(path) - footer_offset - 24)
64
+ fmap: FieldMap = loads(fph_bytes.decode())
65
+ fileptr.close()
66
+
67
+ # If the projections entry is missing, we panic
68
+ if "projections" not in fmap:
69
+ raise KeyError("required `projections` key not found in fph file")
70
+
71
+ # If the projections entry ISN'T a list, we panic
72
+ if not isinstance(fmap["projections"], list):
73
+ raise ValueError("required `projections` value is not an array")
74
+
75
+ # Set the maps
76
+ self._scanMap: FieldMap = (
77
+ FieldMap(fmap["scan"]) if "scan" in fmap else FieldMap()
78
+ )
79
+
80
+ self._sensitiveMap: FieldMap = (
81
+ FieldMap(fmap["sensitive"]) if "sensitive" in fmap else FieldMap()
82
+ )
83
+
84
+ self._projectionMaps: list[FieldMap] = fmap["projections"]
85
+ self._missingFields: set[str] = set()
86
+
87
+ try:
88
+ self._projectionMaps.sort(key=lambda x: x[fields.ReadingIndex])
89
+ except KeyError:
90
+ self._projectionMaps.sort(
91
+ key=lambda x: x[fields.View][fields.TablePosition]
92
+ )
93
+ except Exception as e:
94
+ raise e
95
+
96
+ # Set the anonymization level
97
+ if anon_level is not None:
98
+ self.anonymization = anon_level
99
+
100
+ # Set the number of rows and columns (necessary?)
101
+ self._rows: int = self._scanMap[fields.Rows]
102
+ self._channels: int = self._scanMap[fields.Channels]