dicompare 0.1.8__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.
dicompare/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ __version__ = "0.1.8"
2
+
3
+ # Import core functionalities
4
+ from .io import get_dicom_values, load_dicom, load_json_session, load_dicom_session, load_python_session
5
+ from .compliance import check_session_compliance_with_json_reference, check_session_compliance_with_python_module, check_dicom_compliance, is_session_compliant, is_dicom_compliant
6
+ from .mapping import map_to_json_reference, interactive_mapping_to_json_reference, interactive_mapping_to_python_reference
7
+ from .validation import BaseValidationModel, ValidationError, validator
File without changes
@@ -0,0 +1,93 @@
1
+ import sys
2
+ import json
3
+ import argparse
4
+ import pandas as pd
5
+
6
+ from dicompare.io import load_json_session, load_python_session, load_dicom_session
7
+ from dicompare.compliance import check_session_compliance_with_json_reference, check_session_compliance_with_python_module
8
+ from dicompare.mapping import map_to_json_reference, interactive_mapping_to_json_reference, interactive_mapping_to_python_reference
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(description="Generate compliance summaries for a DICOM session.")
12
+ parser.add_argument("--json_ref", help="Path to the JSON reference file.")
13
+ parser.add_argument("--python_ref", help="Path to the Python module containing validation models.")
14
+ parser.add_argument("--in_session", required=True, help="Directory path for the DICOM session.")
15
+ parser.add_argument("--out_json", default="compliance_report.json", help="Path to save the JSON compliance summary report.")
16
+ parser.add_argument("--auto_yes", action="store_true", help="Automatically map acquisitions to series.")
17
+ args = parser.parse_args()
18
+
19
+ if not (args.json_ref or args.python_ref):
20
+ raise ValueError("You must provide either --json_ref or --python_ref.")
21
+
22
+ # Load the reference models and fields
23
+ if args.json_ref:
24
+ acquisition_fields, reference_fields, ref_session = load_json_session(json_ref=args.json_ref)
25
+ elif args.python_ref:
26
+ ref_models = load_python_session(module_path=args.python_ref)
27
+ acquisition_fields = ["ProtocolName"]
28
+
29
+ # Load the input session
30
+ in_session = load_dicom_session(
31
+ session_dir=args.in_session,
32
+ acquisition_fields=acquisition_fields,
33
+ )
34
+
35
+ if args.json_ref:
36
+ # Group by all existing unique combinations of reference fields
37
+ in_session = (
38
+ in_session.groupby(reference_fields)
39
+ .apply(lambda x: x.reset_index(drop=True))
40
+ .reset_index(drop=True) # Reset the index to avoid index/column ambiguity
41
+ )
42
+
43
+ # Assign unique group numbers for each combination of reference fields
44
+ in_session["Series"] = (
45
+ in_session.groupby(reference_fields, dropna=False).ngroup().add(1).apply(lambda x: f"Series {x}")
46
+ )
47
+
48
+ if args.json_ref:
49
+ session_map = map_to_json_reference(in_session, ref_session)
50
+ if not args.auto_yes and sys.stdin.isatty():
51
+ session_map = interactive_mapping_to_json_reference(in_session, ref_session, initial_mapping=session_map)
52
+ else:
53
+ session_map = interactive_mapping_to_python_reference(in_session, ref_models)
54
+
55
+
56
+ # Perform compliance check
57
+ if args.json_ref:
58
+ compliance_summary = check_session_compliance_with_json_reference(
59
+ in_session=in_session,
60
+ ref_session=ref_session,
61
+ session_map=session_map
62
+ )
63
+ else:
64
+ compliance_summary = check_session_compliance_with_python_module(
65
+ in_session=in_session,
66
+ ref_models=ref_models,
67
+ session_map=session_map
68
+ )
69
+ compliance_df = pd.DataFrame(compliance_summary)
70
+
71
+ # If compliance_df is empty, print message and exit
72
+ if compliance_df.empty:
73
+ print("Session is fully compliant with the reference model.")
74
+ return
75
+
76
+ # Inline summary output
77
+ for entry in compliance_summary:
78
+ if entry.get('acquisition'): print(f"Acquisition: {entry.get('acquisition')}")
79
+ if entry.get('field'): print(f"Field: {entry.get('field')}")
80
+ if entry.get('value'): print(f"Value: {entry.get('value')}")
81
+ if entry.get('rule'): print(f"Rule: {entry.get('rule')}")
82
+ if entry.get('message'): print(f"Message: {entry.get('message')}")
83
+ if entry.get('passed'): print(f"Passed: {entry.get('passed')}")
84
+ print("-" * 40)
85
+
86
+ # Save compliance summary to JSON
87
+ if args.out_json:
88
+ with open(args.out_json, "w") as f:
89
+ json.dump(compliance_summary, f)
90
+
91
+ if __name__ == "__main__":
92
+ main()
93
+
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python
2
+
3
+ import argparse
4
+ import json
5
+ import pandas as pd
6
+ from dicompare.io import load_dicom_session
7
+ from dicompare.utils import clean_string
8
+
9
+ def make_hashable(value):
10
+ """
11
+ Convert a value into a hashable format.
12
+ Handles lists, dictionaries, and other non-hashable types.
13
+ """
14
+ if isinstance(value, list):
15
+ return tuple(value)
16
+ elif isinstance(value, dict):
17
+ return tuple((k, make_hashable(v)) for k, v in value.items())
18
+ elif isinstance(value, set):
19
+ return tuple(sorted(make_hashable(v) for v in value))
20
+ return value
21
+
22
+
23
+ def create_json_reference(session_df, acquisition_fields, reference_fields, name_template="{ProtocolName}"):
24
+ """
25
+ Create a JSON reference from the session DataFrame.
26
+
27
+ Args:
28
+ session_df (pd.DataFrame): DataFrame of the DICOM session.
29
+ acquisition_fields (List[str]): Fields to uniquely identify each acquisition.
30
+ reference_fields (List[str]): Fields to include in JSON reference.
31
+ name_template (str): Naming template for acquisitions/series.
32
+
33
+ Returns:
34
+ dict: JSON structure representing the reference.
35
+ """
36
+ # Ensure all values in the DataFrame are hashable
37
+ for col in session_df.columns:
38
+ session_df[col] = session_df[col].apply(make_hashable)
39
+
40
+ json_reference = {"acquisitions": {}}
41
+
42
+ # Group by acquisition
43
+ for acquisition_name, group in session_df.groupby("Acquisition"):
44
+ acquisition_entry = {"fields": [], "series": []}
45
+
46
+ # Add acquisition-level fields
47
+ for field in acquisition_fields:
48
+ unique_values = group[field].dropna().unique()
49
+ if len(unique_values) == 1:
50
+ acquisition_entry["fields"].append({"field": field, "value": unique_values[0]})
51
+
52
+ # Group by series within each acquisition
53
+ series_fields = list(set(reference_fields) - set(acquisition_fields))
54
+ if series_fields:
55
+ series_groups = group.groupby(series_fields, dropna=False)
56
+
57
+ for i, (series_key, series_group) in enumerate(series_groups, start=1):
58
+ series_entry = {
59
+ "name": f"Series {i}",
60
+ "fields": [{"field": field, "value": series_key[j]} for j, field in enumerate(series_fields)]
61
+ }
62
+ acquisition_entry["series"].append(series_entry)
63
+
64
+ # Exclude reference fields from acquisition-level fields if they appear in series
65
+ acquisition_entry["fields"] = [
66
+ field for field in acquisition_entry["fields"] if field["field"] not in reference_fields
67
+ ]
68
+
69
+ # Add to JSON reference
70
+ json_reference["acquisitions"][clean_string(acquisition_name)] = acquisition_entry
71
+
72
+ return json_reference
73
+
74
+
75
+
76
+ def main():
77
+ parser = argparse.ArgumentParser(description="Generate a JSON reference for DICOM compliance.")
78
+ parser.add_argument("--in_session_dir", required=True, help="Directory containing DICOM files for the session.")
79
+ parser.add_argument("--out_json_ref", required=True, help="Path to save the generated JSON reference.")
80
+ parser.add_argument("--acquisition_fields", nargs="+", required=True, help="Fields to uniquely identify each acquisition.")
81
+ parser.add_argument("--reference_fields", nargs="+", required=True, help="Fields to include in JSON reference with their values.")
82
+ parser.add_argument("--name_template", default="{ProtocolName}", help="Naming template for each acquisition series.")
83
+ args = parser.parse_args()
84
+
85
+ # Read DICOM session
86
+ session_data = load_dicom_session(
87
+ session_dir=args.in_session_dir,
88
+ acquisition_fields=args.acquisition_fields,
89
+ )
90
+
91
+ # Filter fields in DataFrame
92
+ relevant_fields = set(args.acquisition_fields + args.reference_fields)
93
+ session_data = session_data[list(relevant_fields.intersection(session_data.columns)) + ["Acquisition"]]
94
+
95
+ # Generate JSON reference
96
+ json_reference = create_json_reference(
97
+ session_df=session_data,
98
+ acquisition_fields=args.acquisition_fields,
99
+ reference_fields=args.reference_fields,
100
+ name_template=args.name_template,
101
+ )
102
+
103
+ # Write JSON to output file
104
+ with open(args.out_json_ref, "w") as f:
105
+ json.dump(json_reference, f, indent=4)
106
+ print(f"JSON reference saved to {args.out_json_ref}")
107
+
108
+
109
+ if __name__ == "__main__":
110
+ main()
@@ -0,0 +1,18 @@
1
+ import os
2
+ import webbrowser
3
+ from importlib.resources import files
4
+
5
+ def main():
6
+ # Get the package base directory using `importlib.resources`
7
+ package_dir = files("dicompare").joinpath("docs", "index.html")
8
+
9
+ # Convert the resource path to an absolute file path
10
+ docs_path = str(package_dir)
11
+
12
+ # Check if the file exists
13
+ if not os.path.exists(docs_path):
14
+ print(f"Error: Documentation not found at {docs_path}.")
15
+ return
16
+
17
+ # Open the documentation in the default web browser
18
+ webbrowser.open(f"file://{docs_path}")
@@ -0,0 +1,343 @@
1
+ """
2
+ This module provides functions for validating a DICOM sessions.
3
+
4
+ The module supports compliance checks for JSON-based reference sessions and Python module-based validation models.
5
+
6
+ """
7
+
8
+ from typing import List, Dict, Any, Tuple
9
+ from dicompare.validation import BaseValidationModel
10
+ import pandas as pd
11
+
12
+ def check_session_compliance_with_json_reference(
13
+ in_session: pd.DataFrame,
14
+ ref_session: Dict[str, Any],
15
+ session_map: Dict[Tuple[str, str], Tuple[str, str]]
16
+ ) -> List[Dict[str, Any]]:
17
+ """
18
+ Validate a DICOM session against a JSON reference session.
19
+
20
+ Args:
21
+ in_session (pd.DataFrame): Input session DataFrame containing DICOM metadata.
22
+ ref_session (Dict[str, Any]): Reference session data loaded from a JSON file.
23
+ session_map (Dict[Tuple[str, str], Tuple[str, str]]): Mapping of input acquisitions/series
24
+ to reference acquisitions/series.
25
+
26
+ Returns:
27
+ List[Dict[str, Any]]: A list of compliance issues, where each issue is represented as a dictionary.
28
+ """
29
+ compliance_summary = []
30
+
31
+ # Iterate over the session mapping
32
+ for (in_acq_name, in_series_name), (ref_acq_name, ref_series_name) in session_map.items():
33
+ # Filter the input session for the current acquisition and series
34
+ in_acq_series = in_session[
35
+ (in_session["Acquisition"] == in_acq_name) &
36
+ (in_session["Series"] == in_series_name)
37
+ ]
38
+
39
+ if in_acq_series.empty:
40
+ compliance_summary.append({
41
+ "reference acquisition": (ref_acq_name, ref_series_name),
42
+ "input acquisition": (in_acq_name, in_series_name),
43
+ "field": "Acquisition-Level Error",
44
+ "value": None,
45
+ "rule": "Input acquisition and series must be present.",
46
+ "message": "Input acquisition or series not found.",
47
+ "passed": "❌"
48
+ })
49
+ continue
50
+
51
+ # Filter the reference session for the current acquisition and series
52
+ ref_acq = ref_session["acquisitions"].get(ref_acq_name, {})
53
+ ref_series = next(
54
+ (series for series in ref_acq.get("series", []) if series["name"] == ref_series_name),
55
+ None
56
+ )
57
+
58
+ if not ref_series:
59
+ compliance_summary.append({
60
+ "reference acquisition": (ref_acq_name, ref_series_name),
61
+ "input acquisition": (in_acq_name, in_series_name),
62
+ "field": "Reference-Level Error",
63
+ "value": None,
64
+ "rule": "Reference acquisition and series must be present.",
65
+ "message": "Reference acquisition or series not found.",
66
+ "passed": "❌"
67
+ })
68
+ continue
69
+
70
+ # Iterate through the reference fields and check compliance
71
+ for ref_field in ref_series.get("fields", []):
72
+ field_name = ref_field["field"]
73
+ expected_value = ref_field.get("value")
74
+ tolerance = ref_field.get("tolerance")
75
+ contains = ref_field.get("contains")
76
+
77
+ # Check the corresponding field in the input session DataFrame
78
+ if field_name not in in_acq_series.columns:
79
+ compliance_summary.append({
80
+ "reference acquisition": (ref_acq_name, ref_series_name),
81
+ "input acquisition": (in_acq_name, in_series_name),
82
+ "field": field_name,
83
+ "value": None,
84
+ "rule": "Field must be present.",
85
+ "message": "Field not found in input session.",
86
+ "passed": "❌"
87
+ })
88
+ continue
89
+
90
+ actual_value = in_acq_series[field_name].iloc[0]
91
+
92
+ # Contains check
93
+ if contains is not None:
94
+ if not isinstance(actual_value, list) or contains not in actual_value:
95
+ compliance_summary.append({
96
+ "reference acquisition": (ref_acq_name, ref_series_name),
97
+ "input acquisition": (in_acq_name, in_series_name),
98
+ "field": field_name,
99
+ "value": actual_value,
100
+ "rule": "Field must contain value.",
101
+ "message": f"Expected to contain {contains}, got {actual_value}.",
102
+ "passed": "❌"
103
+ })
104
+
105
+ # Tolerance check
106
+ elif tolerance is not None and isinstance(actual_value, (int, float)):
107
+ if not (expected_value - tolerance <= actual_value <= expected_value + tolerance):
108
+ compliance_summary.append({
109
+ "reference acquisition": (ref_acq_name, ref_series_name),
110
+ "input acquisition": (in_acq_name, in_series_name),
111
+ "field": field_name,
112
+ "value": actual_value,
113
+ "rule": "Field must be within tolerance.",
114
+ "message": f"Expected {expected_value} ± {tolerance}, got {actual_value}.",
115
+ "passed": "❌"
116
+ })
117
+
118
+ # Exact match check
119
+ elif expected_value is not None and actual_value != expected_value:
120
+ compliance_summary.append({
121
+ "reference acquisition": (ref_acq_name, ref_series_name),
122
+ "input acquisition": (in_acq_name, in_series_name),
123
+ "field": field_name,
124
+ "value": actual_value,
125
+ "rule": "Field must match expected value.",
126
+ "message": f"Expected {expected_value}, got {actual_value}.",
127
+ "passed": "❌"
128
+ })
129
+
130
+ return compliance_summary
131
+
132
+ def check_session_compliance_with_python_module(
133
+ in_session: pd.DataFrame,
134
+ ref_models: Dict[str, BaseValidationModel],
135
+ session_map: Dict[str, str],
136
+ raise_errors: bool = False
137
+ ) -> List[Dict[str, Any]]:
138
+ """
139
+ Validate a DICOM session against Python module-based validation models.
140
+
141
+ Args:
142
+ in_session (pd.DataFrame): Input session DataFrame containing DICOM metadata.
143
+ ref_models (Dict[str, BaseValidationModel]): Dictionary mapping acquisition names to
144
+ validation models.
145
+ session_map (Dict[str, str]): Mapping of reference acquisitions to input acquisitions.
146
+ raise_errors (bool): Whether to raise exceptions for validation failures. Defaults to False.
147
+
148
+ Returns:
149
+ List[Dict[str, Any]]: A list of compliance issues, where each issue is represented as a dictionary.
150
+
151
+ Raises:
152
+ ValueError: If `raise_errors` is True and validation fails for any acquisition.
153
+ """
154
+ compliance_summary = []
155
+
156
+ for ref_acq_name, in_acq_name in session_map.items():
157
+ # Filter the input session for the current acquisition
158
+ in_acq = in_session[in_session["Acquisition"] == in_acq_name]
159
+
160
+ if in_acq.empty:
161
+ compliance_summary.append({
162
+ "reference acquisition": ref_acq_name,
163
+ "input acquisition": in_acq_name,
164
+ "field": "Acquisition-Level Error",
165
+ "value": None,
166
+ "rule": "Input acquisition must be present.",
167
+ "message": f"Input acquisition '{in_acq_name}' not found.",
168
+ "passed": "❌"
169
+ })
170
+ continue
171
+
172
+ # Retrieve reference model
173
+ ref_model_cls = ref_models.get(ref_acq_name)
174
+ if not ref_model_cls:
175
+ compliance_summary.append({
176
+ "reference acquisition": ref_acq_name,
177
+ "input acquisition": in_acq_name,
178
+ "field": "Model Error",
179
+ "value": None,
180
+ "rule": "Reference model must exist.",
181
+ "message": f"No model found for reference acquisition '{ref_acq_name}'.",
182
+ "passed": "❌"
183
+ })
184
+ continue
185
+ ref_model = ref_model_cls()
186
+
187
+ # Prepare acquisition data as a single DataFrame
188
+ acquisition_df = in_acq.copy()
189
+
190
+ # Validate using the reference model
191
+ success, errors, passes = ref_model.validate(data=acquisition_df)
192
+
193
+ # Record errors
194
+ for error in errors:
195
+ compliance_summary.append({
196
+ "reference acquisition": ref_acq_name,
197
+ "reference series": None,
198
+ "input acquisition": in_acq_name,
199
+ "input series": None,
200
+ "field": error['field'],
201
+ "value": error['value'],
202
+ "rule": error['rule'],
203
+ "message": error['message'],
204
+ "passed": "❌"
205
+ })
206
+
207
+ # Record passes
208
+ for passed_test in passes:
209
+ compliance_summary.append({
210
+ "reference acquisition": ref_acq_name,
211
+ "reference series": None,
212
+ "input acquisition": in_acq_name,
213
+ "input series": None,
214
+ "field": passed_test['field'],
215
+ "value": passed_test['value'],
216
+ "rule": passed_test['rule'],
217
+ "message": passed_test['message'],
218
+ "passed": "✅"
219
+ })
220
+
221
+ # Raise an error if validation fails and `raise_errors` is True
222
+ if raise_errors and not success:
223
+ raise ValueError(f"Validation failed for acquisition '{in_acq_name}'.")
224
+
225
+ return compliance_summary
226
+
227
+ def check_dicom_compliance(
228
+ reference_fields: List[Dict[str, Any]],
229
+ dicom_values: Dict[str, Any]
230
+ ) -> List[Dict[str, Any]]:
231
+ """
232
+ Validate individual DICOM values against reference fields.
233
+
234
+ Args:
235
+ reference_fields (List[Dict[str, Any]]): A list of dictionaries defining the expected values
236
+ and rules for validation (e.g., tolerance, contains).
237
+ dicom_values (Dict[str, Any]): Dictionary of DICOM metadata values to be validated.
238
+
239
+ Returns:
240
+ List[Dict[str, Any]]: A list of compliance issues, where each issue is represented as a dictionary.
241
+ """
242
+ compliance_summary = []
243
+
244
+ for ref_field in reference_fields:
245
+ field_name = ref_field["field"]
246
+ expected_value = ref_field.get("value")
247
+ tolerance = ref_field.get("tolerance")
248
+ contains = ref_field.get("contains")
249
+ actual_value = dicom_values.get(field_name, "N/A")
250
+
251
+ # Convert lists to tuples for comparison
252
+ if expected_value is not None and isinstance(expected_value, list):
253
+ expected_value = tuple(expected_value)
254
+ if actual_value is not None and isinstance(actual_value, list):
255
+ actual_value = tuple(actual_value)
256
+
257
+ # Check for missing field
258
+ if actual_value == "N/A":
259
+ compliance_summary.append({
260
+ "field": field_name,
261
+ "value": actual_value,
262
+ "rule": "Field must be present.",
263
+ "message": "Field not found.",
264
+ "passed": "❌",
265
+ })
266
+ continue
267
+
268
+ # Contains check
269
+ if contains is not None:
270
+ if not isinstance(actual_value, list) or contains not in actual_value:
271
+ compliance_summary.append({
272
+ "field": field_name,
273
+ "value": actual_value,
274
+ "rule": "Field must contain value.",
275
+ "message": f"Expected to contain {contains}, got {actual_value}.",
276
+ "passed": "❌",
277
+ })
278
+
279
+ # Tolerance check
280
+ elif tolerance is not None and isinstance(actual_value, (int, float)):
281
+ if not (expected_value - tolerance <= actual_value <= expected_value + tolerance):
282
+ compliance_summary.append({
283
+ "field": field_name,
284
+ "value": actual_value,
285
+ "rule": "Field must be within tolerance.",
286
+ "message": f"Expected {expected_value} ± {tolerance}, got {actual_value}.",
287
+ "passed": "❌",
288
+ })
289
+
290
+ # Exact match check
291
+ elif expected_value is not None and actual_value != expected_value:
292
+ compliance_summary.append({
293
+ "field": field_name,
294
+ "value": actual_value,
295
+ "rule": "Field must match expected value.",
296
+ "message": f"Expected {expected_value}, got {actual_value}.",
297
+ "passed": "❌",
298
+ })
299
+
300
+ return compliance_summary
301
+
302
+ def is_session_compliant(
303
+ in_session: Dict[str, Dict[str, Any]],
304
+ ref_session: Dict[str, Dict[str, Any]],
305
+ session_map: Dict[Tuple[str, str], Tuple[str, str]]
306
+ ) -> bool:
307
+ """
308
+ Check if the entire DICOM session complies with the reference session.
309
+
310
+ Args:
311
+ in_session (Dict): Input session data containing DICOM metadata.
312
+ ref_session (Dict): Reference session data containing expected metadata and rules.
313
+ session_map (Dict): Mapping of input acquisitions/series to reference acquisitions/series.
314
+
315
+ Returns:
316
+ bool: True if the session is fully compliant, False otherwise.
317
+ """
318
+
319
+ compliance_issues = check_session_compliance_with_json_reference(in_session, ref_session, session_map)
320
+ return len(compliance_issues) == 0
321
+
322
+ def is_dicom_compliant(
323
+ reference_model: BaseValidationModel,
324
+ dicom_values: Dict[str, Any]
325
+ ) -> bool:
326
+ """
327
+ Check if a DICOM file's metadata complies with a validation model.
328
+
329
+ Args:
330
+ reference_model (BaseValidationModel): The validation model defining expected metadata.
331
+ dicom_values (Dict[str, Any]): Dictionary of DICOM metadata values to be validated.
332
+
333
+ Returns:
334
+ bool: True if the DICOM metadata is compliant, False otherwise.
335
+ """
336
+
337
+ compliance_issues = check_dicom_compliance(
338
+ reference_model.fields,
339
+ dicom_values
340
+ )
341
+
342
+ return len(compliance_issues) == 0
343
+