feat(tasks): Implement task action functionality in task overview page
This commit is contained in:
+100
-41
@@ -199,19 +199,8 @@ def series_reconstruct_task(
|
||||
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")
|
||||
requested_slice_spacing = float(slice_spacing_val) if slice_spacing_val is not None else None
|
||||
requested_slab_thickness = float(slice_thickness_val) if slice_thickness_val is not None else None
|
||||
|
||||
# Determine source acquisition plane from normal vector.
|
||||
dominant_normal_axis = int(np.argmax(np.abs(normal_dir)))
|
||||
@@ -219,9 +208,14 @@ def series_reconstruct_task(
|
||||
|
||||
# Reorient the reconstructed volume into patient axes order [z, y, x] so
|
||||
# generated plane names are anatomically correct regardless of source plane.
|
||||
source_axis_dirs = [normal_dir, row_dir, col_dir]
|
||||
source_axis_spacings = [float(target_spacing), float(native_row_spacing), float(native_col_spacing)]
|
||||
source_axis_sizes = [int(volume_for_recon.shape[0]), int(volume_for_recon.shape[1]), int(volume_for_recon.shape[2])]
|
||||
# NOTE: DICOM stores ImageOrientationPatient as:
|
||||
# - first triplet: direction across columns (x-axis in pixel grid)
|
||||
# - second triplet: direction across rows (y-axis in pixel grid)
|
||||
# In _extract_recon_geometry names are historically row_dir/col_dir, so we
|
||||
# map axis spacing/indexing explicitly to avoid directional mixups.
|
||||
source_axis_dirs = [normal_dir, col_dir, row_dir]
|
||||
source_axis_spacings = [float(native_z_spacing), float(native_row_spacing), float(native_col_spacing)]
|
||||
source_axis_sizes = [int(volume.shape[0]), int(volume.shape[1]), int(volume.shape[2])]
|
||||
|
||||
source_for_patient_axis = {}
|
||||
sign_for_patient_axis = {}
|
||||
@@ -243,7 +237,7 @@ def series_reconstruct_task(
|
||||
spacing_y = source_axis_spacings[src_y]
|
||||
spacing_z = source_axis_spacings[src_z]
|
||||
|
||||
vol_zyx = np.transpose(volume_for_recon, (src_z, src_y, src_x))
|
||||
vol_zyx = np.transpose(volume, (src_z, src_y, src_x))
|
||||
|
||||
if sign_for_patient_axis[2] < 0:
|
||||
vol_zyx = np.flip(vol_zyx, axis=0)
|
||||
@@ -255,9 +249,9 @@ def series_reconstruct_task(
|
||||
def source_indices_from_patient_zyx(iz, iy, ix):
|
||||
src_indices = [0, 0, 0]
|
||||
|
||||
z_index = int(iz)
|
||||
y_index = int(iy)
|
||||
x_index = int(ix)
|
||||
z_index = float(iz)
|
||||
y_index = float(iy)
|
||||
x_index = float(ix)
|
||||
|
||||
if sign_for_patient_axis[2] < 0:
|
||||
z_index = source_axis_sizes[src_z] - 1 - z_index
|
||||
@@ -275,41 +269,106 @@ def series_reconstruct_task(
|
||||
src_k, src_r, src_c = source_indices_from_patient_zyx(iz, iy, ix)
|
||||
pos = (
|
||||
np.asarray(origin_ipp, dtype=float)
|
||||
+ (np.asarray(normal_dir, dtype=float) * (float(src_k) * float(target_spacing)))
|
||||
+ (np.asarray(row_dir, dtype=float) * (float(src_r) * float(native_row_spacing)))
|
||||
+ (np.asarray(col_dir, dtype=float) * (float(src_c) * float(native_col_spacing)))
|
||||
+ (np.asarray(normal_dir, dtype=float) * (float(src_k) * float(native_z_spacing)))
|
||||
+ (np.asarray(col_dir, dtype=float) * (float(src_r) * float(native_row_spacing)))
|
||||
+ (np.asarray(row_dir, dtype=float) * (float(src_c) * float(native_col_spacing)))
|
||||
)
|
||||
return [float(pos[0]), float(pos[1]), float(pos[2])]
|
||||
|
||||
spacing_x = source_axis_spacings[src_x]
|
||||
spacing_y = source_axis_spacings[src_y]
|
||||
spacing_z = source_axis_spacings[src_z]
|
||||
|
||||
def _reduce_slab(slab):
|
||||
if recon_thickness_mode == "max":
|
||||
out = np.max(slab, axis=0)
|
||||
elif recon_thickness_mode == "min":
|
||||
out = np.min(slab, axis=0)
|
||||
else:
|
||||
out = np.mean(slab, axis=0)
|
||||
|
||||
if np.issubdtype(volume.dtype, np.integer):
|
||||
info = np.iinfo(volume.dtype)
|
||||
out = np.clip(np.rint(out), info.min, info.max).astype(volume.dtype)
|
||||
else:
|
||||
out = out.astype(volume.dtype, copy=False)
|
||||
return out
|
||||
|
||||
def _slice_indices_for_axis(axis_length, axis_spacing, output_spacing, slab_thickness):
|
||||
if output_spacing <= 0:
|
||||
raise ValueError("Slice spacing must be greater than 0")
|
||||
if slab_thickness <= 0:
|
||||
raise ValueError("Slice thickness must be greater than 0")
|
||||
|
||||
axis_positions_mm = np.arange(axis_length, dtype=float) * float(axis_spacing)
|
||||
end_pos = axis_positions_mm[-1] if axis_positions_mm.size else 0.0
|
||||
centers_mm = np.arange(0.0, end_pos + (output_spacing * 0.5), output_spacing, dtype=float)
|
||||
if centers_mm.size == 0:
|
||||
centers_mm = np.array([0.0], dtype=float)
|
||||
|
||||
half = slab_thickness / 2.0
|
||||
slabs = []
|
||||
centers_idx = []
|
||||
for center_mm in centers_mm:
|
||||
mask = np.abs(axis_positions_mm - center_mm) <= (half + 1e-6)
|
||||
if not mask.any():
|
||||
nearest = int(np.argmin(np.abs(axis_positions_mm - center_mm)))
|
||||
idxs = np.array([nearest], dtype=int)
|
||||
else:
|
||||
idxs = np.where(mask)[0].astype(int)
|
||||
|
||||
slabs.append(idxs)
|
||||
centers_idx.append(float(center_mm / axis_spacing) if axis_spacing > 0 else float(idxs[0]))
|
||||
|
||||
return slabs, centers_idx
|
||||
|
||||
plane_builders = {}
|
||||
for plane in recon_planes:
|
||||
plane_norm = plane.lower()
|
||||
if plane_norm == "axial":
|
||||
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_z)
|
||||
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
|
||||
slab_indices, slab_centers_idx = _slice_indices_for_axis(
|
||||
int(vol_zyx.shape[0]), float(spacing_z), plane_spacing, plane_thickness
|
||||
)
|
||||
plane_builders[plane_norm] = {
|
||||
"count": int(vol_zyx.shape[0]),
|
||||
"slice_fn": lambda idx: vol_zyx[idx, :, :],
|
||||
"count": int(len(slab_indices)),
|
||||
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[slabs[idx], :, :]),
|
||||
"pixel_spacing_out": [float(spacing_y), float(spacing_x)],
|
||||
"spacing_between_slices_out": float(spacing_z),
|
||||
"image_orientation_out": [0.0, 1.0, 0.0, 1.0, 0.0, 0.0],
|
||||
"position_fn": lambda idx: position_from_patient_zyx(idx, 0, 0),
|
||||
"spacing_between_slices_out": float(plane_spacing),
|
||||
"slice_thickness_out": float(plane_thickness),
|
||||
"image_orientation_out": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||||
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(centers[idx], 0.0, 0.0),
|
||||
}
|
||||
elif plane_norm == "coronal":
|
||||
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_y)
|
||||
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
|
||||
slab_indices, slab_centers_idx = _slice_indices_for_axis(
|
||||
int(vol_zyx.shape[1]), float(spacing_y), plane_spacing, plane_thickness
|
||||
)
|
||||
plane_builders[plane_norm] = {
|
||||
"count": int(vol_zyx.shape[1]),
|
||||
"slice_fn": lambda idx: vol_zyx[:, idx, :],
|
||||
"count": int(len(slab_indices)),
|
||||
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[:, slabs[idx], :]),
|
||||
"pixel_spacing_out": [float(spacing_z), float(spacing_x)],
|
||||
"spacing_between_slices_out": float(spacing_y),
|
||||
"image_orientation_out": [0.0, 0.0, 1.0, 1.0, 0.0, 0.0],
|
||||
"position_fn": lambda idx: position_from_patient_zyx(0, idx, 0),
|
||||
"spacing_between_slices_out": float(plane_spacing),
|
||||
"slice_thickness_out": float(plane_thickness),
|
||||
"image_orientation_out": [1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
|
||||
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(0.0, centers[idx], 0.0),
|
||||
}
|
||||
elif plane_norm == "sagittal":
|
||||
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_x)
|
||||
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
|
||||
slab_indices, slab_centers_idx = _slice_indices_for_axis(
|
||||
int(vol_zyx.shape[2]), float(spacing_x), plane_spacing, plane_thickness
|
||||
)
|
||||
plane_builders[plane_norm] = {
|
||||
"count": int(vol_zyx.shape[2]),
|
||||
"slice_fn": lambda idx: vol_zyx[:, :, idx],
|
||||
"count": int(len(slab_indices)),
|
||||
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[:, :, slabs[idx]]),
|
||||
"pixel_spacing_out": [float(spacing_z), float(spacing_y)],
|
||||
"spacing_between_slices_out": float(spacing_x),
|
||||
"image_orientation_out": [0.0, 0.0, 1.0, 0.0, 1.0, 0.0],
|
||||
"position_fn": lambda idx: position_from_patient_zyx(0, 0, idx),
|
||||
"spacing_between_slices_out": float(plane_spacing),
|
||||
"slice_thickness_out": float(plane_thickness),
|
||||
"image_orientation_out": [0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
|
||||
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(0.0, 0.0, centers[idx]),
|
||||
}
|
||||
|
||||
if not plane_builders:
|
||||
@@ -344,7 +403,7 @@ def series_reconstruct_task(
|
||||
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_fn"](idx)
|
||||
ds_new.SliceThickness = float(slab_thickness)
|
||||
ds_new.SliceThickness = float(cfg["slice_thickness_out"])
|
||||
ds_new.SpacingBetweenSlices = float(cfg["spacing_between_slices_out"])
|
||||
|
||||
out_io = io.BytesIO()
|
||||
@@ -401,8 +460,8 @@ def series_reconstruct_task(
|
||||
return {
|
||||
"series_id": series.pk,
|
||||
"created_series": created_series,
|
||||
"target_spacing": float(target_spacing),
|
||||
"slab_thickness": float(slab_thickness),
|
||||
"target_spacing": float(requested_slice_spacing) if requested_slice_spacing is not None else None,
|
||||
"slab_thickness": float(requested_slab_thickness) if requested_slab_thickness is not None else None,
|
||||
"mode": recon_thickness_mode,
|
||||
"source_plane": source_plane,
|
||||
}
|
||||
@@ -51,10 +51,28 @@ Atlas Task Overview
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<form method="post" class="mb-3">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||
<select class="form-select form-select-sm" name="task_action" style="max-width: 320px;">
|
||||
<option value="">Choose task action...</option>
|
||||
<option value="reset_failed">Reset FAILED to READY</option>
|
||||
<option value="reset_selected">Reset selected to READY (skip RUNNING)</option>
|
||||
<option value="delete_completed">Delete selected COMPLETED</option>
|
||||
<option value="delete_failed">Delete selected FAILED</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
|
||||
<div class="form-check ms-2">
|
||||
<input class="form-check-input" type="checkbox" id="select-all-tasks">
|
||||
<label class="form-check-label small" for="select-all-tasks">Select all</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 36px;"></th>
|
||||
<th>Task</th>
|
||||
<th>Status</th>
|
||||
<th>Generated Series</th>
|
||||
@@ -64,11 +82,15 @@ Atlas Task Overview
|
||||
<th>Finished</th>
|
||||
<th>Worker</th>
|
||||
<th>Error</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr>
|
||||
<td>
|
||||
<input class="form-check-input task-select" type="checkbox" name="task_ids" value="{{ task.id }}">
|
||||
</td>
|
||||
<td>
|
||||
<div class="small fw-semibold">{{ task.task_path }}</div>
|
||||
<div class="small text-muted">{{ task.id }}</div>
|
||||
@@ -99,14 +121,58 @@ Atlas Task Overview
|
||||
<td class="small">{{ task.finished_at|date:"Y-m-d H:i:s" }}</td>
|
||||
<td class="small">{{ task.worker_ids }}</td>
|
||||
<td class="small text-danger">{{ task.exception_class_path|default:"" }}</td>
|
||||
<td class="small">
|
||||
{% if task.traceback %}
|
||||
<button
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#traceback-{{ task.id }}"
|
||||
aria-expanded="false"
|
||||
aria-controls="traceback-{{ task.id }}"
|
||||
>
|
||||
View full traceback
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if task.traceback %}
|
||||
<tr class="collapse" id="traceback-{{ task.id }}">
|
||||
<td></td>
|
||||
<td colspan="10">
|
||||
<div class="border rounded bg-dark-subtle p-2">
|
||||
<div class="small fw-semibold mb-1">Full traceback</div>
|
||||
<pre class="small mb-0" style="white-space: pre-wrap;">{{ task.traceback }}</pre>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="9" class="text-muted">No tasks found for this filter.</td>
|
||||
<td colspan="11" class="text-muted">No tasks found for this filter.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const selectAll = document.getElementById('select-all-tasks');
|
||||
const rows = Array.from(document.querySelectorAll('.task-select'));
|
||||
if (!selectAll || !rows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectAll.addEventListener('change', function () {
|
||||
rows.forEach((cb) => {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -819,6 +819,54 @@ def task_overview(request):
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
action = (request.POST.get("task_action") or "").strip()
|
||||
selected_ids = [task_id for task_id in request.POST.getlist("task_ids") if task_id]
|
||||
|
||||
if not selected_ids:
|
||||
messages.warning(request, "Select at least one task to run an action.")
|
||||
return redirect(f"{reverse('atlas:task_overview')}?status={(request.GET.get('status') or 'all').strip().lower()}")
|
||||
|
||||
selected_qs = DBTaskResult.objects.filter(id__in=selected_ids)
|
||||
|
||||
if action == "reset_failed":
|
||||
updated = selected_qs.filter(status="FAILED").update(
|
||||
status="READY",
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
worker_ids="",
|
||||
exception_class_path="",
|
||||
traceback="",
|
||||
return_value=None,
|
||||
)
|
||||
messages.success(request, f"Reset {updated} failed task(s) to READY.")
|
||||
elif action == "reset_selected":
|
||||
blocked = selected_qs.filter(status="RUNNING").count()
|
||||
updated = selected_qs.exclude(status="RUNNING").update(
|
||||
status="READY",
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
worker_ids="",
|
||||
exception_class_path="",
|
||||
traceback="",
|
||||
return_value=None,
|
||||
)
|
||||
msg = f"Reset {updated} task(s) to READY."
|
||||
if blocked:
|
||||
msg += f" Skipped {blocked} running task(s)."
|
||||
messages.success(request, msg)
|
||||
elif action == "delete_completed":
|
||||
deleted, _ = selected_qs.filter(status="SUCCESSFUL").delete()
|
||||
messages.success(request, f"Deleted {deleted} completed task record(s).")
|
||||
elif action == "delete_failed":
|
||||
deleted, _ = selected_qs.filter(status="FAILED").delete()
|
||||
messages.success(request, f"Deleted {deleted} failed task record(s).")
|
||||
else:
|
||||
messages.error(request, "Invalid task action requested.")
|
||||
|
||||
status_q = (request.GET.get("status") or "all").strip().lower()
|
||||
return redirect(f"{reverse('atlas:task_overview')}?status={status_q}")
|
||||
|
||||
selected_status = (request.GET.get("status") or "all").strip().lower()
|
||||
status_map = {
|
||||
"active": ["READY", "RUNNING"],
|
||||
|
||||
Reference in New Issue
Block a user