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 +11 -0
- fctpd/enums.py +31 -0
- fctpd/fetcher.py +123 -0
- fctpd/field_map.py +104 -0
- fctpd/fields.py +212 -0
- fctpd/fpb_reader.py +102 -0
- fctpd/fpb_writer.py +156 -0
- fctpd/fph_reader.py +323 -0
- fctpd/fph_writer.py +191 -0
- fctpd/header.py +151 -0
- fctpd/marshaler.py +100 -0
- fctpd/projection.py +89 -0
- fctpd/reader.py +77 -0
- fctpd/timing.py +53 -0
- fctpd/uid.py +27 -0
- fctpd/unmarshaler.py +90 -0
- fctpd/writer.py +105 -0
- fctpd-0.1.0.dist-info/METADATA +111 -0
- fctpd-0.1.0.dist-info/RECORD +22 -0
- fctpd-0.1.0.dist-info/WHEEL +5 -0
- fctpd-0.1.0.dist-info/licenses/LICENSE +24 -0
- fctpd-0.1.0.dist-info/top_level.txt +1 -0
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]
|