From ab7dcaed76c021c83981f80db1cc25e24023cd8e Mon Sep 17 00:00:00 2001 From: Ross Date: Thu, 30 Apr 2026 21:46:36 +0100 Subject: [PATCH] feat: Implement series splitting by DICOM tag with HTMX support Co-authored-by: Copilot --- .../atlas/partials/_series_split_by_tag.html | 17 ++++ atlas/templates/atlas/series_viewer.html | 6 +- atlas/urls.py | 5 + atlas/views.py | 95 +++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 atlas/templates/atlas/partials/_series_split_by_tag.html 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 %} +
+ {% csrf_token %} +
+ + +
+
+
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