feat(v2): add multiple extra features from the pdf slides

This commit is contained in:
2026-07-09 01:22:02 +03:30
parent ae240e7ac1
commit 20a43d5c9a
16 changed files with 1003 additions and 239 deletions

View File

@@ -69,6 +69,16 @@ def histogram(image):
return probabilities.round(8).tolist()
def histogram_payload(image):
gray = to_gray(image)
payload = {"intensity": histogram(gray)}
if image.ndim == 3:
payload["r"] = (np.bincount(image[:, :, 0].ravel(), minlength=256).astype(np.float64) / image[:, :, 0].size).round(8).tolist()
payload["g"] = (np.bincount(image[:, :, 1].ravel(), minlength=256).astype(np.float64) / image[:, :, 1].size).round(8).tolist()
payload["b"] = (np.bincount(image[:, :, 2].ravel(), minlength=256).astype(np.float64) / image[:, :, 2].size).round(8).tolist()
return payload
def image_to_data_url(image):
pil_image = Image.fromarray(ensure_uint8(image))
buffer = BytesIO()

View File

@@ -0,0 +1,51 @@
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("processing", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="ImageState",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("sequence", models.PositiveIntegerField(default=0)),
("label", models.CharField(max_length=120)),
("operation", models.CharField(max_length=96)),
("params", models.JSONField(blank=True, default=dict)),
("image", models.CharField(max_length=255)),
("width", models.PositiveIntegerField()),
("height", models.PositiveIntegerField()),
("channels", models.PositiveSmallIntegerField()),
("color_mode", models.CharField(max_length=16)),
("histogram", models.JSONField(default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"parent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="children",
to="processing.imagestate",
),
),
(
"session",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="states",
to="processing.imagesession",
),
),
],
options={
"ordering": ["sequence", "created_at"],
},
),
]

View File

@@ -28,6 +28,26 @@ class ImageSession(models.Model):
return timezone.now() >= self.expires_at
class ImageState(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
session = models.ForeignKey(ImageSession, related_name="states", on_delete=models.CASCADE)
parent = models.ForeignKey("self", related_name="children", on_delete=models.SET_NULL, null=True, blank=True)
sequence = models.PositiveIntegerField(default=0)
label = models.CharField(max_length=120)
operation = models.CharField(max_length=96)
params = models.JSONField(default=dict, blank=True)
image = models.CharField(max_length=255)
width = models.PositiveIntegerField()
height = models.PositiveIntegerField()
channels = models.PositiveSmallIntegerField()
color_mode = models.CharField(max_length=16)
histogram = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["sequence", "created_at"]
class ProcessingJob(models.Model):
STATUS_PENDING = "pending"
STATUS_RUNNING = "running"

View File

@@ -0,0 +1,342 @@
import math
import cv2
import numpy as np
from .algorithms import (
ProcessingError,
bit_plane,
box_filter,
contrast_stretch,
gamma,
gray_slice,
gray_to_color_sinusoidal,
gray_to_rgb,
histogram_equalization,
histogram_matching,
hsi_intensity_filter,
hsi_to_rgb,
logarithmic,
local_equalization,
negative,
normalize_to_uint8,
pseudo_color_slices,
rgb_to_hsi,
roberts,
sobel,
to_gray,
weighted_average,
median_filter,
high_boost,
ensure_uint8,
)
CH_BASIC = "Basic"
CH3 = "Image Enhancement in the Spatial Domain"
CH4 = "Image Enhancement in the Frequency Domain"
CH6 = "Color Image Processing"
def odd_param(default=3, max_value=35):
return {"type": "int", "default": default, "min": 3, "max": max_value, "step": 2, "odd": True}
def float_param(default, min_value, max_value, step=0.1):
return {"type": "float", "default": default, "min": min_value, "max": max_value, "step": step}
def int_param(default, min_value, max_value, step=1):
return {"type": "int", "default": default, "min": min_value, "max": max_value, "step": step}
def select_param(default, choices):
return {"type": "select", "default": default, "choices": choices}
def bool_param(default=False):
return {"type": "bool", "default": default}
def crop(image, params):
x = max(0, int(params.get("x", 0)))
y = max(0, int(params.get("y", 0)))
width = int(params.get("width", image.shape[1] - x))
height = int(params.get("height", image.shape[0] - y))
if width <= 0 or height <= 0:
raise ProcessingError("Crop width and height must be positive.")
x2 = min(image.shape[1], x + width)
y2 = min(image.shape[0], y + height)
if x >= x2 or y >= y2:
raise ProcessingError("Crop rectangle is outside the image.")
return image[y:y2, x:x2]
def identity(image, params):
return image.copy()
def inverse_log(image, params):
c = float(params.get("c", 1.0))
normalized = image.astype(np.float32) / 255.0
transformed = np.expm1(normalized / max(c, 1e-8))
transformed /= max(float(np.max(transformed)), 1e-8)
return ensure_uint8(np.round(transformed * 255.0))
def threshold(image, params):
level = int(params.get("level", 128))
high = int(params.get("high", 255))
low = int(params.get("low", 0))
gray = to_gray(image)
return gray_to_rgb(np.where(gray >= level, high, low).astype(np.uint8))
def histeq(image, params):
mode = params.get("mode", "intensity")
if image.ndim == 2 or mode == "grayscale":
return histogram_equalization(image, params)
if mode == "rgb":
channels = [histogram_equalization(image[:, :, idx], params)[:, :, 0] for idx in range(3)]
return np.stack(channels, axis=2).astype(np.uint8)
hsi = rgb_to_hsi(image)
intensity = np.round(hsi[..., 2] * 255).astype(np.uint8)
hsi[..., 2] = histogram_equalization(intensity, params)[:, :, 0].astype(np.float32) / 255.0
return hsi_to_rgb(hsi)
def gaussian_noise(image, params):
mean = float(params.get("mean", 0))
variance = float(params.get("variance", 0.01))
sigma = math.sqrt(max(variance, 0.0)) * 255.0
noise = np.random.default_rng().normal(mean * 255.0, sigma, size=image.shape)
return ensure_uint8(np.round(image.astype(np.float32) + noise))
def salt_pepper_noise(image, params):
amount = float(params.get("amount", 0.03))
salt_ratio = float(params.get("salt_ratio", 0.5))
output = image.copy()
rng = np.random.default_rng()
mask = rng.random(image.shape[:2])
salt = mask < amount * salt_ratio
pepper = (mask >= amount * salt_ratio) & (mask < amount)
output[salt] = 255
output[pepper] = 0
return output
def speckle_noise(image, params):
variance = float(params.get("variance", 0.04))
noise = np.random.default_rng().normal(0, math.sqrt(max(variance, 0.0)), size=image.shape)
return ensure_uint8(np.round(image.astype(np.float32) + image.astype(np.float32) * noise))
def gaussian_filter(image, params):
size = int(params.get("size", 3))
variance = float(params.get("variance", 1.0))
if size < 3 or size % 2 == 0:
raise ProcessingError("size must be an odd integer >= 3.")
sigma = math.sqrt(max(variance, 1e-8))
return cv2.GaussianBlur(image, (size, size), sigmaX=sigma, sigmaY=sigma, borderType=cv2.BORDER_REFLECT)
def max_filter(image, params):
size = int(params.get("size", 3))
if size < 3 or size % 2 == 0:
raise ProcessingError("size must be an odd integer >= 3.")
return cv2.dilate(image, np.ones((size, size), np.uint8))
def min_filter(image, params):
size = int(params.get("size", 3))
if size < 3 or size % 2 == 0:
raise ProcessingError("size must be an odd integer >= 3.")
return cv2.erode(image, np.ones((size, size), np.uint8))
def laplacian_slide(image, params):
mask_name = params.get("mask", "cross")
kernels = {
"cross": np.array([[0, 1, 0], [1, -5, 1], [0, 1, 0]], dtype=np.float32),
"diagonal": np.array([[1, 1, 1], [1, -9, 1], [1, 1, 1]], dtype=np.float32),
"zero_sum_cross": np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32),
"zero_sum_diagonal": np.array([[1, 1, 1], [1, -8, 1], [1, 1, 1]], dtype=np.float32),
}
kernel = kernels.get(mask_name)
if kernel is None:
raise ProcessingError("Unknown Laplacian mask.")
channels = [cv2.filter2D(image[:, :, idx], cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT) for idx in range(image.shape[2])]
result = np.stack(channels, axis=2)
return normalize_to_uint8(result) if params.get("mode", "sharpen") == "detail" else ensure_uint8(result)
def gradient_abs_sum(image, params):
operator = params.get("operator", "sobel")
gray = to_gray(image).astype(np.float32)
if operator == "roberts":
gx = np.array([[-1, 0], [0, 1]], dtype=np.float32)
gy = np.array([[0, -1], [1, 0]], dtype=np.float32)
else:
gx = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32)
gy = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32)
fx = cv2.filter2D(gray, cv2.CV_32F, gx, borderType=cv2.BORDER_REFLECT)
fy = cv2.filter2D(gray, cv2.CV_32F, gy, borderType=cv2.BORDER_REFLECT)
return gray_to_rgb(normalize_to_uint8(np.abs(fx) + np.abs(fy)))
def rgb_channel(image, params):
channel = params.get("channel", "r")
index = {"r": 0, "g": 1, "b": 2}.get(channel, 0)
return gray_to_rgb(image[:, :, index])
def hsi_view(image, params):
component = params.get("component", "i")
hsi = rgb_to_hsi(image)
index = {"h": 0, "s": 1, "i": 2}.get(component, 2)
return gray_to_rgb(np.round(hsi[:, :, index] * 255.0).astype(np.uint8))
def fft_spectrum(image, params):
gray = to_gray(image).astype(np.float32)
spectrum = np.fft.fftshift(np.fft.fft2(gray))
mode = params.get("mode", "log_magnitude")
if mode == "phase":
return gray_to_rgb(normalize_to_uint8(np.angle(spectrum)))
magnitude = np.abs(spectrum)
if mode == "log_magnitude":
magnitude = np.log1p(magnitude)
return gray_to_rgb(normalize_to_uint8(magnitude))
def distance_grid(shape):
rows, cols = shape
u = np.arange(rows) - rows / 2
v = np.arange(cols) - cols / 2
vv, uu = np.meshgrid(v, u)
return np.sqrt(uu**2 + vv**2)
def frequency_filter(image, params):
gray = to_gray(image).astype(np.float32)
d0 = float(params.get("cutoff", 40))
order = int(params.get("order", 2))
family = params.get("family", "gaussian")
kind = params.get("kind", "lowpass")
d = distance_grid(gray.shape)
if family == "ideal":
mask = (d <= d0).astype(np.float32)
elif family == "butterworth":
mask = 1.0 / (1.0 + (d / max(d0, 1e-8)) ** (2 * max(order, 1)))
else:
mask = np.exp(-(d**2) / (2.0 * max(d0, 1e-8) ** 2))
if kind == "highpass":
mask = 1.0 - mask
if params.get("output", "image") == "mask":
return gray_to_rgb(normalize_to_uint8(mask))
f = np.fft.fftshift(np.fft.fft2(gray))
result = np.real(np.fft.ifft2(np.fft.ifftshift(f * mask)))
return gray_to_rgb(normalize_to_uint8(result))
def frequency_laplacian(image, params):
gray = to_gray(image).astype(np.float32)
rows, cols = gray.shape
u = np.arange(rows) - rows / 2
v = np.arange(cols) - cols / 2
vv, uu = np.meshgrid(v, u)
h = -4.0 * (np.pi**2) * (uu**2 + vv**2)
f = np.fft.fftshift(np.fft.fft2(gray))
result = np.real(np.fft.ifft2(np.fft.ifftshift(f * h)))
return gray_to_rgb(normalize_to_uint8(result))
def correlation(image, params):
kernel = np.array(params.get("kernel", [[1, 1, 1], [1, 1, 1], [1, 1, 1]]), dtype=np.float32)
kernel /= max(float(np.sum(np.abs(kernel))), 1e-8)
gray = to_gray(image).astype(np.float32)
return gray_to_rgb(normalize_to_uint8(cv2.filter2D(gray, cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT)))
def convolution(image, params):
kernel = np.array(params.get("kernel", [[1, 1, 1], [1, 1, 1], [1, 1, 1]]), dtype=np.float32)
return correlation(image, {"kernel": np.flipud(np.fliplr(kernel)).tolist()})
def bone_scan_workflow(image, params):
gray_rgb = gray_to_rgb(to_gray(image))
lap_detail = laplacian_slide(gray_rgb, {"mask": "zero_sum_diagonal", "mode": "detail"})
sharpened = ensure_uint8(gray_rgb.astype(np.float32) + lap_detail.astype(np.float32))
sobel_img = gradient_abs_sum(gray_rgb, {"operator": "sobel"})
smooth_sobel = box_filter(sobel_img, {"size": 5})
mask = normalize_to_uint8((sharpened.astype(np.float32) * smooth_sobel.astype(np.float32)) / 255.0)
summed = ensure_uint8(gray_rgb.astype(np.float32) + mask.astype(np.float32))
return gamma(summed, {"gamma": float(params.get("gamma", 0.5)), "c": 1.0})
def operation(id, label, chapter, slide_group, func, params=None, supports="both"):
return {
"id": id,
"label": label,
"chapter": chapter,
"slide_group": slide_group,
"params": params or {},
"supports": supports,
"func": func,
}
OPERATIONS = [
operation("crop", "Crop", CH_BASIC, "Workspace", crop, {"x": int_param(0, 0, 4000), "y": int_param(0, 0, 4000), "width": int_param(256, 1, 8000), "height": int_param(256, 1, 8000)}),
operation("identity", "Identity", CH3, "Point Processing", identity),
operation("negative", "Negative", CH3, "Point Processing", negative),
operation("log", "Log", CH3, "Point Processing", logarithmic, {"c": float_param(1.44, 0.1, 5, 0.05)}),
operation("inverse_log", "Inverse Log", CH3, "Point Processing", inverse_log, {"c": float_param(1.0, 0.1, 5, 0.05)}),
operation("gamma", "Power-Law / Gamma", CH3, "Point Processing", gamma, {"gamma": float_param(1.0, 0.1, 5, 0.05), "c": float_param(1.0, 0.1, 3, 0.05)}),
operation("threshold", "Thresholding", CH3, "Point Processing", threshold, {"level": int_param(128, 0, 255), "low": int_param(0, 0, 255), "high": int_param(255, 0, 255)}),
operation("contrast_stretch", "Contrast Stretching", CH3, "Piecewise Linear", contrast_stretch, {"low": int_param(30, 0, 254), "high": int_param(220, 1, 255)}),
operation("gray_slice", "Gray-Level Slicing", CH3, "Piecewise Linear", gray_slice, {"start": int_param(96, 0, 255), "end": int_param(160, 0, 255), "preserve_background": bool_param(True)}),
operation("bit_plane", "Bit-Plane Slicing", CH3, "Piecewise Linear", bit_plane, {"bit": int_param(7, 0, 7)}),
operation("histeq", "histeq()", CH3, "Histogram Processing", histeq, {"mode": select_param("intensity", ["intensity", "rgb", "grayscale"])}),
operation("hist_match", "Histogram Specification", CH3, "Histogram Processing", histogram_matching, {"target": select_param("uniform", ["uniform", "dark", "bright", "bimodal"])}),
operation("local_equalization", "Local Enhancement", CH3, "Histogram Processing", local_equalization, {"size": odd_param(7, 31)}),
operation("gaussian_noise", "Add Gaussian Noise", CH3, "Noise and Denoising", gaussian_noise, {"mean": float_param(0, -1, 1, 0.01), "variance": float_param(0.01, 0, 0.2, 0.005)}),
operation("salt_pepper_noise", "Add Salt & Pepper Noise", CH3, "Noise and Denoising", salt_pepper_noise, {"amount": float_param(0.03, 0, 0.5, 0.01), "salt_ratio": float_param(0.5, 0, 1, 0.05)}),
operation("speckle_noise", "Add Speckle Noise", CH3, "Noise and Denoising", speckle_noise, {"variance": float_param(0.04, 0, 0.3, 0.01)}),
operation("box_filter", "Box / Average Filter", CH3, "Smoothing Linear Filters", box_filter, {"size": odd_param(3, 35)}),
operation("weighted_average", "Weighted Average Filter", CH3, "Smoothing Linear Filters", weighted_average, {"size": odd_param(3, 35)}),
operation("gaussian_filter", "Gaussian fspecial Filter", CH3, "Smoothing Linear Filters", gaussian_filter, {"size": odd_param(3, 35), "variance": float_param(1.0, 0.01, 25, 0.1)}),
operation("median_filter", "Median Filter", CH3, "Order-Statistics Filters", median_filter, {"size": odd_param(3, 25)}),
operation("max_filter", "Max Filter", CH3, "Order-Statistics Filters", max_filter, {"size": odd_param(3, 25)}),
operation("min_filter", "Min Filter", CH3, "Order-Statistics Filters", min_filter, {"size": odd_param(3, 25)}),
operation("laplacian_slide", "Laplacian Masks", CH3, "Sharpening Spatial Filters", laplacian_slide, {"mask": select_param("cross", ["cross", "diagonal", "zero_sum_cross", "zero_sum_diagonal"]), "mode": select_param("sharpen", ["sharpen", "detail"])}),
operation("gradient_abs_sum", "Gradient abs(imfilter Gx)+abs(imfilter Gy)", CH3, "Gradient Operator", gradient_abs_sum, {"operator": select_param("sobel", ["sobel", "roberts"])}),
operation("sobel", "Sobel Magnitude", CH3, "Gradient Operator", sobel),
operation("roberts", "Roberts Magnitude", CH3, "Gradient Operator", roberts),
operation("high_boost", "High-Boost Filtering", CH3, "High-Boost Filtering", high_boost, {"amplification": float_param(1.5, 1, 6, 0.1), "size": odd_param(3, 35)}),
operation("bone_scan_workflow", "Bone Scan Workflow Preset", CH3, "Combining Spatial Enhancement Methods", bone_scan_workflow, {"gamma": float_param(0.5, 0.1, 2, 0.05)}),
operation("fft_spectrum", "FFT/DFT Spectrum View", CH4, "DFT and FFT", fft_spectrum, {"mode": select_param("log_magnitude", ["magnitude", "log_magnitude", "phase"])}),
operation("frequency_filter", "Ideal/Butterworth/Gaussian Frequency Filter", CH4, "Frequency Domain Filtering", frequency_filter, {"family": select_param("gaussian", ["ideal", "butterworth", "gaussian"]), "kind": select_param("lowpass", ["lowpass", "highpass"]), "cutoff": float_param(40, 1, 512, 1), "order": int_param(2, 1, 10), "output": select_param("image", ["image", "mask"])}),
operation("frequency_laplacian", "Laplacian in Frequency Domain", CH4, "Sharpening Highpass Filtering", frequency_laplacian),
operation("convolution", "Convolution Utility", CH4, "Convolution", convolution),
operation("correlation", "Correlation Utility", CH4, "Correlation", correlation),
operation("rgb_channel", "RGB Channel View", CH6, "RGB color model", rgb_channel, {"channel": select_param("r", ["r", "g", "b"])}),
operation("hsi_view", "HSI Component View", CH6, "HSI color model", hsi_view, {"component": select_param("i", ["h", "s", "i"])}),
operation("hsi_intensity_filter", "HSI Intensity Processing", CH6, "HSI color model", hsi_intensity_filter, {"method": select_param("smooth", ["smooth", "sharpen"]), "size": odd_param(3, 25)}),
operation("pseudo_color_slices", "Pseudocolor Intensity Slicing", CH6, "Pseudocolor Image Processing", pseudo_color_slices),
operation("gray_to_color_sinusoidal", "Gray-Level to Color Transform", CH6, "Gray level to color transformation", gray_to_color_sinusoidal, {"hue_frequency": float_param(1, 0.2, 4, 0.1), "saturation_frequency": float_param(0.5, 0.1, 4, 0.1), "intensity_frequency": float_param(0.25, 0.1, 4, 0.1)}),
]
OPERATION_MAP = {item["id"]: item for item in OPERATIONS}
def operation_metadata():
return [{key: value for key, value in item.items() if key != "func"} for item in OPERATIONS]
def apply_registered_operation(image, operation_id, params=None):
item = OPERATION_MAP.get(operation_id)
if item is None:
raise ProcessingError(f"Unsupported operation '{operation_id}'.")
return ensure_uint8(item["func"](ensure_uint8(image), params or {}))

View File

@@ -1,4 +1,4 @@
from .models import ImageSession, ProcessingJob
from .models import ImageSession, ImageState, ProcessingJob
def image_session_get(*, session_id):
@@ -7,3 +7,11 @@ def image_session_get(*, session_id):
def processing_job_get(*, job_id):
return ProcessingJob.objects.filter(id=job_id).first()
def image_state_get(*, state_id):
return ImageState.objects.select_related("session", "parent").filter(id=state_id).first()
def image_states_list(*, session_id):
return ImageState.objects.select_related("parent").filter(session_id=session_id).order_by("sequence", "created_at")

View File

@@ -1,10 +1,12 @@
import time
import numpy as np
from django.conf import settings
from django.utils import timezone
from .algorithms import ProcessingError, decode_image, histogram, process_image
from .models import ImageSession, ProcessingJob
from .algorithms import ProcessingError, average_images, decode_image, histogram, histogram_payload, normalize_to_uint8, process_image, verify_registration
from .models import ImageSession, ImageState, ProcessingJob
from .registry import apply_registered_operation
from .storage import load_image_array, payload_for_image, save_image_array
from .tasks import run_batch_job
@@ -25,19 +27,145 @@ def image_session_create(*, uploaded_file=None, image_base64=None):
original_histogram=hist,
expires_at=timezone.now() + timezone.timedelta(hours=settings.IMAGE_SESSION_TTL_HOURS),
)
state = image_state_create(
session=session,
parent=None,
image=image,
operation="upload",
params={},
label="S0 Upload",
prefix="state-upload",
)
payload = {
"session_id": str(session.id),
"active_state_id": str(state.id),
"width": session.width,
"height": session.height,
"channels": session.channels,
"color_mode": session.color_mode,
"original_histogram": hist,
"histogram": state.histogram,
"expires_at": session.expires_at.isoformat(),
"states": [image_state_payload(state=state, include_image=True)],
}
payload.update(payload_for_image(image, relative_path))
return payload
def image_state_create(*, session, parent, image, operation, params, label=None, prefix="state"):
relative_path = save_image_array(image, prefix)
sequence = session.states.count()
state = ImageState.objects.create(
session=session,
parent=parent,
sequence=sequence,
label=label or f"S{sequence} {operation}",
operation=operation,
params=params or {},
image=relative_path,
width=image.shape[1],
height=image.shape[0],
channels=image.shape[2] if image.ndim == 3 else 1,
color_mode="RGB" if image.ndim == 3 else "L",
histogram=histogram_payload(image),
)
return state
def image_state_payload(*, state, include_image=True):
payload = {
"state_id": str(state.id),
"session_id": str(state.session_id),
"parent_state_id": str(state.parent_id) if state.parent_id else None,
"sequence": state.sequence,
"label": state.label,
"operation": state.operation,
"params": state.params,
"width": state.width,
"height": state.height,
"channels": state.channels,
"color_mode": state.color_mode,
"histogram": state.histogram,
"created_at": state.created_at.isoformat(),
}
if include_image:
image = load_image_array(state.image)
payload.update(payload_for_image(image, state.image))
else:
payload["image_path"] = state.image
payload["image_url"] = f"{settings.MEDIA_URL}{state.image}"
return payload
def image_states_payload(*, states):
return [image_state_payload(state=state, include_image=True) for state in states]
def image_state_apply_operation(*, state, operation, params):
if state.session.expired:
raise ProcessingError("Image session has expired.")
source = load_image_array(state.image)
result = apply_registered_operation(source, operation, params or {})
new_state = image_state_create(
session=state.session,
parent=state,
image=result,
operation=operation,
params=params or {},
label=None,
prefix=f"state-{operation}",
)
return image_state_payload(state=new_state, include_image=True)
def combine_states(*, states, operation, params=None):
params = params or {}
if len(states) < 2:
raise ProcessingError("At least two states are required.")
session = states[0].session
if any(state.session_id != session.id for state in states):
raise ProcessingError("All states must belong to the same session.")
images = [load_image_array(state.image) for state in states]
verify_registration(images)
if operation == "average":
result = average_images(images)
elif operation == "add":
result = np_clip_sum(images)
elif operation == "subtract":
result = normalize_to_uint8(images[0].astype("float32") - images[1].astype("float32"))
elif operation == "dot_product":
result = normalize_to_uint8(np.prod([image.astype("float32") / 255.0 for image in images], axis=0))
elif operation == "and":
result = images[0].copy()
for image in images[1:]:
result = result & image
elif operation == "or":
result = images[0].copy()
for image in images[1:]:
result = result | image
else:
raise ProcessingError(f"Unsupported combine operation '{operation}'.")
new_state = image_state_create(
session=session,
parent=states[0],
image=result,
operation=f"combine_{operation}",
params={**params, "state_ids": [str(state.id) for state in states]},
label=None,
prefix=f"state-combine-{operation}",
)
return image_state_payload(state=new_state, include_image=True)
def np_clip_sum(images):
total = np.zeros_like(images[0], dtype="float32")
for image in images:
total += image.astype("float32")
return np.clip(total, 0, 255).astype("uint8")
def image_session_process(*, session, operation, params):
if session.expired:
raise ProcessingError("Image session has expired.")

View File

@@ -48,6 +48,8 @@ def cleanup_expired_sessions():
for session in expired:
delete_relative_file(session.original_image)
delete_relative_file(session.processed_image)
for state in session.states.all():
delete_relative_file(state.image)
expired.delete()
old_jobs = ProcessingJob.objects.filter(created_at__lt=timezone.now() - timezone.timedelta(hours=24))

View File

@@ -1,11 +1,27 @@
from django.urls import path
from .views import BatchView, HealthView, ImageUploadView, JobDetailView, ProcessView
from .views import (
BatchView,
HealthView,
ImageUploadView,
JobDetailView,
OperationsView,
ProcessView,
SessionStatesView,
StateCombineView,
StateHistogramView,
StateOperationView,
)
urlpatterns = [
path("health/", HealthView.as_view(), name="health"),
path("images/", ImageUploadView.as_view(), name="image-upload"),
path("operations/", OperationsView.as_view(), name="operations"),
path("sessions/<uuid:session_id>/states/", SessionStatesView.as_view(), name="session-states"),
path("states/<uuid:state_id>/operations/", StateOperationView.as_view(), name="state-operation"),
path("states/<uuid:state_id>/histogram/", StateHistogramView.as_view(), name="state-histogram"),
path("states/combine/", StateCombineView.as_view(), name="state-combine"),
path("process/", ProcessView.as_view(), name="process"),
path("batch/", BatchView.as_view(), name="batch"),
path("jobs/<uuid:job_id>/", JobDetailView.as_view(), name="job-detail"),

View File

@@ -3,8 +3,18 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from .algorithms import ProcessingError
from .selectors import image_session_get, processing_job_get
from .services import batch_job_create, image_session_create, image_session_process, processing_job_payload
from .registry import operation_metadata
from .selectors import image_session_get, image_state_get, image_states_list, processing_job_get
from .services import (
batch_job_create,
combine_states,
image_session_create,
image_session_process,
image_state_apply_operation,
image_state_payload,
image_states_payload,
processing_job_payload,
)
def error_response(message, code=status.HTTP_400_BAD_REQUEST):
@@ -59,6 +69,73 @@ class ProcessView(APIView):
return error_response(str(exc), code)
class OperationsView(APIView):
def get(self, request):
return Response({"operations": operation_metadata()})
class SessionStatesView(APIView):
def get(self, request, session_id):
session = image_session_get(session_id=session_id)
if session is None:
return error_response("Image session does not exist.", status.HTTP_404_NOT_FOUND)
return Response({"session_id": str(session.id), "states": image_states_payload(states=image_states_list(session_id=session.id))})
class StateOperationView(APIView):
class InputSerializer(serializers.Serializer):
operation = serializers.CharField()
params = serializers.DictField(required=False, default=dict)
def post(self, request, state_id):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
state = image_state_get(state_id=state_id)
if state is None:
return error_response("Image state does not exist.", status.HTTP_404_NOT_FOUND)
try:
payload = image_state_apply_operation(
state=state,
operation=serializer.validated_data["operation"],
params=serializer.validated_data.get("params", {}),
)
return Response(payload, status=status.HTTP_201_CREATED)
except ProcessingError as exc:
code = status.HTTP_410_GONE if str(exc) == "Image session has expired." else status.HTTP_400_BAD_REQUEST
return error_response(str(exc), code)
class StateCombineView(APIView):
class InputSerializer(serializers.Serializer):
operation = serializers.ChoiceField(choices=["add", "subtract", "dot_product", "average", "and", "or"])
state_ids = serializers.ListField(child=serializers.UUIDField(), min_length=2)
params = serializers.DictField(required=False, default=dict)
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
states = [image_state_get(state_id=state_id) for state_id in serializer.validated_data["state_ids"]]
if any(state is None for state in states):
return error_response("One or more image states do not exist.", status.HTTP_404_NOT_FOUND)
try:
payload = combine_states(
states=states,
operation=serializer.validated_data["operation"],
params=serializer.validated_data.get("params", {}),
)
return Response(payload, status=status.HTTP_201_CREATED)
except ProcessingError as exc:
return error_response(str(exc))
class StateHistogramView(APIView):
def get(self, request, state_id):
state = image_state_get(state_id=state_id)
if state is None:
return error_response("Image state does not exist.", status.HTTP_404_NOT_FOUND)
return Response({"state_id": str(state.id), "histogram": state.histogram, "state": image_state_payload(state=state, include_image=False)})
class BatchView(APIView):
class InputSerializer(serializers.Serializer):
operation = serializers.ChoiceField(choices=["average", "subtract"])