PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
xa_iod_validator.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
13
14#include <sstream>
15
17
18using namespace kcenon::pacs::core;
19
20// =============================================================================
21// xa_iod_validator Implementation
22// =============================================================================
23
25 : options_(options) {}
26
28 validation_result result;
29 result.is_valid = true;
30
31 // Validate mandatory modules
33 validate_patient_module(dataset, result.findings);
37 validate_xa_image_module(dataset, result.findings);
38 validate_sop_common_module(dataset, result.findings);
39 }
40
43 }
44
46 check_positioner_angles(dataset, result.findings);
47 }
48
51 }
52
53 // Check for errors
54 for (const auto& finding : result.findings) {
55 if (finding.severity == validation_severity::error) {
56 result.is_valid = false;
57 break;
58 }
59 if (options_.strict_mode && finding.severity == validation_severity::warning) {
60 result.is_valid = false;
61 break;
62 }
63 }
64
65 return result;
66}
67
70 // First do standard validation
71 auto result = validate(dataset);
72
73 // Then add multi-frame specific validation
74 validate_multiframe_module(dataset, result.findings);
75
76 // Re-check validity after multi-frame validation
77 for (const auto& finding : result.findings) {
78 if (finding.severity == validation_severity::error) {
79 result.is_valid = false;
80 break;
81 }
82 }
83
84 return result;
85}
86
87bool xa_iod_validator::quick_check(const dicom_dataset& dataset) const {
88 // Check only Type 1 required attributes for quick validation
89
90 // General Study Module Type 1
91 if (!dataset.contains(tags::study_instance_uid)) return false;
92
93 // General Series Module Type 1
94 if (!dataset.contains(tags::modality)) return false;
95 if (!dataset.contains(tags::series_instance_uid)) return false;
96
97 // Check modality is XA or XRF
98 auto modality = dataset.get_string(tags::modality);
99 if (modality != "XA" && modality != "XRF") return false;
100
101 // Image Pixel Module Type 1
102 if (!dataset.contains(tags::samples_per_pixel)) return false;
103 if (!dataset.contains(tags::photometric_interpretation)) return false;
104 if (!dataset.contains(tags::rows)) return false;
105 if (!dataset.contains(tags::columns)) return false;
106 if (!dataset.contains(tags::bits_allocated)) return false;
107 if (!dataset.contains(tags::bits_stored)) return false;
108 if (!dataset.contains(tags::high_bit)) return false;
109 if (!dataset.contains(tags::pixel_representation)) return false;
110 if (!dataset.contains(tags::pixel_data)) return false;
111
112 // XA Image Module Type 1
113 if (!dataset.contains(tags::image_type)) return false;
114
115 // SOP Common Module Type 1
116 if (!dataset.contains(tags::sop_class_uid)) return false;
117 if (!dataset.contains(tags::sop_instance_uid)) return false;
118
119 return true;
120}
121
124 validation_result result;
125 result.is_valid = true;
126
127 validate_calibration_module(dataset, result.findings);
128
129 for (const auto& finding : result.findings) {
130 if (finding.severity == validation_severity::error) {
131 result.is_valid = false;
132 break;
133 }
134 }
135
136 return result;
137}
138
140 return options_;
141}
142
146
147// =============================================================================
148// Module Validation Methods
149// =============================================================================
150
152 const dicom_dataset& dataset,
153 std::vector<validation_finding>& findings) const {
154
155 // Patient Module - All attributes are Type 2
156 if (options_.check_type2) {
157 check_type2_attribute(dataset, tags::patient_name, "PatientName", findings);
158 check_type2_attribute(dataset, tags::patient_id, "PatientID", findings);
159 check_type2_attribute(dataset, tags::patient_birth_date, "PatientBirthDate", findings);
160 check_type2_attribute(dataset, tags::patient_sex, "PatientSex", findings);
161 }
162}
163
165 const dicom_dataset& dataset,
166 std::vector<validation_finding>& findings) const {
167
168 // Type 1
169 if (options_.check_type1) {
170 check_type1_attribute(dataset, tags::study_instance_uid, "StudyInstanceUID", findings);
171 }
172
173 // Type 2
174 if (options_.check_type2) {
175 check_type2_attribute(dataset, tags::study_date, "StudyDate", findings);
176 check_type2_attribute(dataset, tags::study_time, "StudyTime", findings);
177 check_type2_attribute(dataset, tags::referring_physician_name, "ReferringPhysicianName", findings);
178 check_type2_attribute(dataset, tags::study_id, "StudyID", findings);
179 check_type2_attribute(dataset, tags::accession_number, "AccessionNumber", findings);
180 }
181}
182
184 const dicom_dataset& dataset,
185 std::vector<validation_finding>& findings) const {
186
187 // Type 1
188 if (options_.check_type1) {
189 check_type1_attribute(dataset, tags::modality, "Modality", findings);
190 check_type1_attribute(dataset, tags::series_instance_uid, "SeriesInstanceUID", findings);
191
192 // Special check: Modality must be "XA" or "XRF"
193 check_modality(dataset, findings);
194 }
195
196 // Type 2
197 if (options_.check_type2) {
198 check_type2_attribute(dataset, tags::series_number, "SeriesNumber", findings);
199 }
200}
201
203 const dicom_dataset& dataset,
204 std::vector<validation_finding>& findings) const {
205
206 // XA/XRF Acquisition Module
207 // Most attributes are Type 3 (optional), but we can provide info findings
208
210 // Field of View info - Type 3 but useful for QA
212 findings.push_back({
215 "FieldOfViewShape (0018,1147) not present - FOV information unavailable",
216 "XA-INFO-001"
217 });
218 }
219
220 // Positioner motion for rotational acquisitions
221 if (!dataset.contains(xa_tags::positioner_motion)) {
222 findings.push_back({
225 "PositionerMotion (0018,1500) not present - motion type unknown",
226 "XA-INFO-002"
227 });
228 }
229 }
230}
231
233 const dicom_dataset& dataset,
234 std::vector<validation_finding>& findings) const {
235
236 // Type 1 - Image Type
237 if (options_.check_type1) {
238 check_type1_attribute(dataset, tags::image_type, "ImageType", findings);
239 }
240
241 // Check photometric interpretation is valid for XA
242 check_xa_photometric(dataset, findings);
243}
244
246 const dicom_dataset& dataset,
247 std::vector<validation_finding>& findings) const {
248
249 // Type 1 attributes
250 if (options_.check_type1) {
251 check_type1_attribute(dataset, tags::samples_per_pixel, "SamplesPerPixel", findings);
252 check_type1_attribute(dataset, tags::photometric_interpretation, "PhotometricInterpretation", findings);
253 check_type1_attribute(dataset, tags::rows, "Rows", findings);
254 check_type1_attribute(dataset, tags::columns, "Columns", findings);
255 check_type1_attribute(dataset, tags::bits_allocated, "BitsAllocated", findings);
256 check_type1_attribute(dataset, tags::bits_stored, "BitsStored", findings);
257 check_type1_attribute(dataset, tags::high_bit, "HighBit", findings);
258 check_type1_attribute(dataset, tags::pixel_representation, "PixelRepresentation", findings);
259 check_type1_attribute(dataset, tags::pixel_data, "PixelData", findings);
260 }
261
262 // Validate pixel data consistency
264 check_pixel_data_consistency(dataset, findings);
265 }
266}
267
269 const dicom_dataset& dataset,
270 std::vector<validation_finding>& findings) const {
271
272 // NumberOfFrames is Type 1 for multi-frame
273 if (options_.check_type1) {
274 check_type1_attribute(dataset, xa_tags::number_of_frames, "NumberOfFrames", findings);
275 }
276
277 // Frame timing validation
279 bool has_frame_time = dataset.contains(xa_tags::frame_time);
280 bool has_frame_time_vector = dataset.contains(xa_tags::frame_time_vector);
281
282 if (!has_frame_time && !has_frame_time_vector) {
283 findings.push_back({
286 "Neither FrameTime (0018,1063) nor FrameTimeVector (0018,1065) present - "
287 "frame timing information missing for cine playback",
288 "XA-WARN-001"
289 });
290 }
291
292 // CineRate is useful for proper playback
293 if (!dataset.contains(xa_tags::cine_rate) &&
295 findings.push_back({
298 "Neither CineRate nor RecommendedDisplayFrameRate present - "
299 "display timing may be incorrect",
300 "XA-INFO-003"
301 });
302 }
303 }
304}
305
307 const dicom_dataset& dataset,
308 std::vector<validation_finding>& findings) const {
309
310 // XA Calibration Module (Conditional)
311 // Required if quantitative measurements are to be performed
312
313 bool has_imager_spacing = dataset.contains(xa_tags::imager_pixel_spacing);
314 bool has_sid = dataset.contains(xa_tags::distance_source_to_detector);
315 bool has_sod = dataset.contains(xa_tags::distance_source_to_patient);
316
317 // Imager Pixel Spacing is essential for any calibration
318 if (!has_imager_spacing) {
319 findings.push_back({
322 "ImagerPixelSpacing (0018,1164) not present - "
323 "quantitative measurements not possible",
324 "XA-WARN-002"
325 });
326 }
327
328 // SID and SOD needed for accurate isocenter calibration
329 if (has_imager_spacing && (!has_sid || !has_sod)) {
330 findings.push_back({
333 "DistanceSourceToDetector or DistanceSourceToPatient missing - "
334 "magnification correction not possible for QCA",
335 "XA-WARN-003"
336 });
337 }
338
339 // Validate SID > SOD (physical constraint)
340 if (has_sid && has_sod) {
341 auto sid = dataset.get_numeric<double>(xa_tags::distance_source_to_detector);
342 auto sod = dataset.get_numeric<double>(xa_tags::distance_source_to_patient);
343
344 if (sid && sod && *sid <= *sod) {
345 findings.push_back({
348 "DistanceSourceToDetector (" + std::to_string(*sid) +
349 ") must be greater than DistanceSourceToPatient (" +
350 std::to_string(*sod) + ")",
351 "XA-ERR-001"
352 });
353 }
354 }
355}
356
358 const dicom_dataset& dataset,
359 std::vector<validation_finding>& findings) const {
360
361 // Type 1
362 if (options_.check_type1) {
363 check_type1_attribute(dataset, tags::sop_class_uid, "SOPClassUID", findings);
364 check_type1_attribute(dataset, tags::sop_instance_uid, "SOPInstanceUID", findings);
365 }
366
367 // Validate SOP Class UID is an XA/XRF storage class
368 if (dataset.contains(tags::sop_class_uid)) {
369 auto sop_class = dataset.get_string(tags::sop_class_uid);
370 if (!sop_classes::is_xa_storage_sop_class(sop_class)) {
371 findings.push_back({
374 "SOPClassUID is not a recognized XA/XRF Storage SOP Class: " + sop_class,
375 "XA-ERR-002"
376 });
377 }
378 }
379}
380
381// =============================================================================
382// Attribute Validation Helpers
383// =============================================================================
384
386 const dicom_dataset& dataset,
387 dicom_tag tag,
388 std::string_view name,
389 std::vector<validation_finding>& findings) const {
390
391 if (!dataset.contains(tag)) {
392 findings.push_back({
394 tag,
395 std::string("Type 1 attribute missing: ") + std::string(name) +
396 " (" + tag.to_string() + ")",
397 "XA-TYPE1-MISSING"
398 });
399 } else {
400 // Type 1 must have a value (cannot be empty)
401 auto value = dataset.get_string(tag);
402 if (value.empty()) {
403 findings.push_back({
405 tag,
406 std::string("Type 1 attribute has empty value: ") +
407 std::string(name) + " (" + tag.to_string() + ")",
408 "XA-TYPE1-EMPTY"
409 });
410 }
411 }
412}
413
415 const dicom_dataset& dataset,
416 dicom_tag tag,
417 std::string_view name,
418 std::vector<validation_finding>& findings) const {
419
420 // Type 2 must be present but can be empty
421 if (!dataset.contains(tag)) {
422 findings.push_back({
424 tag,
425 std::string("Type 2 attribute missing: ") + std::string(name) +
426 " (" + tag.to_string() + ")",
427 "XA-TYPE2-MISSING"
428 });
429 }
430}
431
433 const dicom_dataset& dataset,
434 std::vector<validation_finding>& findings) const {
435
436 if (!dataset.contains(tags::modality)) {
437 return; // Already reported as Type 1 missing
438 }
439
440 auto modality = dataset.get_string(tags::modality);
441 if (modality != "XA" && modality != "XRF") {
442 findings.push_back({
445 "Modality must be 'XA' or 'XRF' for angiographic images, found: " + modality,
446 "XA-ERR-003"
447 });
448 }
449}
450
452 const dicom_dataset& dataset,
453 std::vector<validation_finding>& findings) const {
454
456 return; // Will be reported by pixel module validation
457 }
458
459 auto photometric = dataset.get_string(tags::photometric_interpretation);
460 if (!sop_classes::is_valid_xa_photometric(photometric)) {
461 findings.push_back({
464 "Invalid photometric interpretation for XA: " + photometric +
465 " (must be MONOCHROME1 or MONOCHROME2)",
466 "XA-ERR-004"
467 });
468 }
469}
470
472 const dicom_dataset& dataset,
473 std::vector<validation_finding>& findings) const {
474
475 // Check BitsStored <= BitsAllocated
476 auto bits_allocated = dataset.get_numeric<uint16_t>(tags::bits_allocated);
477 auto bits_stored = dataset.get_numeric<uint16_t>(tags::bits_stored);
478 auto high_bit = dataset.get_numeric<uint16_t>(tags::high_bit);
479
480 if (bits_allocated && bits_stored) {
481 if (*bits_stored > *bits_allocated) {
482 findings.push_back({
485 "BitsStored (" + std::to_string(*bits_stored) +
486 ") exceeds BitsAllocated (" + std::to_string(*bits_allocated) + ")",
487 "XA-ERR-005"
488 });
489 }
490
491 // XA typically uses 8, 10, 12, or 16 bits
492 if (*bits_stored != 8 && *bits_stored != 10 &&
493 *bits_stored != 12 && *bits_stored != 16) {
494 findings.push_back({
497 "Unusual BitsStored for XA: " + std::to_string(*bits_stored) +
498 " (expected 8, 10, 12, or 16)",
499 "XA-WARN-004"
500 });
501 }
502 }
503
504 // Check HighBit == BitsStored - 1
505 if (bits_stored && high_bit) {
506 if (*high_bit != *bits_stored - 1) {
507 findings.push_back({
510 "HighBit (" + std::to_string(*high_bit) +
511 ") should typically be BitsStored - 1 (" +
512 std::to_string(*bits_stored - 1) + ")",
513 "XA-WARN-005"
514 });
515 }
516 }
517
518 // XA must be grayscale (SamplesPerPixel = 1)
519 auto samples = dataset.get_numeric<uint16_t>(tags::samples_per_pixel);
520 if (samples && *samples != 1) {
521 findings.push_back({
524 "XA images must be grayscale (SamplesPerPixel = 1), found: " +
525 std::to_string(*samples),
526 "XA-ERR-006"
527 });
528 }
529
530 // XA uses unsigned integers (PixelRepresentation = 0)
531 auto pixel_rep = dataset.get_numeric<uint16_t>(tags::pixel_representation);
532 if (pixel_rep && *pixel_rep != 0) {
533 findings.push_back({
536 "XA images typically use unsigned integers (PixelRepresentation = 0)",
537 "XA-WARN-006"
538 });
539 }
540}
541
543 const dicom_dataset& dataset,
544 std::vector<validation_finding>& findings) const {
545
546 bool has_primary = dataset.contains(xa_tags::positioner_primary_angle);
547 bool has_secondary = dataset.contains(xa_tags::positioner_secondary_angle);
548
549 // Both angles should be present for proper geometry reconstruction
550 if (has_primary != has_secondary) {
551 findings.push_back({
555 "Only one positioner angle present - complete geometry unavailable",
556 "XA-WARN-007"
557 });
558 }
559
560 if (!has_primary && !has_secondary) {
561 findings.push_back({
564 "No positioner angle information - geometry reconstruction not possible",
565 "XA-INFO-004"
566 });
567 }
568
569 // Validate angle ranges if present
570 if (has_primary) {
571 auto angle = dataset.get_numeric<double>(xa_tags::positioner_primary_angle);
572 if (angle && (*angle < -180.0 || *angle > 180.0)) {
573 findings.push_back({
576 "PositionerPrimaryAngle (" + std::to_string(*angle) +
577 ") outside typical range [-180, 180]",
578 "XA-WARN-008"
579 });
580 }
581 }
582
583 if (has_secondary) {
584 auto angle = dataset.get_numeric<double>(xa_tags::positioner_secondary_angle);
585 if (angle && (*angle < -90.0 || *angle > 90.0)) {
586 findings.push_back({
589 "PositionerSecondaryAngle (" + std::to_string(*angle) +
590 ") outside typical range [-90, 90]",
591 "XA-WARN-009"
592 });
593 }
594 }
595}
596
597// =============================================================================
598// Convenience Functions
599// =============================================================================
600
602 xa_iod_validator validator;
603 return validator.validate(dataset);
604}
605
606bool is_valid_xa_dataset(const dicom_dataset& dataset) {
607 xa_iod_validator validator;
608 return validator.quick_check(dataset);
609}
610
611bool has_qca_calibration(const dicom_dataset& dataset) {
612 // QCA requires ImagerPixelSpacing, SID, and SOD
613 if (!dataset.contains(xa_tags::imager_pixel_spacing)) return false;
614 if (!dataset.contains(xa_tags::distance_source_to_detector)) return false;
615 if (!dataset.contains(xa_tags::distance_source_to_patient)) return false;
616
617 // Validate SID > SOD
618 auto sid = dataset.get_numeric<double>(xa_tags::distance_source_to_detector);
619 auto sod = dataset.get_numeric<double>(xa_tags::distance_source_to_patient);
620
621 if (!sid || !sod || *sid <= *sod) return false;
622
623 return true;
624}
625
626} // namespace kcenon::pacs::services::validation
auto get_numeric(dicom_tag tag) const -> std::optional< T >
Get the numeric value of an element.
auto contains(dicom_tag tag) const noexcept -> bool
Check if the dataset contains an element with the given tag.
auto get_string(dicom_tag tag, std::string_view default_value="") const -> std::string
Get the string value of an element.
auto to_string() const -> std::string
Convert to string representation.
void validate_calibration_module(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
void validate_multiframe_module(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
void check_pixel_data_consistency(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
validation_result validate_calibration(const core::dicom_dataset &dataset) const
Validate calibration data for quantitative analysis.
void set_options(const xa_validation_options &options)
Set validation options.
validation_result validate_multiframe(const core::dicom_dataset &dataset) const
Validate a multi-frame XA dataset.
void validate_sop_common_module(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
const xa_validation_options & options() const noexcept
Get the validation options.
void check_type2_attribute(const core::dicom_dataset &dataset, core::dicom_tag tag, std::string_view name, std::vector< validation_finding > &findings) const
void check_modality(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
void check_positioner_angles(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
void validate_xa_acquisition_module(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
void check_xa_photometric(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
xa_iod_validator()=default
Construct validator with default options.
bool quick_check(const core::dicom_dataset &dataset) const
Quick check if dataset has minimum required XA attributes.
void validate_general_series_module(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
void validate_image_pixel_module(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
void validate_patient_module(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
validation_result validate(const core::dicom_dataset &dataset) const
Validate a DICOM dataset against XA IOD.
void check_type1_attribute(const core::dicom_dataset &dataset, core::dicom_tag tag, std::string_view name, std::vector< validation_finding > &findings) const
void validate_general_study_module(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
void validate_xa_image_module(const core::dicom_dataset &dataset, std::vector< validation_finding > &findings) const
Compile-time constants for commonly used DICOM tags.
constexpr dicom_tag high_bit
High Bit.
constexpr dicom_tag referring_physician_name
Referring Physician's Name.
constexpr dicom_tag patient_id
Patient ID.
constexpr dicom_tag bits_allocated
Bits Allocated.
constexpr dicom_tag rows
Rows.
constexpr dicom_tag columns
Columns.
constexpr dicom_tag sop_instance_uid
SOP Instance UID.
constexpr dicom_tag bits_stored
Bits Stored.
constexpr dicom_tag image_type
Image Type.
constexpr dicom_tag patient_birth_date
Patient's Birth Date.
constexpr dicom_tag pixel_data
Pixel Data.
constexpr dicom_tag pixel_representation
Pixel Representation.
constexpr dicom_tag accession_number
Accession Number.
constexpr dicom_tag modality
Modality.
constexpr dicom_tag study_time
Study Time.
constexpr dicom_tag patient_sex
Patient's Sex.
constexpr dicom_tag samples_per_pixel
Samples per Pixel.
constexpr dicom_tag study_instance_uid
Study Instance UID.
constexpr dicom_tag series_number
Series Number.
constexpr dicom_tag photometric_interpretation
Photometric Interpretation.
constexpr dicom_tag sop_class_uid
SOP Class UID.
constexpr dicom_tag study_id
Study ID.
constexpr dicom_tag patient_name
Patient's Name.
constexpr dicom_tag study_date
Study Date.
constexpr dicom_tag series_instance_uid
Series Instance UID.
bool is_valid_xa_photometric(std::string_view value) noexcept
Check if photometric interpretation is valid for XA.
bool is_xa_storage_sop_class(std::string_view uid) noexcept
Check if a SOP Class UID is an XA/XRF Storage SOP Class.
constexpr core::dicom_tag distance_source_to_patient
Distance Source to Patient (0018,1111) - SOD.
constexpr core::dicom_tag number_of_frames
Number of Frames (0028,0008) - for multi-frame.
constexpr core::dicom_tag positioner_secondary_angle
Positioner Secondary Angle (0018,1511) - Cranial/Caudal.
constexpr core::dicom_tag recommended_display_frame_rate
Recommended Display Frame Rate (0008,2144)
constexpr core::dicom_tag imager_pixel_spacing
Imager Pixel Spacing (0018,1164) - at detector plane.
constexpr core::dicom_tag positioner_primary_angle
Positioner Primary Angle (0018,1510) - LAO/RAO.
constexpr core::dicom_tag field_of_view_shape
Field of View Shape (0018,1147)
constexpr core::dicom_tag positioner_motion
Positioner Motion (0018,1500)
constexpr core::dicom_tag distance_source_to_detector
Distance Source to Detector (0018,1110) - SID.
constexpr core::dicom_tag frame_time
Frame Time (0018,1063) - time between frames in ms.
constexpr core::dicom_tag frame_time_vector
Frame Time Vector (0018,1065) - variable frame timing.
constexpr core::dicom_tag cine_rate
Cine Rate (0018,0040) - intended display rate.
bool is_valid_xa_dataset(const core::dicom_dataset &dataset)
Quick check if a dataset is a valid XA image.
@ warning
Non-critical - IOD may have issues.
@ info
Informational - suggestion for improvement.
bool has_qca_calibration(const core::dicom_dataset &dataset)
Check if dataset has valid QCA calibration data.
validation_result validate_xa_iod(const core::dicom_dataset &dataset)
Validate an XA dataset with default options.
std::vector< validation_finding > findings
All findings during validation.
bool strict_mode
Strict mode - treat warnings as errors.
bool validate_calibration
Validate calibration data for QCA.
bool check_conditional
Check Type 1C/2C (conditionally required) attributes.
bool check_type1
Check Type 1 (required) attributes.
bool validate_pixel_data
Validate pixel data consistency (rows, columns, bits)
bool check_type2
Check Type 2 (required, can be empty) attributes.
bool validate_multiframe_timing
Validate multi-frame timing information.
std::string_view name
X-Ray Angiographic Image IOD Validator.
X-Ray Angiographic (XA) Image Storage SOP Classes.