diff --git a/atlas/templates/atlas/partials/_series_tag_consistency.html b/atlas/templates/atlas/partials/_series_tag_consistency.html new file mode 100644 index 00000000..bbd8be34 --- /dev/null +++ b/atlas/templates/atlas/partials/_series_tag_consistency.html @@ -0,0 +1,107 @@ +{% load django_htmx %} +
+
+ + {% if analyze_requested %} + Scanned {{ image_count }} image{{ image_count|pluralize }} + {% endif %} +
+ + {% if analyze_requested %} +
+ Same: {{ same_tags|length }} + Different: {{ different_tags|length }} + Missing in some: {{ partial_tags|length }} +
+ +
+ Different tags (highlighted) +
+ + + + + + + + + + {% for row in different_tags %} + + + + + + {% empty %} + + + + {% endfor %} + +
TagUnique valuesSample values
{{ row.tag_name }}{{ row.unique_count }}{{ row.sample_values|join:", " }}
No tags vary across all images.
+
+
+ +
+ Same tags +
+ + + + + + + + + {% for row in same_tags %} + + + + + {% empty %} + + + + {% endfor %} + +
TagValue
{{ row.tag_name }}{{ row.sample_values.0 }}
No uniform tags found across all images.
+
+
+ +
+ Missing in some images +
+ + + + + + + + + + {% for row in partial_tags %} + + + + + + {% empty %} + + + + {% endfor %} + +
TagPresent inUnique values
{{ row.tag_name }}{{ row.present_count }}/{{ image_count }}{{ row.unique_count }}
All scanned tags were present in every image.
+
+
+ {% else %} +

Run analysis to quickly highlight tags that are the same or different across the series.

+ {% endif %} +
diff --git a/atlas/templates/atlas/series_viewer.html b/atlas/templates/atlas/series_viewer.html index 902f6c36..e58947c4 100755 --- a/atlas/templates/atlas/series_viewer.html +++ b/atlas/templates/atlas/series_viewer.html @@ -195,6 +195,11 @@ hx-swap="outerHTML"> Loading split controls… +
+ Loading tag analysis controls... +
diff --git a/atlas/urls.py b/atlas/urls.py index 5670fe37..a2d8a73e 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -619,6 +619,11 @@ urlpatterns = [ views.series_split_by_tag, name="series_split_by_tag", ), + path( + "series//tag_consistency", + views.series_tag_consistency, + name="series_tag_consistency", + ), path( "series//delete", views.SeriesDelete.as_view(), diff --git a/atlas/views.py b/atlas/views.py index 4e43c27e..6fb0a477 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -3644,6 +3644,19 @@ def _normalise_dicom_split_value(value): return str(value) +def _summarise_dicom_value(value): + if isinstance(value, pydicom.multival.MultiValue): + value_text = ",".join(map(str, value)) + elif isinstance(value, (bytes, bytearray)): + value_text = f"" + else: + value_text = str(value) + + if len(value_text) > 120: + return f"{value_text[:117]}..." + return value_text + + def _analyse_series_split_options(series): tags = [tag_value for tag_value, _ in SERIES_SPLIT_TAGS] tag_value_sets = {tag: set() for tag in tags} @@ -3675,6 +3688,81 @@ def _analyse_series_split_options(series): return split_options +@login_required +@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access +def series_tag_consistency(request, pk): + """Analyze DICOM tags across all images in a series. + + Scope: Any user with view access to the series. + + Functionality: + - GET: returns a panel with an optional on-demand analysis. + - GET ?analyze=1: scans all tags across images and groups them by: + 1) same in all images, 2) different across images, 3) missing in some images. + """ + series = get_object_or_404(Series, pk=pk) + analyze_requested = request.GET.get("analyze") == "1" + + same_tags = [] + different_tags = [] + partial_tags = [] + image_count = 0 + + if analyze_requested: + tag_values = defaultdict(set) + tag_presence = defaultdict(int) + + for image in series.images.iterator(): + image_count += 1 + ds = image.get_dicom_data() + seen_in_image = set() + + for element in ds.iterall(): + # PixelData is large and not useful for consistency checks. + if element.tag == (0x7FE0, 0x0010): + continue + + tag_name = element.keyword or str(element.tag) + tag_values[tag_name].add(_summarise_dicom_value(element.value)) + + if tag_name not in seen_in_image: + tag_presence[tag_name] += 1 + seen_in_image.add(tag_name) + + for tag_name in sorted(tag_values.keys()): + present_count = tag_presence[tag_name] + unique_values = tag_values[tag_name] + unique_count = len(unique_values) + sample_values = sorted(unique_values)[:3] + + row = { + "tag_name": tag_name, + "present_count": present_count, + "unique_count": unique_count, + "sample_values": sample_values, + } + + if present_count == image_count and unique_count == 1: + same_tags.append(row) + elif present_count == image_count: + different_tags.append(row) + else: + partial_tags.append(row) + + return render( + request, + "atlas/partials/_series_tag_consistency.html", + { + "series": series, + "analyze_requested": analyze_requested, + "image_count": image_count, + "same_tags": same_tags, + "different_tags": different_tags, + "partial_tags": partial_tags, + }, + ) + + @login_required @user_is_author_or_atlas_series_checker def series_split_by_tag(request, pk):