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_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_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-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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -614,6 +614,11 @@ urlpatterns = [
|
|||||||
views.series_order_upload_filename,
|
views.series_order_upload_filename,
|
||||||
name="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(
|
path(
|
||||||
"series/<int:pk>/delete",
|
"series/<int:pk>/delete",
|
||||||
views.SeriesDelete.as_view(),
|
views.SeriesDelete.as_view(),
|
||||||
|
|||||||
@@ -3622,6 +3622,101 @@ def series_order_upload_filename(request, pk):
|
|||||||
return redirect("atlas:series_detail", pk=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
|
# Vendored in from BaseZipView
|
||||||
class SeriesImagesZipView(SeriesImagesZipViewBase, SuperuserRequiredMixin):
|
class SeriesImagesZipView(SeriesImagesZipViewBase, SuperuserRequiredMixin):
|
||||||
series_object = Series
|
series_object = Series
|
||||||
|
|||||||
Reference in New Issue
Block a user