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,4 +1,17 @@
|
|||||||
{% load django_htmx %}
|
{% load django_htmx %}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{% if analyze_requested %}
|
||||||
|
{% if split_options %}
|
||||||
<form method="post"
|
<form method="post"
|
||||||
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
|
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
|
||||||
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
|
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
|
||||||
@@ -7,11 +20,22 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<select name="dicom_tag" class="form-select form-select-sm">
|
<select name="dicom_tag" class="form-select form-select-sm">
|
||||||
{% for tag_value, tag_label in split_tags %}
|
{% for option in split_options %}
|
||||||
<option value="{{ tag_value }}">{{ tag_label }}</option>
|
<option value="{{ option.tag_value }}">{{ option.tag_label }} ({{ option.split_count }} series)</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
|
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div id="split-by-tag-result" class="mt-2"></div>
|
<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.shortcuts import render, get_object_or_404, redirect
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils import timezone
|
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_POST
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
from django.template.loader import render_to_string
|
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
|
@login_required
|
||||||
@user_is_author_or_atlas_series_checker
|
@user_is_author_or_atlas_series_checker
|
||||||
def series_split_by_tag(request, pk):
|
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).
|
Scope: Atlas series authors and series checkers only (via decorator).
|
||||||
|
|
||||||
Functionality:
|
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
|
- POST: performs the split. The original series retains the images matching
|
||||||
the first unique tag value; new series are cloned for each subsequent value.
|
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
|
On error (tag not found or non-DICOM images), returns an HTMX-swappable
|
||||||
error message fragment.
|
error message fragment.
|
||||||
"""
|
"""
|
||||||
series = get_object_or_404(Series, pk=pk)
|
series = get_object_or_404(Series, pk=pk)
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
|
analyze_requested = request.GET.get("analyze") == "1"
|
||||||
|
split_options = _analyse_series_split_options(series) if analyze_requested else []
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/partials/_series_split_by_tag.html",
|
"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()
|
dicom_tag = request.POST.get("dicom_tag", "").strip()
|
||||||
@@ -3679,18 +3724,14 @@ def series_split_by_tag(request, pk):
|
|||||||
ds = image.get_dicom_data()
|
ds = image.get_dicom_data()
|
||||||
if dicom_tag not in ds:
|
if dicom_tag not in ds:
|
||||||
return HttpResponse(
|
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
|
key = _normalise_dicom_split_value(ds[dicom_tag].value)
|
||||||
if isinstance(val, pydicom.multival.MultiValue):
|
|
||||||
key = ",".join(map(str, val))
|
|
||||||
else:
|
|
||||||
key = str(val)
|
|
||||||
image_map[key].append(image)
|
image_map[key].append(image)
|
||||||
|
|
||||||
if len(image_map) < 2:
|
if len(image_map) < 2:
|
||||||
return HttpResponse(
|
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())
|
case = list(series.case.all())
|
||||||
@@ -3715,7 +3756,9 @@ def series_split_by_tag(request, pk):
|
|||||||
dicom_tag,
|
dicom_tag,
|
||||||
new_series_pks,
|
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
|
# Vendored in from BaseZipView
|
||||||
|
|||||||
Reference in New Issue
Block a user