diff --git a/atlas/templates/atlas/partials/_series_split_by_tag.html b/atlas/templates/atlas/partials/_series_split_by_tag.html
new file mode 100644
index 00000000..19b3f719
--- /dev/null
+++ b/atlas/templates/atlas/partials/_series_split_by_tag.html
@@ -0,0 +1,17 @@
+{% load django_htmx %}
+
+
diff --git a/atlas/templates/atlas/series_viewer.html b/atlas/templates/atlas/series_viewer.html
index 39d44c42..902f6c36 100755
--- a/atlas/templates/atlas/series_viewer.html
+++ b/atlas/templates/atlas/series_viewer.html
@@ -190,7 +190,11 @@
Order by instance number
Order by SeriesInstanceUID
Order by uploaded filename
- Split by ImageType tag
+
+ Loading split controls…
+
diff --git a/atlas/urls.py b/atlas/urls.py
index 1e6e209a..5670fe37 100755
--- a/atlas/urls.py
+++ b/atlas/urls.py
@@ -614,6 +614,11 @@ urlpatterns = [
views.series_order_upload_filename,
name="series_order_upload_filename",
),
+ path(
+ "series//split_by_tag",
+ views.series_split_by_tag,
+ name="series_split_by_tag",
+ ),
path(
"series//delete",
views.SeriesDelete.as_view(),
diff --git a/atlas/views.py b/atlas/views.py
index e9e3d173..66c4d305 100755
--- a/atlas/views.py
+++ b/atlas/views.py
@@ -3622,6 +3622,101 @@ def series_order_upload_filename(request, pk):
return redirect("atlas:series_detail", pk=pk)
+# Common DICOM tags available for series splitting
+SERIES_SPLIT_TAGS = [
+ ("ImageType", "Image Type"),
+ ("EchoNumbers", "Echo Numbers (multi-echo MRI)"),
+ ("EchoTime", "Echo Time"),
+ ("DiffusionBValue", "Diffusion B-Value (DWI)"),
+ ("TemporalPositionIdentifier", "Temporal Position (dynamic series)"),
+ ("AcquisitionNumber", "Acquisition Number"),
+ ("FlipAngle", "Flip Angle"),
+ ("ContrastBolusAgent", "Contrast/Bolus Agent"),
+ ("CardiacNumberOfImages", "Cardiac Number of Images"),
+]
+
+
+@login_required
+@user_is_author_or_atlas_series_checker
+def series_split_by_tag(request, pk):
+ """Split a series into multiple series based on unique values of a chosen DICOM tag.
+
+ Scope: Atlas series authors and series checkers only (via decorator).
+
+ Functionality:
+ - GET: renders a partial form with a dropdown of common DICOM tags.
+ - POST: performs the split. The original series retains the images matching
+ the first unique tag value; new series are cloned for each subsequent value.
+ On success, redirects to the series detail page.
+ On error (tag not found or non-DICOM images), returns an HTMX-swappable
+ error message fragment.
+ """
+ series = get_object_or_404(Series, pk=pk)
+
+ if request.method == "GET":
+ return render(
+ request,
+ "atlas/partials/_series_split_by_tag.html",
+ {"series": series, "split_tags": SERIES_SPLIT_TAGS},
+ )
+
+ dicom_tag = request.POST.get("dicom_tag", "").strip()
+ if not dicom_tag:
+ return HttpResponse(
+ 'No DICOM tag specified.
'
+ )
+
+ # Support hex tuple notation e.g. "(0008,0008)"
+ if dicom_tag.startswith("(") and dicom_tag.endswith(")"):
+ dicom_tag = tuple(
+ [hex(int(i.strip(), 16)) for i in dicom_tag[1:-1].split(",")]
+ )
+
+ image_map = defaultdict(list)
+
+ for image in series.images.iterator():
+ ds = image.get_dicom_data()
+ if dicom_tag not in ds:
+ return HttpResponse(
+ f'Tag {dicom_tag} not found in image {image}.
'
+ )
+ val = ds[dicom_tag].value
+ if isinstance(val, pydicom.multival.MultiValue):
+ key = ",".join(map(str, val))
+ else:
+ key = str(val)
+ image_map[key].append(image)
+
+ if len(image_map) < 2:
+ return HttpResponse(
+ f'Tag {dicom_tag} has only one unique value — no split performed.
'
+ )
+
+ case = list(series.case.all())
+ authors = list(series.author.all())
+ new_series_pks = []
+
+ with transaction.atomic():
+ for n, (option, images) in enumerate(image_map.items()):
+ if n == 0:
+ series.images.set(images)
+ new_series_pks.append(series.pk)
+ else:
+ series.pk = None
+ series.save()
+ series.case.set(case)
+ series.author.set(authors)
+ series.images.set(images)
+ new_series_pks.append(series.pk)
+
+ logger.info(
+ "Series split by tag '{}': produced series PKs {}",
+ dicom_tag,
+ new_series_pks,
+ )
+ return redirect("atlas:series_detail", pk=new_series_pks[0])
+
+
# Vendored in from BaseZipView
class SeriesImagesZipView(SeriesImagesZipViewBase, SuperuserRequiredMixin):
series_object = Series