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.
- isocenter/__init__.py +63 -0
- isocenter/automation.py +143 -0
- isocenter/builders.py +96 -0
- isocenter/config_manager.py +277 -0
- isocenter/configuration.py +208 -0
- isocenter/crypto.py +73 -0
- isocenter/discovery.py +336 -0
- isocenter/entities.py +548 -0
- isocenter/exporters/__init__.py +76 -0
- isocenter/exporters/dicom.py +19 -0
- isocenter/exporters/wfdb.py +567 -0
- isocenter/imagecodecs_handler.py +153 -0
- isocenter/io_handlers.py +1228 -0
- isocenter/logger.py +69 -0
- isocenter/manifest.py +185 -0
- isocenter/murmur.py +215 -0
- isocenter/parallel.py +199 -0
- isocenter/persistence.py +1822 -0
- isocenter/persistence_manager.py +145 -0
- isocenter/pixel_analysis.py +219 -0
- isocenter/privacy.py +418 -0
- isocenter/profiles.py +72 -0
- isocenter/remediation.py +390 -0
- isocenter/reporting.py +155 -0
- isocenter/resources/ctp_rules.json +662 -0
- isocenter/resources/phi_tags.json +10 -0
- isocenter/resources/redaction_rules.json +27 -0
- isocenter/resources/research_tags.json +36 -0
- isocenter/reversibility.py +148 -0
- isocenter/services.py +593 -0
- isocenter/session.py +2200 -0
- isocenter/sidecar.py +132 -0
- isocenter/store.py +66 -0
- isocenter/utils/__init__.py +0 -0
- isocenter/utils/ctp_parser.py +136 -0
- isocenter/validation.py +56 -0
- isocenter/verification.py +160 -0
- isocenter/waveform.py +282 -0
- isocenter-0.7.0.dist-info/METADATA +435 -0
- isocenter-0.7.0.dist-info/RECORD +43 -0
- isocenter-0.7.0.dist-info/WHEEL +5 -0
- isocenter-0.7.0.dist-info/licenses/LICENSE +661 -0
- 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"]
|
isocenter/automation.py
ADDED
|
@@ -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).")
|