feat(reconstruction): Add asynchronous series reconstruction task with progress tracking

This commit is contained in:
Ross
2026-05-17 11:32:48 +01:00
parent c8d05818a4
commit 7ed0a8f378
4 changed files with 421 additions and 198 deletions
+248 -2
View File
@@ -3,11 +3,15 @@ from django.core.mail import send_mail
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from celery import shared_task
from atlas.models import Case
from atlas.models import Case, Series, SeriesImage
from generic.models import CimarCase
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
from helpers.cimar import CimarAPI, NotFoundError
from pydicom.uid import generate_uid
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
import copy
import io
@shared_task()
def push_case_to_cimar_task(case_id):
@@ -49,4 +53,246 @@ def push_case_to_cimar_task(case_id):
cimar_case.refresh_study()
return 10
return 10
@shared_task(bind=True)
def series_reconstruct_task(
self,
series_id,
user_id,
recon_planes,
slice_thickness_val=None,
slice_spacing_val=None,
recon_thickness_mode="mean",
):
"""Generate reconstructions asynchronously for a series with progress updates."""
import numpy as np
from loguru import logger
from atlas import views as atlas_views
series = get_object_or_404(Series, pk=series_id)
user = get_object_or_404(User, pk=user_id)
if not series.check_user_can_edit(user):
raise PermissionError("Permission denied")
images = list(series.get_images())
dicom_items = []
for image in images:
ds = atlas_views._read_series_image_dataset(image)
if ds is None:
continue
try:
arr = ds.pixel_array
if arr.ndim != 2:
continue
dicom_items.append((image, ds, arr))
except Exception:
continue
if len(dicom_items) < 2:
raise ValueError("Need at least 2 valid DICOM images in series for reconstruction")
base_shape = dicom_items[0][2].shape
dicom_items = [item for item in dicom_items if item[2].shape == base_shape]
if len(dicom_items) < 2:
raise ValueError("Not enough consistently-sized slices for reconstruction")
geom = atlas_views._extract_recon_geometry(dicom_items)
sorted_items = geom["sorted_items"]
volume = np.stack([item[2] for item in sorted_items], axis=0)
template_ds = sorted_items[0][1]
source_positions_mm = geom["source_positions_mm"]
origin_ipp = geom["origin_ipp"]
row_dir = geom["row_dir"]
col_dir = geom["col_dir"]
normal_dir = geom["normal_dir"]
native_row_spacing = geom["row_spacing"]
native_col_spacing = geom["col_spacing"]
native_z_spacing = geom["native_z_spacing"]
target_spacing = float(slice_spacing_val) if slice_spacing_val is not None else native_z_spacing
slab_thickness = float(slice_thickness_val) if slice_thickness_val is not None else target_spacing
volume_for_recon, recon_centers_mm = atlas_views._aggregate_volume_along_z(
volume,
source_positions_mm,
float(target_spacing),
float(slab_thickness),
recon_thickness_mode,
)
if volume_for_recon.shape[0] < 1:
raise ValueError("No reconstruction slices were generated")
base_z_offset = float(recon_centers_mm[0]) if len(recon_centers_mm) > 0 else 0.0
def build_position(origin, row_vector, col_vector, stack_vector, row_index=0, col_index=0, stack_offset=0.0):
pos = (
np.asarray(origin, dtype=float)
+ (np.asarray(stack_vector, dtype=float) * float(stack_offset))
+ (np.asarray(row_vector, dtype=float) * (float(row_index) * float(native_row_spacing)))
+ (np.asarray(col_vector, dtype=float) * (float(col_index) * float(native_col_spacing)))
)
return [float(pos[0]), float(pos[1]), float(pos[2])]
plane_slices = {}
for plane in recon_planes:
plane_norm = plane.lower()
if plane_norm == "axial":
recon_slices = [volume_for_recon[i, :, :] for i in range(volume_for_recon.shape[0])]
pixel_spacing_out = [native_row_spacing, native_col_spacing]
spacing_between_slices_out = float(target_spacing)
image_orientation_out = [
float(row_dir[0]),
float(row_dir[1]),
float(row_dir[2]),
float(col_dir[0]),
float(col_dir[1]),
float(col_dir[2]),
]
def position_for_index(idx):
return build_position(
origin_ipp,
row_dir,
col_dir,
normal_dir,
row_index=0,
col_index=0,
stack_offset=float(recon_centers_mm[idx]),
)
elif plane_norm == "coronal":
recon_slices = [volume_for_recon[:, i, :] for i in range(volume_for_recon.shape[1])]
pixel_spacing_out = [target_spacing, native_col_spacing]
spacing_between_slices_out = float(native_row_spacing)
image_orientation_out = [
float(normal_dir[0]),
float(normal_dir[1]),
float(normal_dir[2]),
float(col_dir[0]),
float(col_dir[1]),
float(col_dir[2]),
]
def position_for_index(idx):
return build_position(
origin_ipp,
row_dir,
col_dir,
normal_dir,
row_index=idx,
col_index=0,
stack_offset=base_z_offset,
)
elif plane_norm == "sagittal":
recon_slices = [volume_for_recon[:, :, i] for i in range(volume_for_recon.shape[2])]
pixel_spacing_out = [target_spacing, native_row_spacing]
spacing_between_slices_out = float(native_col_spacing)
image_orientation_out = [
float(normal_dir[0]),
float(normal_dir[1]),
float(normal_dir[2]),
float(row_dir[0]),
float(row_dir[1]),
float(row_dir[2]),
]
def position_for_index(idx):
return build_position(
origin_ipp,
row_dir,
col_dir,
normal_dir,
row_index=0,
col_index=idx,
stack_offset=base_z_offset,
)
else:
continue
plane_slices[plane_norm] = {
"recon_slices": recon_slices,
"pixel_spacing_out": pixel_spacing_out,
"spacing_between_slices_out": spacing_between_slices_out,
"image_orientation_out": image_orientation_out,
"position_for_index": position_for_index,
}
if not plane_slices:
raise ValueError("No valid reconstruction planes selected")
total_slices = sum(len(v["recon_slices"]) for v in plane_slices.values())
processed = 0
created_series = []
for plane_norm, cfg in plane_slices.items():
recon_series = atlas_views._create_series_derivative(series, f"Recon {plane_norm.title()}")
recon_series.series_instance_uid = generate_uid()
recon_series.save(update_fields=["series_instance_uid"])
for idx, arr2d in enumerate(cfg["recon_slices"]):
ds_new = copy.deepcopy(template_ds)
arr2d = np.asarray(arr2d, dtype=dicom_items[0][2].dtype)
ds_new.Rows = int(arr2d.shape[0])
ds_new.Columns = int(arr2d.shape[1])
ds_new.InstanceNumber = idx + 1
ds_new.SOPInstanceUID = generate_uid()
ds_new.SeriesInstanceUID = recon_series.series_instance_uid
ds_new.PixelData = arr2d.tobytes()
ds_new.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
ds_new.ImagePositionPatient = cfg["position_for_index"](idx)
ds_new.SliceThickness = float(slab_thickness)
ds_new.SpacingBetweenSlices = float(cfg["spacing_between_slices_out"])
out_io = io.BytesIO()
ds_new.save_as(out_io, write_like_original=False)
out_io.seek(0)
recon_image = SeriesImage(
series=recon_series,
position=idx + 1,
upload_filename=f"recon_{plane_norm}_{idx + 1}.dcm",
)
recon_image.image.save(
f"recon_{plane_norm}_{recon_series.pk}_{idx + 1}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
recon_image.save()
processed += 1
self.update_state(
state="PROGRESS",
meta={
"current": processed,
"total": total_slices,
"message": f"Generating {plane_norm} reconstruction ({processed}/{total_slices})",
},
)
created_series.append(
{
"id": recon_series.pk,
"url": recon_series.get_absolute_url(),
"description": recon_series.description or str(recon_series.pk),
}
)
logger.info(
"Reconstruction task complete for series {} with {} outputs",
series.pk,
len(created_series),
)
return {
"series_id": series.pk,
"created_series": created_series,
"target_spacing": float(target_spacing),
"slab_thickness": float(slab_thickness),
"mode": recon_thickness_mode,
}
+61 -1
View File
@@ -294,10 +294,19 @@
<div class="d-flex flex-wrap gap-2 mb-3">
<button id="truncate-test-modal-btn" class="btn btn-sm btn-outline-primary" type="button">Test preview</button>
<button class="btn btn-sm btn-danger" type="submit" name="operation" value="truncate">Apply truncate</button>
<button id="truncate-apply-btn" class="btn btn-sm btn-danger" type="submit" name="operation" value="truncate">Apply truncate</button>
<button class="btn btn-sm btn-outline-warning" type="submit" name="operation" value="remove_empty">Remove empty DICOMs</button>
</div>
<div id="truncate-progress" class="d-none mb-3">
<div class="small text-warning mb-1">Applying truncate. Please wait...</div>
<div class="progress" style="height: 8px;">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-warning" style="width: 100%" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
<div class="small text-muted mb-3">Truncate is a dedicated step. Downsample and reconstruction always run on the full active series.</div>
<hr>
<div class="mb-3">
@@ -443,6 +452,19 @@
endHidden.value = endInput.value;
}
function updateBoundsInputs(startValue, endValue) {
const startInput = document.getElementById('truncate-lower-input');
const endInput = document.getElementById('truncate-upper-input');
if (!startInput || !endInput) {
return;
}
startInput.value = String(startValue);
endInput.value = String(endValue);
startInput.max = String(endValue);
endInput.max = String(endValue);
syncOptimizeBounds();
}
function getTruncateViewerElement() {
return document.getElementById(truncateApiKey);
}
@@ -559,8 +581,46 @@
syncOptimizeBounds();
});
document.body.addEventListener('htmx:beforeRequest', function (event) {
const form = document.getElementById('series-optimize-form');
if (!form || event.target !== form) {
return;
}
const op = event.detail?.parameters?.operation || '';
const truncateProgress = document.getElementById('truncate-progress');
const truncateApplyBtn = document.getElementById('truncate-apply-btn');
if (op === 'truncate') {
truncateProgress?.classList.remove('d-none');
if (truncateApplyBtn) {
truncateApplyBtn.disabled = true;
}
}
});
document.body.addEventListener('htmx:afterRequest', function (event) {
const form = document.getElementById('series-optimize-form');
if (!form || event.target !== form) {
return;
}
const truncateProgress = document.getElementById('truncate-progress');
const truncateApplyBtn = document.getElementById('truncate-apply-btn');
truncateProgress?.classList.add('d-none');
if (truncateApplyBtn) {
truncateApplyBtn.disabled = false;
}
});
document.body.addEventListener('htmx:afterSwap', function (event) {
if (event.target && event.target.id === 'series-optimize-feedback') {
const truncatePayload = document.getElementById('truncate-range-payload');
if (truncatePayload) {
const start = parseInt(truncatePayload.dataset.start || '1', 10);
const end = parseInt(truncatePayload.dataset.end || '1', 10);
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
updateBoundsInputs(start, end);
}
}
const payload = document.getElementById('downsample-compare-payload');
if (!payload) {
return;
+5
View File
@@ -570,6 +570,11 @@ urlpatterns = [
views.series_optimize_htmx,
name="series_optimize",
),
path(
"series/<int:series_id>/reconstruct/status/<str:task_id>/",
views.series_reconstruct_status_htmx,
name="series_reconstruct_status",
),
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
+107 -195
View File
@@ -163,7 +163,8 @@ from .filters import (
NormalCaseFilter,
)
from .tasks import push_case_to_cimar_task
from .tasks import push_case_to_cimar_task, series_reconstruct_task
from celery.result import AsyncResult
from django_tables2 import SingleTableView, SingleTableMixin
from django_filters.views import FilterView
@@ -530,6 +531,7 @@ def series_optimize_htmx(request, series_id):
images = list(series.get_images())
bounded_images = [img for idx, img in enumerate(images) if idx >= start and idx <= end]
full_series_images = images
if operation == "truncate":
removed = []
@@ -542,8 +544,19 @@ def series_optimize_htmx(request, series_id):
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
new_total = int(series.get_image_count())
payload = (
f'<div id="truncate-range-payload" data-start="1" data-end="{new_total}" data-total="{new_total}"></div>'
)
return HttpResponse(
f'<div class="alert alert-success mb-0">Truncate complete. Removed <strong>{len(removed)}</strong> images.</div>'
(
'<div class="alert alert-success mb-2">'
f'Truncate complete. Removed <strong>{len(removed)}</strong> images. '
f'Updated working range to <strong>1-{new_total}</strong>.'
'</div>'
f'{payload}'
)
)
if operation == "remove_empty":
@@ -573,7 +586,7 @@ def series_optimize_htmx(request, series_id):
preview_series = _create_series_derivative(series, f"Downsample preview {downsample_pct}%")
created = 0
for image in bounded_images:
for image in full_series_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
@@ -618,7 +631,7 @@ def series_optimize_htmx(request, series_id):
)
downsampled = []
for image in bounded_images:
for image in full_series_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
@@ -672,207 +685,106 @@ def series_optimize_htmx(request, series_id):
return HttpResponse('<div class="alert alert-danger mb-0">Slice thickness must be greater than 0.</div>')
if slice_spacing_val is not None and slice_spacing_val <= 0:
return HttpResponse('<div class="alert alert-danger mb-0">Slice spacing must be greater than 0.</div>')
dicom_items = []
for image in bounded_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
try:
arr = ds.pixel_array
if arr.ndim != 2:
continue
dicom_items.append((image, ds, arr))
except Exception:
continue
if len(dicom_items) < 2:
return HttpResponse('<div class="alert alert-warning mb-0">Need at least 2 valid DICOM images in range for reconstruction.</div>')
import numpy as np
base_shape = dicom_items[0][2].shape
dicom_items = [item for item in dicom_items if item[2].shape == base_shape]
if len(dicom_items) < 2:
return HttpResponse('<div class="alert alert-warning mb-0">Not enough consistently-sized slices for reconstruction.</div>')
geom = _extract_recon_geometry(dicom_items)
sorted_items = geom["sorted_items"]
volume = np.stack([item[2] for item in sorted_items], axis=0)
template_ds = sorted_items[0][1]
source_positions_mm = geom["source_positions_mm"]
origin_ipp = geom["origin_ipp"]
row_dir = geom["row_dir"]
col_dir = geom["col_dir"]
normal_dir = geom["normal_dir"]
native_row_spacing = geom["row_spacing"]
native_col_spacing = geom["col_spacing"]
native_z_spacing = geom["native_z_spacing"]
target_spacing = slice_spacing_val if slice_spacing_val is not None else native_z_spacing
slab_thickness = slice_thickness_val if slice_thickness_val is not None else target_spacing
try:
volume_for_recon, recon_centers_mm = _aggregate_volume_along_z(
volume,
source_positions_mm,
float(target_spacing),
float(slab_thickness),
recon_thickness_mode,
)
except Exception as exc:
logger.warning("Reconstruction aggregation failed for series {}: {}", series.pk, exc)
return HttpResponse('<div class="alert alert-danger mb-0">Failed to build reconstruction slabs for the requested spacing/thickness.</div>')
if volume_for_recon.shape[0] < 1:
return HttpResponse('<div class="alert alert-warning mb-0">No reconstruction slices were generated.</div>')
base_z_offset = float(recon_centers_mm[0]) if len(recon_centers_mm) > 0 else 0.0
def build_position(origin, row_vector, col_vector, stack_vector, row_index=0, col_index=0, stack_offset=0.0):
import numpy as np
pos = (
np.asarray(origin, dtype=float)
+ (np.asarray(stack_vector, dtype=float) * float(stack_offset))
+ (np.asarray(row_vector, dtype=float) * (float(row_index) * float(native_row_spacing)))
+ (np.asarray(col_vector, dtype=float) * (float(col_index) * float(native_col_spacing)))
)
return [float(pos[0]), float(pos[1]), float(pos[2])]
created_series = []
for plane in recon_planes:
plane_norm = plane.lower()
if plane_norm == "axial":
recon_slices = [volume_for_recon[i, :, :] for i in range(volume_for_recon.shape[0])]
pixel_spacing_out = [native_row_spacing, native_col_spacing]
spacing_between_slices_out = float(target_spacing)
image_orientation_out = [
float(row_dir[0]),
float(row_dir[1]),
float(row_dir[2]),
float(col_dir[0]),
float(col_dir[1]),
float(col_dir[2]),
]
def position_for_index(idx):
return build_position(
origin_ipp,
row_dir,
col_dir,
normal_dir,
row_index=0,
col_index=0,
stack_offset=float(recon_centers_mm[idx]),
)
elif plane_norm == "coronal":
recon_slices = [volume_for_recon[:, i, :] for i in range(volume_for_recon.shape[1])]
pixel_spacing_out = [target_spacing, native_col_spacing]
spacing_between_slices_out = float(native_row_spacing)
image_orientation_out = [
float(normal_dir[0]),
float(normal_dir[1]),
float(normal_dir[2]),
float(col_dir[0]),
float(col_dir[1]),
float(col_dir[2]),
]
def position_for_index(idx):
return build_position(
origin_ipp,
row_dir,
col_dir,
normal_dir,
row_index=idx,
col_index=0,
stack_offset=base_z_offset,
)
elif plane_norm == "sagittal":
recon_slices = [volume_for_recon[:, :, i] for i in range(volume_for_recon.shape[2])]
pixel_spacing_out = [target_spacing, native_row_spacing]
spacing_between_slices_out = float(native_col_spacing)
image_orientation_out = [
float(normal_dir[0]),
float(normal_dir[1]),
float(normal_dir[2]),
float(row_dir[0]),
float(row_dir[1]),
float(row_dir[2]),
]
def position_for_index(idx):
return build_position(
origin_ipp,
row_dir,
col_dir,
normal_dir,
row_index=0,
col_index=idx,
stack_offset=base_z_offset,
)
else:
continue
recon_series = _create_series_derivative(series, f"Recon {plane_norm.title()}")
recon_series.series_instance_uid = generate_uid()
recon_series.save(update_fields=["series_instance_uid"])
for idx, arr2d in enumerate(recon_slices):
ds_new = copy.deepcopy(template_ds)
arr2d = np.asarray(arr2d, dtype=dicom_items[0][2].dtype)
ds_new.Rows = int(arr2d.shape[0])
ds_new.Columns = int(arr2d.shape[1])
ds_new.InstanceNumber = idx + 1
ds_new.SOPInstanceUID = generate_uid()
ds_new.SeriesInstanceUID = recon_series.series_instance_uid
ds_new.PixelData = arr2d.tobytes()
ds_new.PixelSpacing = [float(pixel_spacing_out[0]), float(pixel_spacing_out[1])]
ds_new.ImageOrientationPatient = image_orientation_out
ds_new.ImagePositionPatient = position_for_index(idx)
ds_new.SliceThickness = float(slab_thickness)
ds_new.SpacingBetweenSlices = float(spacing_between_slices_out)
out_io = io.BytesIO()
ds_new.save_as(out_io, write_like_original=False)
out_io.seek(0)
recon_image = SeriesImage(series=recon_series, position=idx + 1, upload_filename=f"recon_{plane_norm}_{idx+1}.dcm")
recon_image.image.save(
f"recon_{plane_norm}_{recon_series.pk}_{idx+1}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
recon_image.save()
created_series.append(recon_series)
if not created_series:
return HttpResponse('<div class="alert alert-warning mb-0">No reconstruction plane could be generated.</div>')
links = " ".join(
[
f'<a class="alert-link me-2" target="_blank" href="{s.get_absolute_url()}">{escape(s.description or str(s.pk))}</a>'
for s in created_series
]
async_task = series_reconstruct_task.delay(
series_id=series.pk,
user_id=request.user.pk,
recon_planes=recon_planes,
slice_thickness_val=slice_thickness_val,
slice_spacing_val=slice_spacing_val,
recon_thickness_mode=recon_thickness_mode,
)
return HttpResponse(
(
'<div class="alert alert-success mb-0">'
f'Created <strong>{len(created_series)}</strong> reconstruction series '
f'(spacing={target_spacing:.2f}mm, thickness={slab_thickness:.2f}mm, mode={escape(recon_thickness_mode)}): {links}'
'<div class="alert alert-info mb-2">'
'Reconstruction queued. This runs in the background to avoid request timeout.'
'</div>'
f'<div id="recon-task-status" hx-get="{reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": async_task.id})}" '
'hx-trigger="load, every 2s" hx-swap="outerHTML">'
'<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
'<span class="small text-muted">Starting reconstruction task...</span></div></div>'
)
)
return HttpResponse('<div class="alert alert-danger mb-0">Unknown optimize operation.</div>')
@login_required
def series_reconstruct_status_htmx(request, series_id, task_id):
"""HTMX endpoint returning progress for a queued series reconstruction task."""
series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user):
return HttpResponse('<div class="alert alert-danger mb-0">Permission denied</div>')
task_result = AsyncResult(task_id)
state = task_result.state
poll_url = reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_id})
if state in ("PENDING", "STARTED", "RETRY"):
return HttpResponse(
(
f'<div id="recon-task-status" hx-get="{poll_url}" hx-trigger="every 2s" hx-swap="outerHTML">'
'<div class="d-flex align-items-center gap-2">'
'<span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
'<span class="small text-muted">Reconstruction is running...</span>'
'</div></div>'
)
)
if state == "PROGRESS":
meta = task_result.info or {}
current = int(meta.get("current", 0) or 0)
total = int(meta.get("total", 0) or 0)
message = escape(str(meta.get("message", "Reconstruction running...")))
pct = int((current / total) * 100) if total > 0 else 0
return HttpResponse(
(
f'<div id="recon-task-status" hx-get="{poll_url}" hx-trigger="every 2s" hx-swap="outerHTML">'
f'<div class="small text-muted mb-1">{message}</div>'
'<div class="progress" style="height: 10px;">'
f'<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {pct}%" '
f'aria-valuenow="{pct}" aria-valuemin="0" aria-valuemax="100"></div>'
'</div>'
f'<div class="small text-muted mt-1">{current}/{total} slices processed</div>'
'</div>'
)
)
if state == "SUCCESS":
payload = task_result.result if isinstance(task_result.result, dict) else {}
created_series = payload.get("created_series", [])
if created_series:
links = " ".join(
[
f'<a class="alert-link me-2" target="_blank" href="{escape(item.get("url", "#"))}">{escape(item.get("description", str(item.get("id", "series"))))}</a>'
for item in created_series
]
)
return HttpResponse(
(
'<div id="recon-task-status" class="alert alert-success mb-0">'
f'Reconstruction complete. Created <strong>{len(created_series)}</strong> series: {links}'
'</div>'
)
)
return HttpResponse('<div id="recon-task-status" class="alert alert-warning mb-0">Reconstruction finished but no output series were created.</div>')
if state == "FAILURE":
err = escape(str(task_result.result))
return HttpResponse(
f'<div id="recon-task-status" class="alert alert-danger mb-0">Reconstruction failed: {err}</div>'
)
return HttpResponse(
f'<div id="recon-task-status" class="alert alert-secondary mb-0">Task state: {escape(state)}</div>'
)
@login_required
def series_image_size_htmx(request, series_id):
"""HTMX endpoint returning the total size of images in a series.