feat: Implement series splitting by DICOM tag with HTMX support
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user