feat(tasks): Refactor series_reconstruct_task and add _stamp_derived_dataset for improved dataset handling

This commit is contained in:
Ross
2026-05-18 07:48:24 +01:00
parent ff00efecf7
commit 1c76e0696c
3 changed files with 88 additions and 19 deletions
+7 -13
View File
@@ -86,7 +86,6 @@ 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 datetime import datetime
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
from django.core.cache import cache
@@ -392,9 +391,6 @@ def series_reconstruct_task(
recon_series = atlas_views._create_series_derivative(series, f"Recon {plane_norm.title()} ({source_plane.title()} src)")
recon_series.series_instance_uid = generate_uid()
recon_series.save(update_fields=["series_instance_uid"])
now = datetime.utcnow()
date_str = now.strftime("%Y%m%d")
time_str = now.strftime("%H%M%S.%f")[:13]
for idx in range(int(cfg["count"])):
arr2d = cfg["slice_fn"](idx)
@@ -404,18 +400,16 @@ def series_reconstruct_task(
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.SeriesDescription = recon_series.description or f"Recon {plane_norm.title()}"
ds_new.ImageType = ["DERIVED", "SECONDARY", "MPR"]
ds_new.DerivationDescription = (
atlas_views._stamp_derived_dataset(
ds_new,
series_instance_uid=recon_series.series_instance_uid,
series_description=recon_series.description or f"Recon {plane_norm.title()}",
derivation_description=(
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
f"thickness={float(cfg['slice_thickness_out']):.3f}mm; spacing={float(cfg['spacing_between_slices_out']):.3f}mm"
),
image_type=["DERIVED", "SECONDARY", "MPR"],
)
ds_new.ContentDate = date_str
ds_new.ContentTime = time_str
ds_new.InstanceCreationDate = date_str
ds_new.InstanceCreationTime = time_str
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"]
+2 -2
View File
@@ -212,7 +212,7 @@
<button class="btn btn-outline-warning btn-sm"
data-bs-toggle="modal"
data-bs-target="#truncate-series-modal">
<i class="bi bi-scissors"></i> Truncate series
<i class="bi bi-sliders"></i> Series tools
</button>
<div class="card mt-2">
@@ -255,7 +255,7 @@
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Series Optimization</h5>
<h5 class="modal-title">Series Tools</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
+79 -4
View File
@@ -307,7 +307,45 @@ def _resize_array_nearest(arr, target_rows, target_cols):
return arr[np.ix_(row_idx, col_idx)]
def _downsample_dicom_dataset(ds, reduction_pct):
def _stamp_derived_dataset(
ds,
*,
series_instance_uid=None,
series_description=None,
derivation_description=None,
image_type=None,
regenerate_sop_instance_uid=True,
stamp_series_datetime=True,
):
now = timezone.now()
date_str = now.strftime("%Y%m%d")
time_str = now.strftime("%H%M%S.%f")[:13]
if regenerate_sop_instance_uid:
ds.SOPInstanceUID = generate_uid()
if series_instance_uid:
ds.SeriesInstanceUID = series_instance_uid
if series_description:
ds.SeriesDescription = series_description
ds.ImageType = image_type or ["DERIVED", "SECONDARY"]
if derivation_description:
ds.DerivationDescription = derivation_description
ds.ContentDate = date_str
ds.ContentTime = time_str
ds.InstanceCreationDate = date_str
ds.InstanceCreationTime = time_str
if stamp_series_datetime:
ds.SeriesDate = date_str
ds.SeriesTime = time_str
return ds
def _downsample_dicom_dataset(ds, reduction_pct, *, series_instance_uid=None, series_description=None):
if reduction_pct <= 0 or reduction_pct >= 100:
raise ValueError("Downsample percentage must be between 1 and 99")
@@ -321,11 +359,36 @@ def _downsample_dicom_dataset(ds, reduction_pct):
target_cols = max(1, int(round(px.shape[1] * factor)))
resized = _resize_array_nearest(px, target_rows, target_cols)
derivation_description = f"Downsampled by {reduction_pct}% using nearest-neighbour resize"
_stamp_derived_dataset(
ds,
series_instance_uid=series_instance_uid,
series_description=series_description,
derivation_description=derivation_description,
image_type=["DERIVED", "SECONDARY"],
)
if hasattr(ds, "PixelSpacing") and ds.PixelSpacing and len(ds.PixelSpacing) >= 2:
row_spacing = _safe_float(ds.PixelSpacing[0], 1.0)
col_spacing = _safe_float(ds.PixelSpacing[1], 1.0)
ds.PixelSpacing = [float(row_spacing / factor), float(col_spacing / factor)]
if hasattr(ds, "ImagerPixelSpacing") and ds.ImagerPixelSpacing and len(ds.ImagerPixelSpacing) >= 2:
row_spacing = _safe_float(ds.ImagerPixelSpacing[0], 1.0)
col_spacing = _safe_float(ds.ImagerPixelSpacing[1], 1.0)
ds.ImagerPixelSpacing = [float(row_spacing / factor), float(col_spacing / factor)]
if hasattr(ds, "NominalScannedPixelSpacing") and ds.NominalScannedPixelSpacing and len(ds.NominalScannedPixelSpacing) >= 2:
row_spacing = _safe_float(ds.NominalScannedPixelSpacing[0], 1.0)
col_spacing = _safe_float(ds.NominalScannedPixelSpacing[1], 1.0)
ds.NominalScannedPixelSpacing = [float(row_spacing / factor), float(col_spacing / factor)]
ds.Rows = int(target_rows)
ds.Columns = int(target_cols)
ds.PixelData = resized.tobytes()
ds.pop("SmallestImagePixelValue", None)
ds.pop("LargestImagePixelValue", None)
ds.pop("PixelAspectRatio", None)
return ds
@@ -606,7 +669,12 @@ def series_optimize_htmx(request, series_id):
continue
try:
ds_out = _downsample_dicom_dataset(ds, downsample_pct)
ds_out = _downsample_dicom_dataset(
ds,
downsample_pct,
series_instance_uid=preview_series.series_instance_uid,
series_description=preview_series.description,
)
except Exception as exc:
logger.warning("Downsample preview failed for image {}: {}", image.pk, exc)
continue
@@ -645,6 +713,7 @@ def series_optimize_htmx(request, series_id):
)
downsampled = []
replacement_series_uid = generate_uid()
for image in full_series_images:
ds = _read_series_image_dataset(image)
if ds is None:
@@ -652,7 +721,12 @@ def series_optimize_htmx(request, series_id):
try:
original_hash = image.image_blake3_hash
ds_out = _downsample_dicom_dataset(ds, downsample_pct)
ds_out = _downsample_dicom_dataset(
ds,
downsample_pct,
series_instance_uid=replacement_series_uid,
series_description=series.description,
)
out_io = io.BytesIO()
ds_out.save_as(out_io, write_like_original=False)
out_io.seek(0)
@@ -671,7 +745,8 @@ def series_optimize_htmx(request, series_id):
if downsampled:
series.modified = Series.SeriesModifiedChocies.RE
series.save(update_fields=["modified"])
series.series_instance_uid = replacement_series_uid
series.save(update_fields=["modified", "series_instance_uid"])
return HttpResponse(
f'<div class="alert alert-success mb-0">Downsample complete ({downsample_pct}%). Updated <strong>{len(downsampled)}</strong> images while preserving original hashes.</div>'