feat: Implement series splitting by DICOM tag with HTMX support

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Ross
2026-04-30 21:46:36 +01:00
parent 2aff9516ac
commit ab7dcaed76
4 changed files with 122 additions and 1 deletions
@@ -0,0 +1,17 @@
{% load django_htmx %}
<form method="post"
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#split-by-tag-result"
hx-swap="innerHTML">
{% csrf_token %}
<div class="input-group input-group-sm">
<select name="dicom_tag" class="form-select form-select-sm">
{% for tag_value, tag_label in split_tags %}
<option value="{{ tag_value }}">{{ tag_label }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</div>
</form>
<div id="split-by-tag-result" class="mt-2"></div>
+5 -1
View File
@@ -190,7 +190,11 @@
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">Order by instance number</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">Order by SeriesInstanceUID</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">Order by uploaded filename</a>
<a class="btn btn-outline-warning btn-sm" href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}">Split by ImageType tag</a>
<div hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-trigger="load"
hx-swap="outerHTML">
<span class="text-muted small">Loading split controls…</span>
</div>
</div>
</div>
+5
View File
@@ -614,6 +614,11 @@ urlpatterns = [
views.series_order_upload_filename,
name="series_order_upload_filename",
),
path(
"series/<int:pk>/split_by_tag",
views.series_split_by_tag,
name="series_split_by_tag",
),
path(
"series/<int:pk>/delete",
views.SeriesDelete.as_view(),
+95
View File
@@ -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(
'<div class="alert alert-danger">No DICOM tag specified.</div>'
)
# 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'<div class="alert alert-danger">Tag <code>{dicom_tag}</code> not found in image {image}.</div>'
)
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'<div class="alert alert-warning">Tag <code>{dicom_tag}</code> has only one unique value — no split performed.</div>'
)
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