isocenter 0.7.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.
Files changed (43) hide show
  1. isocenter/__init__.py +63 -0
  2. isocenter/automation.py +143 -0
  3. isocenter/builders.py +96 -0
  4. isocenter/config_manager.py +277 -0
  5. isocenter/configuration.py +208 -0
  6. isocenter/crypto.py +73 -0
  7. isocenter/discovery.py +336 -0
  8. isocenter/entities.py +548 -0
  9. isocenter/exporters/__init__.py +76 -0
  10. isocenter/exporters/dicom.py +19 -0
  11. isocenter/exporters/wfdb.py +567 -0
  12. isocenter/imagecodecs_handler.py +153 -0
  13. isocenter/io_handlers.py +1228 -0
  14. isocenter/logger.py +69 -0
  15. isocenter/manifest.py +185 -0
  16. isocenter/murmur.py +215 -0
  17. isocenter/parallel.py +199 -0
  18. isocenter/persistence.py +1822 -0
  19. isocenter/persistence_manager.py +145 -0
  20. isocenter/pixel_analysis.py +219 -0
  21. isocenter/privacy.py +418 -0
  22. isocenter/profiles.py +72 -0
  23. isocenter/remediation.py +390 -0
  24. isocenter/reporting.py +155 -0
  25. isocenter/resources/ctp_rules.json +662 -0
  26. isocenter/resources/phi_tags.json +10 -0
  27. isocenter/resources/redaction_rules.json +27 -0
  28. isocenter/resources/research_tags.json +36 -0
  29. isocenter/reversibility.py +148 -0
  30. isocenter/services.py +593 -0
  31. isocenter/session.py +2200 -0
  32. isocenter/sidecar.py +132 -0
  33. isocenter/store.py +66 -0
  34. isocenter/utils/__init__.py +0 -0
  35. isocenter/utils/ctp_parser.py +136 -0
  36. isocenter/validation.py +56 -0
  37. isocenter/verification.py +160 -0
  38. isocenter/waveform.py +282 -0
  39. isocenter-0.7.0.dist-info/METADATA +435 -0
  40. isocenter-0.7.0.dist-info/RECORD +43 -0
  41. isocenter-0.7.0.dist-info/WHEEL +5 -0
  42. isocenter-0.7.0.dist-info/licenses/LICENSE +661 -0
  43. isocenter-0.7.0.dist-info/top_level.txt +1 -0
isocenter/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ import warnings
2
+ # Suppress all pydicom warnings (e.g. strict UID validation)
3
+ warnings.filterwarnings("ignore", module="pydicom.*")
4
+
5
+ try:
6
+ from .session import DicomSession as Session
7
+
8
+ # Expose the Builder for power users
9
+ from .builders import DicomBuilder as Builder
10
+
11
+ # Expose Equipment for type hinting
12
+ from .entities import Equipment
13
+
14
+ # Expose handler for direct import check
15
+ from . import imagecodecs_handler
16
+
17
+ except ImportError as e:
18
+ # Catch broken pylibjpeg installations that typically occur on Python 3.14t
19
+ if "_openjpeg" in str(e):
20
+ raise RuntimeError(
21
+ "\n"
22
+ "CRITICAL ERROR: Broken 'pylibjpeg' installation detected.\n"
23
+ "----------------------------------------------------------\n"
24
+ "This environment contains corrupt 'pylibjpeg' packages from a failed build attempt.\n"
25
+ "Isocenter cannot start because 'pydicom' is attempting to load these broken plugins.\n\n"
26
+ "TO FIX: Run this command to clean your environment:\n"
27
+ " pip uninstall -y pylibjpeg pylibjpeg-openjpeg pylibjpeg-libjpeg pylibjpeg-rle\n"
28
+ "----------------------------------------------------------\n"
29
+ ) from e
30
+ raise
31
+
32
+ # Configure pydicom handlers
33
+ # We prioritize pylibjpeg (if installed) and pillow.
34
+ # GDCM is often problematic to install via pip, so pylibjpeg is preferred for JPEG/JPEG-LS/RLE.
35
+ try:
36
+ from pydicom import config as pydicom_config
37
+ import pydicom.pixel_data_handlers.gdcm_handler as gdcm_handler
38
+ import pydicom.pixel_data_handlers.pillow_handler as pillow_handler
39
+ import pydicom.pixel_data_handlers.numpy_handler as numpy_handler
40
+ from . import imagecodecs_handler
41
+
42
+ # We explicitly define the priority list using module objects
43
+ pydicom_config.pixel_data_handlers = [
44
+ gdcm_handler,
45
+ imagecodecs_handler,
46
+ pillow_handler,
47
+ numpy_handler
48
+ ]
49
+ except ImportError:
50
+ pass
51
+
52
+ try:
53
+ from importlib.metadata import version, PackageNotFoundError
54
+ except ImportError:
55
+ # Backport for older Pythons if needed, though Isocenter requires 3.9+ where this is standard
56
+ from importlib_metadata import version, PackageNotFoundError
57
+
58
+ try:
59
+ __version__ = version("isocenter")
60
+ except PackageNotFoundError:
61
+ # Package is not installed
62
+ __version__ = "0.0.0"
63
+ __all__ = ["Session", "Builder", "Equipment"]
@@ -0,0 +1,143 @@
1
+ """
2
+ Module for analyzing OCR findings and suggesting configuration updates.
3
+ """
4
+ from typing import List, Dict, Any
5
+ from collections import defaultdict
6
+ from isocenter.privacy import PhiReport
7
+ from isocenter.configuration import IsocenterConfiguration
8
+
9
+ class ConfigAutomator:
10
+ """
11
+ Analyzes OCR findings and generates suggestions to update the redaction configuration.
12
+ """
13
+
14
+ @staticmethod
15
+ def suggest_config_updates(report: PhiReport, _current_config: IsocenterConfiguration) -> List[Dict[str, Any]]:
16
+ """
17
+ Generates a list of suggested configuration changes.
18
+
19
+ Returns:
20
+ List[Dict]: A list of 'suggestion' objects:
21
+ {
22
+ "serial": str,
23
+ "action": "ADD_ZONE" | "EXPAND_ZONE",
24
+ "zone": [x, y, w, h],
25
+ "reason": str
26
+ }
27
+ """
28
+ suggestions = []
29
+
30
+ # Group findings by machine serial
31
+ findings_by_serial = defaultdict(list)
32
+
33
+ for finding in report:
34
+ meta = finding.metadata
35
+ if not meta:
36
+ continue
37
+
38
+ serial = meta.get("rule_serial")
39
+ if serial:
40
+ findings_by_serial[serial].append(finding)
41
+ else:
42
+ # Todo: Handle findings with no matching rule (Unknown Serial or No config entry)
43
+ pass
44
+
45
+ for serial, findings in findings_by_serial.items():
46
+ # In a real system we might merge zones here.
47
+ for f in findings:
48
+ meta = f.metadata
49
+ l_type = meta.get("leak_type")
50
+ text_box = meta.get("text_box") # x,y,w,h
51
+
52
+ if not text_box:
53
+ continue
54
+
55
+ if l_type == "PARTIAL_LEAK":
56
+ # Suggest expanding the best_zone to cover text_box
57
+ best_zone = meta.get("best_zone")
58
+ if best_zone:
59
+ # Calculate union box
60
+ tx, ty, tw, th = text_box
61
+ zx, zy, zw, zh = best_zone
62
+
63
+ ux = min(tx, zx)
64
+ uy = min(ty, zy)
65
+ ur = max(tx+tw, zx+zw)
66
+ ub = max(ty+th, zy+zh)
67
+
68
+ union_zone = [int(ux), int(uy), int(ur-ux), int(ub-uy)]
69
+
70
+ suggestions.append({
71
+ "serial": serial,
72
+ "action": "EXPAND_ZONE",
73
+ "original_zone": best_zone,
74
+ "new_zone": union_zone,
75
+ "reason": f"Partial leak detected ({f.value}). Expanded to cover."
76
+ })
77
+
78
+ elif l_type == "NEW_LEAK":
79
+ # Suggest adding the text box as a new zone
80
+ # Add some padding?
81
+ # Ensure ints
82
+ zone = [int(x) for x in text_box]
83
+
84
+ suggestions.append({
85
+ "serial": serial,
86
+ "action": "ADD_ZONE",
87
+ "zone": list(zone),
88
+ "reason": f"New leak detected ({f.value}). Added new zone."
89
+ })
90
+
91
+ return suggestions
92
+
93
+ @staticmethod
94
+ def apply_suggestions(session: 'DicomSession', suggestions: List[Dict[str, Any]]) -> int:
95
+ """
96
+ Applies the suggestions to the session's in-memory configuration.
97
+ Returns: Number of changes applied.
98
+ """
99
+ count = 0
100
+ rules = session.configuration.rules
101
+
102
+ for sug in suggestions:
103
+ serial = sug["serial"]
104
+ action = sug["action"]
105
+
106
+ # Find the rule object
107
+ target_rule = None
108
+ for r in rules:
109
+ if r.get("serial_number") == serial:
110
+ target_rule = r
111
+ break
112
+
113
+ if not target_rule:
114
+ continue
115
+
116
+ if action == "ADD_ZONE":
117
+ zone = sug["zone"]
118
+ # Check duplicates?
119
+ if zone not in target_rule["redaction_zones"]:
120
+ target_rule["redaction_zones"].append(zone)
121
+ count += 1
122
+
123
+ elif action == "EXPAND_ZONE":
124
+ old_zone = sug["original_zone"]
125
+ new_zone = sug["new_zone"]
126
+
127
+ # Find index of old_zone
128
+ zones = target_rule["redaction_zones"]
129
+ try:
130
+ # Convert to list for comparison just in case
131
+ idx = -1
132
+ for i, z in enumerate(zones):
133
+ if list(z) == list(old_zone):
134
+ idx = i
135
+ break
136
+
137
+ if idx >= 0:
138
+ zones[idx] = new_zone
139
+ count += 1
140
+ except ValueError:
141
+ pass
142
+
143
+ return count
isocenter/builders.py ADDED
@@ -0,0 +1,96 @@
1
+ """
2
+ Builder pattern implementation for constructing DICOM entity hierarchies.
3
+
4
+ This module provides a fluent interface for creating Patient, Study, Series,
5
+ and Instance objects in a structured way.
6
+ """
7
+ from .entities import Patient, Study, Series, Instance, Equipment
8
+
9
+
10
+ class DicomBuilder:
11
+ """
12
+ Factory for creating fluent Dicom hierarchy builders.
13
+
14
+ Usage:
15
+ patient = DicomBuilder.start_patient("P123", "Doe^John")
16
+ .add_study("1.2.3", "20240101")
17
+ .add_series("1.2.3.1", "CT", "1")
18
+ .end_study().build()
19
+ """
20
+ @staticmethod
21
+ def start_patient(id, name):
22
+ """Begin building a Patient."""
23
+ return PatientBuilder(id, name)
24
+
25
+
26
+ class PatientBuilder:
27
+ """Fluent Builder for Patient entities."""
28
+
29
+ def __init__(self, id, name): self.patient = Patient(id, name)
30
+
31
+ def add_study(self, uid, date):
32
+ """Adds a child Study to this Patient."""
33
+ s = Study(uid, date)
34
+ self.patient.studies.append(s)
35
+ return StudyBuilder(self, s)
36
+
37
+ def build(self):
38
+ """Returns the fully constructed Patient object."""
39
+ return self.patient
40
+
41
+
42
+ class StudyBuilder:
43
+ """Fluent Builder for Study entities."""
44
+
45
+ def __init__(self, parent, study): self.parent, self.study = parent, study
46
+
47
+ def add_series(self, uid, mod, num):
48
+ """Adds a child Series to this Study."""
49
+ s = Series(uid, mod, num)
50
+ self.study.series.append(s)
51
+ return SeriesBuilder(self, s)
52
+
53
+ def end_study(self):
54
+ """Finishes the Study configuration and returns the parent PatientBuilder."""
55
+ return self.parent
56
+
57
+
58
+ class SeriesBuilder:
59
+ """Fluent Builder for Series entities."""
60
+
61
+ def __init__(self, parent, series): self.parent, self.series = parent, series
62
+
63
+ def set_equipment(self, man, mod, sn=""):
64
+ """Sets the Equipment metadata for this Series."""
65
+ self.series.equipment = Equipment(man, mod, sn)
66
+ return self
67
+
68
+ def add_instance(self, uid, cls, num):
69
+ """Adds a child Instance to this Series."""
70
+ inst = Instance(uid, cls, num)
71
+ self.series.instances.append(inst)
72
+ return InstanceContextBuilder(self, inst)
73
+
74
+ def end_series(self):
75
+ """Finishes the Series configuration and returns the parent StudyBuilder."""
76
+ return self.parent
77
+
78
+
79
+ class InstanceContextBuilder:
80
+ """Fluent context for configuring a single Instance."""
81
+
82
+ def __init__(self, parent, instance): self.parent, self.instance = parent, instance
83
+
84
+ def set_attribute(self, tag, val):
85
+ """Sets a generic DICOM attribute."""
86
+ self.instance.set_attr(tag, val)
87
+ return self
88
+
89
+ def set_pixel_data(self, arr):
90
+ """Injects pixel data (numpy array)."""
91
+ self.instance.set_pixel_data(arr)
92
+ return self
93
+
94
+ def end_instance(self):
95
+ """Finishes the Instance and returns the parent SeriesBuilder."""
96
+ return self.parent
@@ -0,0 +1,277 @@
1
+ """
2
+ Configuration manager for handling Isocenter system settings.
3
+
4
+ This module provides functionality to load, validate, and manage configuration
5
+ files for the Isocenter application. It supports unified YAML configurations,
6
+ legacy formats, and privacy profile management.
7
+ """
8
+
9
+ import os
10
+ import logging
11
+ import copy
12
+ from typing import Dict, Any, List, Optional
13
+ import json
14
+ import re
15
+ import yaml
16
+
17
+ from dotenv import load_dotenv
18
+
19
+ from .profiles import PRIVACY_PROFILES
20
+
21
+ CONFIG_VERSION = "2.0"
22
+
23
+ # Load environment variables
24
+ load_dotenv()
25
+
26
+
27
+ def get_logger() -> logging.Logger:
28
+ """
29
+ Retrieves the configured logger for the Isocenter application.
30
+
31
+ Returns:
32
+ logging.Logger: The 'isocenter' logger instance.
33
+ """
34
+ return logging.getLogger("isocenter")
35
+
36
+
37
+ def load_unified_config(path: str) -> Dict[str, Any]:
38
+ """
39
+ Loads the unified configuration file (YAML).
40
+
41
+ Supports legacy list-based config (machine rules only) and new dict-based config.
42
+ Merges 'privacy_profile' if specified (Built-in or External).
43
+
44
+ Args:
45
+ path (str): Path to the YAML configuration file.
46
+
47
+ Returns:
48
+ Dict[str, Any]: The loaded configuration dictionary.
49
+
50
+ Raises:
51
+ ValueError: If file is not YAML.
52
+ """
53
+ if not (path.endswith('.yaml') or path.endswith('.yml')):
54
+ raise ValueError("Configuration file must be a YAML file (.yaml or .yml)")
55
+
56
+ with open(path, "r", encoding="utf-8") as f:
57
+ data = yaml.safe_load(f)
58
+
59
+ # Handle Standard Config
60
+ config = data
61
+
62
+ # Merge Privacy Profile
63
+ if "privacy_profile" in config:
64
+ profile_name = config["privacy_profile"]
65
+
66
+ profile_rules = {}
67
+
68
+ # 1. Check Built-in Profiles
69
+ if profile_name in PRIVACY_PROFILES:
70
+ profile_rules = copy.deepcopy(PRIVACY_PROFILES[profile_name])
71
+ get_logger().info("Loaded built-in privacy profile '%s' with %d rules.", profile_name, len(profile_rules))
72
+
73
+ # 2. Check External File (Custom Profile)
74
+ elif os.path.exists(profile_name):
75
+ try:
76
+ # We reuse load_phi_config logic to parse just the tags
77
+ profile_rules = ConfigLoader.load_phi_config(profile_name)
78
+ get_logger().info("Loaded custom privacy profile from '%s' with %d rules.", profile_name, len(profile_rules))
79
+ except (ValueError, OSError) as e:
80
+ get_logger().error("Failed to load custom profile '%s': %s", profile_name, e)
81
+
82
+ else:
83
+ get_logger().warning("Unknown privacy profile reference '%s' (not a built-in or file). Ignoring.", profile_name)
84
+
85
+ if not profile_rules:
86
+ # Ignoring it means ignoring it everywhere. Leaving the name in
87
+ # the config would let the compliance report name a profile that
88
+ # contributed no rules -- protection that never ran.
89
+ config.pop("privacy_profile", None)
90
+
91
+ if profile_rules:
92
+ # User rules override profile rules
93
+ user_rules = config.get("phi_tags", {})
94
+ profile_rules.update(user_rules)
95
+ config["phi_tags"] = profile_rules
96
+
97
+ return config
98
+
99
+
100
+ class ConfigLoader:
101
+ """
102
+ Loads and validates configuration files for the Isocenter system.
103
+
104
+ This class provides static methods to parse unified YAML configuration files (v2.0),
105
+ legacy configuration formats, and PHI tag definitions. It handles configuration
106
+ validation, normalization, and file I/O operations.
107
+
108
+ Supports multiple configuration formats:
109
+ - Unified v2.0 YAML configs with PHI tags, machine rules, and date jitter settings
110
+ - Legacy machine rule configurations
111
+ - PHI tag definitions (from files or internal defaults)
112
+
113
+ The class also provides utility methods for filename sanitization and YAML parsing.
114
+ """
115
+
116
+ @staticmethod
117
+ def load_unified_config(
118
+ filepath: str) -> tuple[Dict[str, Any], List[Dict[str, Any]],
119
+ Dict[str, Any], bool, Optional[str]]:
120
+ """
121
+ Parses the unified YAML config (v2.0).
122
+
123
+ Extracts the core configuration components: PHI tags, machine rules,
124
+ date jitter settings, and global flags.
125
+
126
+ Args:
127
+ filepath (str): Path to the config file.
128
+
129
+ Returns:
130
+ tuple: (phi_tags, machine_rules, date_jitter_config,
131
+ remove_private_tags, privacy_profile). The last element is the
132
+ name of the profile whose rules were merged, or None -- an
133
+ unknown reference resolves to None rather than to its own name,
134
+ because it contributed nothing.
135
+ """
136
+ # Call the top-level loader which handles YAML, Legacy List, and Privacy Profiles
137
+ data = load_unified_config(filepath)
138
+
139
+ phi_tags = data.get("phi_tags", {})
140
+ # Support 'machines' (v2) or 'machine_rules' (legacy internal)
141
+ machine_rules = data.get("machines", data.get("machine_rules", []))
142
+
143
+ # Date Jitter Normalization
144
+ dj = data.get("date_jitter", {"min_days": -365, "max_days": -1})
145
+ if isinstance(dj, int):
146
+ # Legacy support or user provided int. Convert to fixed shift.
147
+ date_jitter_config = {"min_days": dj, "max_days": dj}
148
+ else:
149
+ date_jitter_config = dj
150
+
151
+ remove_private_tags = data.get("remove_private_tags", True)
152
+
153
+ # Validate machines
154
+ for i, rule in enumerate(machine_rules):
155
+ ConfigLoader._validate_rule(rule, i)
156
+
157
+ return (phi_tags, machine_rules, date_jitter_config,
158
+ remove_private_tags, data.get("privacy_profile"))
159
+
160
+ @staticmethod
161
+ def load_redaction_rules(filepath: str) -> List[Dict[str, Any]]:
162
+ """
163
+ Legacy/Convenience support for loading only Machine Rules.
164
+
165
+ Use this if you only need the 'machines' list from a unified config,
166
+ or an old-style legacy config file.
167
+
168
+ Args:
169
+ filepath (str): Path to the config file.
170
+
171
+ Returns:
172
+ List[Dict[str, Any]]: List of validated machine rule dictionaries.
173
+ """
174
+ data = ConfigLoader._load_yaml(filepath)
175
+
176
+ rules = []
177
+
178
+ if "machines" in data:
179
+ rules = data["machines"] # v1 or v2
180
+ else:
181
+ get_logger().warning("Config Warning: Could not find 'machines' list.")
182
+
183
+ for i, rule in enumerate(rules):
184
+ ConfigLoader._validate_rule(rule, i)
185
+
186
+ return rules
187
+
188
+ @staticmethod
189
+ def load_phi_config(filepath: str = None) -> Dict[str, str]:
190
+ """
191
+ Legacy/Convenience support for loading only PHI Tags.
192
+
193
+ Arg:
194
+ filepath (str, optional): Path to config file. If None, loads internal defaults.
195
+
196
+ Returns:
197
+ Dict: Mapping of tags to configuration (action/name).
198
+ """
199
+ if filepath:
200
+ data = ConfigLoader._load_yaml(filepath)
201
+
202
+ # Support v2 unified file used as simple PHI config
203
+ if "phi_tags" in data:
204
+ return data["phi_tags"]
205
+ return data.get("phi_tags", data) # Fallback to assumes root dict is tags if no key
206
+ else:
207
+ # Load default from package resources
208
+ base = os.path.dirname(os.path.abspath(__file__))
209
+ filepath = os.path.join(base, "resources", "phi_tags.json")
210
+ if os.path.exists(filepath):
211
+ # Resource is likely still JSON for internal defaults unless we change it too.
212
+ # But sticking to JSON for internal resources is fine, OR we change helper to handle both?
213
+ # User asked to drop JSON support for *config files*.
214
+ # Let's support JSON just for internal resources via simple json load if yaml fails or extension check?
215
+ # Actually, clearer to migrate the resource to YAML too?
216
+ # Or just use json.load here explicitly since it's internal.
217
+ # import json <-- Removed
218
+
219
+ with open(filepath, 'r', encoding="utf-8") as f:
220
+ return json.load(f).get("phi_tags", {})
221
+ return {}
222
+
223
+ @staticmethod
224
+ def clean_filename(filename: str) -> str:
225
+ """
226
+ Sanitizes a string to be safe for use as a filename.
227
+
228
+ Replaces spaces with underscores and removes non-alphanumeric characters
229
+ (except key delimiters like dash/dot).
230
+ """
231
+ # import re <-- Removed
232
+
233
+ s = str(filename).strip().replace(" ", "_")
234
+ return re.sub(r'(?u)[^-\w.]', '', s)
235
+
236
+ @staticmethod
237
+ def _load_yaml(filepath: str) -> Dict[str, Any]:
238
+ if not os.path.exists(filepath):
239
+ raise FileNotFoundError(f"Configuration file not found: {filepath}")
240
+
241
+ try:
242
+ with open(filepath, 'r', encoding="utf-8") as f:
243
+ return yaml.safe_load(f)
244
+ except yaml.YAMLError as e:
245
+ raise ValueError(f"Invalid YAML format in {filepath}: {e}") from e
246
+
247
+ @staticmethod
248
+ def _validate_rule(rule: Dict[str, Any], index: int):
249
+ sn = rule.get("serial_number")
250
+ if not sn:
251
+ raise ValueError(f"Rule #{index}: Missing 'serial_number'.")
252
+
253
+ zones = rule.get("redaction_zones", [])
254
+ if not isinstance(zones, list):
255
+ raise ValueError(f"Rule #{index} ({sn}): 'redaction_zones' must be a list.")
256
+
257
+ for z_idx, zone in enumerate(zones):
258
+ if isinstance(zone, list):
259
+ roi = zone
260
+ elif isinstance(zone, dict):
261
+ roi = zone.get("roi")
262
+ else:
263
+ raise ValueError(
264
+ f"Rule #{index} ({sn}), Zone #{z_idx}: Invalid zone format (must be list or dict).")
265
+
266
+ if not roi or not isinstance(roi, list) or len(roi) != 4:
267
+ raise ValueError(
268
+ f"Rule #{index} ({sn}), Zone #{z_idx}: ROI must be a list of 4 integers.")
269
+
270
+ r1, r2, c1, c2 = roi
271
+ if any(x < 0 for x in roi):
272
+ raise ValueError(
273
+ f"Rule #{index} ({sn}), Zone #{z_idx}: ROI values must be non-negative.")
274
+
275
+ if r1 > r2 or c1 > c2:
276
+ raise ValueError(
277
+ f"Rule #{index} ({sn}), Zone #{z_idx}: Invalid ROI logic (Start > End).")