feat: Enhance series splitting functionality with DICOM tag analysis and improved user feedback
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -1,17 +1,41 @@
|
||||
{% 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 id="series-split-by-tag-panel" class="d-flex flex-column gap-2">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button"
|
||||
class="btn btn-outline-warning btn-sm"
|
||||
hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}?analyze=1"
|
||||
hx-target="#series-split-by-tag-panel"
|
||||
hx-swap="outerHTML">
|
||||
Analyze split options
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="split-by-tag-result" class="mt-2"></div>
|
||||
|
||||
{% if analyze_requested %}
|
||||
{% if split_options %}
|
||||
<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 option in split_options %}
|
||||
<option value="{{ option.tag_value }}">{{ option.tag_label }} ({{ option.split_count }} series)</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="small text-muted mb-0">Only tags that can produce more than one output series are shown.</p>
|
||||
{% else %}
|
||||
<div class="alert alert-secondary mb-0 small">
|
||||
No common split tags were found that can divide this series into multiple groups.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="small text-muted mb-0">Run analysis to detect valid split tags and estimated output series count.</p>
|
||||
{% endif %}
|
||||
|
||||
<div id="split-by-tag-result"></div>
|
||||
</div>
|
||||
|
||||
+54
-11
@@ -10,6 +10,7 @@ from django.db import transaction
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
from django.utils.html import escape
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.template.loader import render_to_string
|
||||
@@ -3637,6 +3638,43 @@ SERIES_SPLIT_TAGS = [
|
||||
]
|
||||
|
||||
|
||||
def _normalise_dicom_split_value(value):
|
||||
if isinstance(value, pydicom.multival.MultiValue):
|
||||
return ",".join(map(str, value))
|
||||
return str(value)
|
||||
|
||||
|
||||
def _analyse_series_split_options(series):
|
||||
tags = [tag_value for tag_value, _ in SERIES_SPLIT_TAGS]
|
||||
tag_value_sets = {tag: set() for tag in tags}
|
||||
tag_missing = {tag: False for tag in tags}
|
||||
|
||||
for image in series.images.iterator():
|
||||
ds = image.get_dicom_data()
|
||||
for tag in tags:
|
||||
if tag in ds:
|
||||
tag_value_sets[tag].add(_normalise_dicom_split_value(ds[tag].value))
|
||||
else:
|
||||
tag_missing[tag] = True
|
||||
|
||||
split_options = []
|
||||
for tag_value, tag_label in SERIES_SPLIT_TAGS:
|
||||
if tag_missing[tag_value]:
|
||||
continue
|
||||
split_count = len(tag_value_sets[tag_value])
|
||||
if split_count > 1:
|
||||
split_options.append(
|
||||
{
|
||||
"tag_value": tag_value,
|
||||
"tag_label": tag_label,
|
||||
"split_count": split_count,
|
||||
}
|
||||
)
|
||||
|
||||
split_options.sort(key=lambda item: item["split_count"], reverse=True)
|
||||
return split_options
|
||||
|
||||
|
||||
@login_required
|
||||
@user_is_author_or_atlas_series_checker
|
||||
def series_split_by_tag(request, pk):
|
||||
@@ -3645,20 +3683,27 @@ def series_split_by_tag(request, pk):
|
||||
Scope: Atlas series authors and series checkers only (via decorator).
|
||||
|
||||
Functionality:
|
||||
- GET: renders a partial form with a dropdown of common DICOM tags.
|
||||
- GET: renders a partial form and, on demand, analyzes available DICOM tags
|
||||
to list only tags that can actually split the series.
|
||||
- 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 success, returns an HTMX success fragment with number of series created.
|
||||
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":
|
||||
analyze_requested = request.GET.get("analyze") == "1"
|
||||
split_options = _analyse_series_split_options(series) if analyze_requested else []
|
||||
return render(
|
||||
request,
|
||||
"atlas/partials/_series_split_by_tag.html",
|
||||
{"series": series, "split_tags": SERIES_SPLIT_TAGS},
|
||||
{
|
||||
"series": series,
|
||||
"split_options": split_options,
|
||||
"analyze_requested": analyze_requested,
|
||||
},
|
||||
)
|
||||
|
||||
dicom_tag = request.POST.get("dicom_tag", "").strip()
|
||||
@@ -3679,18 +3724,14 @@ def series_split_by_tag(request, pk):
|
||||
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>'
|
||||
f'<div class="alert alert-danger">Tag <code>{escape(str(dicom_tag))}</code> not found in image {escape(str(image))}.</div>'
|
||||
)
|
||||
val = ds[dicom_tag].value
|
||||
if isinstance(val, pydicom.multival.MultiValue):
|
||||
key = ",".join(map(str, val))
|
||||
else:
|
||||
key = str(val)
|
||||
key = _normalise_dicom_split_value(ds[dicom_tag].value)
|
||||
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>'
|
||||
f'<div class="alert alert-warning">Tag <code>{escape(str(dicom_tag))}</code> has only one unique value, so no split was performed.</div>'
|
||||
)
|
||||
|
||||
case = list(series.case.all())
|
||||
@@ -3715,7 +3756,9 @@ def series_split_by_tag(request, pk):
|
||||
dicom_tag,
|
||||
new_series_pks,
|
||||
)
|
||||
return redirect("atlas:series_detail", pk=new_series_pks[0])
|
||||
return HttpResponse(
|
||||
f'<div class="alert alert-success">Split complete: <strong>{len(new_series_pks)}</strong> series created from tag <code>{escape(str(dicom_tag))}</code>.</div>'
|
||||
)
|
||||
|
||||
|
||||
# Vendored in from BaseZipView
|
||||
|
||||
Reference in New Issue
Block a user