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)
+
+
+
+
+ | Tag |
+ Unique values |
+ Sample values |
+
+
+
+ {% for row in different_tags %}
+
+ | {{ row.tag_name }} |
+ {{ row.unique_count }} |
+ {{ row.sample_values|join:", " }} |
+
+ {% empty %}
+
+ | No tags vary across all images. |
+
+ {% endfor %}
+
+
+
+
+
+
+ Same tags
+
+
+
+
+ | Tag |
+ Value |
+
+
+
+ {% for row in same_tags %}
+
+ | {{ row.tag_name }} |
+ {{ row.sample_values.0 }} |
+
+ {% empty %}
+
+ | No uniform tags found across all images. |
+
+ {% endfor %}
+
+
+
+
+
+
+ Missing in some images
+
+
+
+
+ | Tag |
+ Present in |
+ Unique values |
+
+
+
+ {% for row in partial_tags %}
+
+ | {{ row.tag_name }} |
+ {{ row.present_count }}/{{ image_count }} |
+ {{ row.unique_count }} |
+
+ {% empty %}
+
+ | All scanned tags were present in every image. |
+
+ {% endfor %}
+
+
+
+
+ {% 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):