diff --git a/backend/processing/algorithms.py b/backend/processing/algorithms.py index 0969ed7..6b32729 100644 --- a/backend/processing/algorithms.py +++ b/backend/processing/algorithms.py @@ -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() diff --git a/backend/processing/migrations/0002_imagestate.py b/backend/processing/migrations/0002_imagestate.py new file mode 100644 index 0000000..05c18ec --- /dev/null +++ b/backend/processing/migrations/0002_imagestate.py @@ -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"], + }, + ), + ] diff --git a/backend/processing/models.py b/backend/processing/models.py index de22d07..94180eb 100644 --- a/backend/processing/models.py +++ b/backend/processing/models.py @@ -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" diff --git a/backend/processing/registry.py b/backend/processing/registry.py new file mode 100644 index 0000000..eb4e8b0 --- /dev/null +++ b/backend/processing/registry.py @@ -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 {})) diff --git a/backend/processing/selectors.py b/backend/processing/selectors.py index a4b4ce0..cd0ed1c 100644 --- a/backend/processing/selectors.py +++ b/backend/processing/selectors.py @@ -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") diff --git a/backend/processing/services.py b/backend/processing/services.py index 5206b51..1f9aef7 100644 --- a/backend/processing/services.py +++ b/backend/processing/services.py @@ -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.") diff --git a/backend/processing/tasks.py b/backend/processing/tasks.py index f2474d8..c8239fa 100644 --- a/backend/processing/tasks.py +++ b/backend/processing/tasks.py @@ -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)) diff --git a/backend/processing/urls.py b/backend/processing/urls.py index 43e61fb..c8a96a6 100644 --- a/backend/processing/urls.py +++ b/backend/processing/urls.py @@ -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//states/", SessionStatesView.as_view(), name="session-states"), + path("states//operations/", StateOperationView.as_view(), name="state-operation"), + path("states//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//", JobDetailView.as_view(), name="job-detail"), diff --git a/backend/processing/views.py b/backend/processing/views.py index 2aa9504..f26ff4b 100644 --- a/backend/processing/views.py +++ b/backend/processing/views.py @@ -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"]) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7f3a374..ee7ef90 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,22 +1,50 @@ import { useEffect, useMemo, useState } from "react"; -import CanvasPane from "./components/CanvasPane.jsx"; +import CanvasPane, { CanvasThumbnail } from "./components/CanvasPane.jsx"; import Controls from "./components/Controls.jsx"; import HistogramPanel from "./components/HistogramPanel.jsx"; -import { createBatch, getJob, processImage, uploadImage } from "./lib/api.js"; -import { useDebouncedEffect } from "./lib/debounce.js"; +import { applyStateOperation, combineStates, getOperations, listStates, uploadImage } from "./lib/api.js"; + +function defaultCropParams(state) { + return { + x: 0, + y: 0, + width: state?.width || 256, + height: state?.height || 256 + }; +} export default function App() { const [session, setSession] = useState(null); - const [processed, setProcessed] = useState(null); - const [batchSessions, setBatchSessions] = useState([]); - const [operation, setOperation] = useState("gamma"); - const [params, setParams] = useState({ gamma: 1 }); + const [states, setStates] = useState([]); + const [activeState, setActiveState] = useState(null); + const [operations, setOperations] = useState([]); + const [selectedOperation, setSelectedOperation] = useState(""); + const [params, setParams] = useState({}); + const [selectedStateIds, setSelectedStateIds] = useState([]); const [status, setStatus] = useState("Upload an image to begin."); const [busy, setBusy] = useState(false); const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }); - const originalHistogram = session?.original_histogram; - const processedHistogram = processed?.processed_histogram || processed?.result_histogram; + useEffect(() => { + getOperations() + .then((payload) => { + setOperations(payload.operations || []); + const first = payload.operations?.find((operation) => operation.id === "crop") || payload.operations?.[0]; + if (first) { + setSelectedOperation(first.id); + setParams(Object.fromEntries(Object.entries(first.params || {}).map(([key, schema]) => [key, schema.default]))); + } + }) + .catch((error) => setStatus(error.message)); + }, []); + + async function refreshStates(sessionId, nextActiveId = null) { + const payload = await listStates(sessionId); + setStates(payload.states || []); + const nextActive = payload.states?.find((state) => state.state_id === nextActiveId) || payload.states?.at(-1) || null; + setActiveState(nextActive); + return nextActive; + } async function handleUpload(file) { if (!file) return; @@ -25,10 +53,13 @@ export default function App() { try { const payload = await uploadImage(file); setSession(payload); - setProcessed(null); - setBatchSessions([payload.session_id]); + const initialStates = payload.states || []; + setStates(initialStates); + setActiveState(initialStates[0] || null); + setSelectedStateIds(initialStates[0] ? [initialStates[0].state_id] : []); setTransform({ x: 0, y: 0, scale: 1 }); - setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded.`); + setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded as S0.`); + if (selectedOperation === "crop") setParams(defaultCropParams(initialStates[0])); } catch (error) { setStatus(error.message); } finally { @@ -36,14 +67,24 @@ export default function App() { } } - async function handleBatchUpload(file) { - if (!file) return; + function handleSelectOperation(operationId, nextParams) { + setSelectedOperation(operationId); + setParams(operationId === "crop" ? { ...nextParams, ...defaultCropParams(activeState) } : nextParams); + } + + function handleParamChange(key, value) { + setParams((current) => ({ ...current, [key]: value })); + } + + async function handleApply() { + if (!activeState || !selectedOperation) return; setBusy(true); - setStatus("Uploading batch image..."); + setStatus(`Applying ${selectedOperation} to ${activeState.label}...`); try { - const payload = await uploadImage(file); - setBatchSessions((current) => [...current, payload.session_id]); - setStatus("Batch image added."); + const state = await applyStateOperation(activeState.state_id, selectedOperation, params); + await refreshStates(state.session_id, state.state_id); + setSelectedStateIds([state.state_id]); + setStatus(`${state.label} created.`); } catch (error) { setStatus(error.message); } finally { @@ -51,14 +92,15 @@ export default function App() { } } - async function runBatch(kind) { + async function handleCombine(kind) { + if (selectedStateIds.length < 2) return; setBusy(true); - setStatus(`Starting ${kind} job...`); + setStatus(`Combining ${selectedStateIds.length} states using ${kind}...`); try { - const job = await createBatch(kind, batchSessions); - const result = await pollJob(job.job_id); - setProcessed(result); - setStatus(`${kind} complete.`); + const state = await combineStates(kind, selectedStateIds); + await refreshStates(state.session_id, state.state_id); + setSelectedStateIds([state.state_id]); + setStatus(`${state.label} created.`); } catch (error) { setStatus(error.message); } finally { @@ -66,86 +108,53 @@ export default function App() { } } - async function pollJob(jobId) { - for (let attempt = 0; attempt < 80; attempt += 1) { - const job = await getJob(jobId); - setStatus(`Job ${job.status}: ${job.progress}%`); - if (job.status === "complete") return job; - if (job.status === "failed") throw new Error(job.error || "Batch job failed"); - await new Promise((resolve) => window.setTimeout(resolve, 1000)); - } - throw new Error("Batch job timed out."); + function toggleCombineState(stateId) { + setSelectedStateIds((current) => current.includes(stateId) ? current.filter((id) => id !== stateId) : [...current, stateId]); } - useDebouncedEffect( - () => { - if (!session?.session_id || !operation) return; - let cancelled = false; - async function run() { - setBusy(true); - setStatus(`Processing ${operation}...`); - try { - const payload = await processImage(session.session_id, operation, params); - if (!cancelled) { - setProcessed(payload); - setStatus(`${operation} complete in ${payload.elapsed_ms} ms.`); - } - } catch (error) { - if (!cancelled) setStatus(error.message); - } finally { - if (!cancelled) setBusy(false); - } - } - run(); - return () => { - cancelled = true; - }; - }, - [session?.session_id, operation, JSON.stringify(params)], - 300 - ); - - const processedImage = processed?.image_data || session?.image_data; - const originalImage = session?.image_data; - + const originalState = states[0] || null; const viewportTitle = useMemo(() => { - if (!session) return "No image"; - return `${session.width} x ${session.height} ${session.color_mode}`; - }, [session]); + if (!activeState) return "No active state"; + return `${activeState.label} ยท ${activeState.width} x ${activeState.height} ${activeState.color_mode}`; + }, [activeState]); return (
{ - setOperation(nextOperation); - setParams(nextParams); - }} - onParamChange={(key, value) => setParams((current) => ({ ...current, [key]: value }))} - onUpload={handleUpload} - onBatchUpload={handleBatchUpload} - batchCount={batchSessions.length} - onBatchRun={runBatch} - disabled={!session || busy} + states={states} + activeState={activeState} + selectedStateIds={selectedStateIds} busy={busy} + onUpload={handleUpload} + onSelectOperation={handleSelectOperation} + onParamChange={handleParamChange} + onApply={handleApply} + onSelectState={(state) => { + setActiveState(state); + if (selectedOperation === "crop") setParams(defaultCropParams(state)); + }} + onToggleCombineState={toggleCombineState} + onCombine={handleCombine} />
-

Professional Dark Studio

+

Professor Slide Workspace

{viewportTitle}

{busy ? "Working..." : status}
-
- - +
+ +
- +
); diff --git a/frontend/src/App.real-render.test.jsx b/frontend/src/App.real-render.test.jsx index 1ee3abd..d54bc3b 100644 --- a/frontend/src/App.real-render.test.jsx +++ b/frontend/src/App.real-render.test.jsx @@ -1,9 +1,17 @@ import { render, screen } from "@testing-library/react"; import App from "./App.jsx"; +vi.mock("./lib/api.js", () => ({ + getOperations: () => Promise.resolve({ operations: [] }), + listStates: () => Promise.resolve({ states: [] }), + uploadImage: vi.fn(), + applyStateOperation: vi.fn(), + combineStates: vi.fn() +})); + describe("App real render", () => { it("mounts without mocking third-party components", () => { render(); - expect(screen.getByText("Spatial Image Enhancer Pro")).toBeInTheDocument(); + expect(screen.getByText("Academic Image Processing Workspace")).toBeInTheDocument(); }); }); diff --git a/frontend/src/App.test.jsx b/frontend/src/App.test.jsx index edd2157..eb840db 100644 --- a/frontend/src/App.test.jsx +++ b/frontend/src/App.test.jsx @@ -5,11 +5,19 @@ vi.mock("react-quick-pinch-zoom", () => ({ default: ({ children }) =>
{children}
})); +vi.mock("./lib/api.js", () => ({ + getOperations: () => Promise.resolve({ operations: [] }), + listStates: () => Promise.resolve({ states: [] }), + uploadImage: vi.fn(), + applyStateOperation: vi.fn(), + combineStates: vi.fn() +})); + describe("App", () => { - it("renders the processing studio immediately", () => { + it("renders the academic workspace immediately", () => { render(); - expect(screen.getByText("Spatial Image Enhancer Pro")).toBeInTheDocument(); - expect(screen.getAllByText("Original").length).toBeGreaterThan(0); - expect(screen.getAllByText("Processed").length).toBeGreaterThan(0); + expect(screen.getByText("Academic Image Processing Workspace")).toBeInTheDocument(); + expect(screen.getByText("Image States")).toBeInTheDocument(); + expect(screen.getByText("Combine Selected States")).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/CanvasPane.jsx b/frontend/src/components/CanvasPane.jsx index 6819d61..36eae98 100644 --- a/frontend/src/components/CanvasPane.jsx +++ b/frontend/src/components/CanvasPane.jsx @@ -47,3 +47,22 @@ export default function CanvasPane({ title, imageData, histogram, transform, onT ); } + +export function CanvasThumbnail({ title, imageData }) { + const canvasRef = useRef(null); + + useEffect(() => { + drawToCanvas(canvasRef.current, imageData); + }, [imageData]); + + if (!imageData) return null; + + return ( +
+
{title}
+
+ +
+
+ ); +} diff --git a/frontend/src/components/Controls.jsx b/frontend/src/components/Controls.jsx index 4bc17f9..447c181 100644 --- a/frontend/src/components/Controls.jsx +++ b/frontend/src/components/Controls.jsx @@ -1,93 +1,111 @@ -import { Activity, Aperture, Blend, Layers, SlidersHorizontal, Upload } from "lucide-react"; +import { Combine, Crop, Layers, SlidersHorizontal, Upload } from "lucide-react"; -const groups = [ - { - title: "Intensity", - icon: SlidersHorizontal, - operations: [ - { id: "negative", label: "Negative", params: [] }, - { id: "log", label: "Log", params: [{ key: "c", label: "c", min: 0.1, max: 3, step: 0.05, default: 1.44 }] }, - { id: "gamma", label: "Gamma", params: [{ key: "gamma", label: "Gamma", min: 0.1, max: 4, step: 0.05, default: 1 }] }, - { - id: "contrast_stretch", - label: "Contrast Stretch", - params: [ - { key: "low", label: "Low", min: 0, max: 254, step: 1, default: 30 }, - { key: "high", label: "High", min: 1, max: 255, step: 1, default: 220 } - ] - }, - { - id: "gray_slice", - label: "Gray Slice", - params: [ - { key: "start", label: "Start", min: 0, max: 255, step: 1, default: 96 }, - { key: "end", label: "End", min: 0, max: 255, step: 1, default: 160 } - ] - }, - { id: "bit_plane", label: "Bit Plane", params: [{ key: "bit", label: "Bit", min: 0, max: 7, step: 1, default: 7 }] } - ] - }, - { - title: "Histogram", - icon: Activity, - operations: [ - { id: "hist_equalization", label: "Global Equalization", params: [] }, - { id: "hist_match", label: "Match Uniform", params: [] }, - { id: "local_equalization", label: "Local Equalization", params: [{ key: "size", label: "Window", min: 3, max: 31, step: 2, default: 7 }] } - ] - }, - { - title: "Spatial Filters", - icon: Aperture, - operations: [ - { id: "box_filter", label: "Box", params: [{ key: "size", label: "Size", min: 3, max: 35, step: 2, default: 3 }] }, - { id: "weighted_average", label: "Weighted Avg", params: [{ key: "size", label: "Size", min: 3, max: 35, step: 2, default: 3 }] }, - { id: "median_filter", label: "Median", params: [{ key: "size", label: "Size", min: 3, max: 15, step: 2, default: 3 }] }, - { id: "laplacian", label: "Laplacian", params: [] }, - { - id: "high_boost", - label: "High Boost", - params: [ - { key: "amplification", label: "A", min: 1, max: 5, step: 0.1, default: 1.5 }, - { key: "size", label: "Size", min: 3, max: 35, step: 2, default: 3 } - ] - }, - { id: "sobel", label: "Sobel", params: [] }, - { id: "roberts", label: "Roberts", params: [] } - ] - }, - { - title: "Color", - icon: Blend, - operations: [ - { id: "pseudo_color_slices", label: "Intensity Slices", params: [] }, - { id: "gray_to_color_sinusoidal", label: "HSI Sinusoids", params: [{ key: "hue_frequency", label: "Hue Freq", min: 0.2, max: 4, step: 0.1, default: 1 }] }, - { id: "hsi_intensity_filter", label: "HSI Smooth I", params: [{ key: "size", label: "Size", min: 3, max: 15, step: 2, default: 3 }] } - ] +function defaultParams(operation) { + return Object.fromEntries( + Object.entries(operation?.params || {}).map(([key, schema]) => [key, schema.default]) + ); +} + +function groupOperations(operations) { + return operations.reduce((acc, operation) => { + acc[operation.chapter] ||= {}; + acc[operation.chapter][operation.slide_group] ||= []; + acc[operation.chapter][operation.slide_group].push(operation); + return acc; + }, {}); +} + +function ParamControl({ name, schema, value, onChange }) { + if (schema.type === "select") { + return ( + + ); } -]; - -function initialParams(operation) { - return Object.fromEntries(operation.params.map((param) => [param.key, param.default])); + if (schema.type === "bool") { + return ( + + ); + } + return ( + + ); } export default function Controls({ - selected, + operations, + selectedOperation, params, - onOperationChange, - onParamChange, + states, + activeState, + selectedStateIds, + busy, onUpload, - onBatchUpload, - batchCount, - onBatchRun, - disabled, - busy + onSelectOperation, + onParamChange, + onApply, + onSelectState, + onToggleCombineState, + onCombine, }) { + const grouped = groupOperations(operations); + const operation = operations.find((item) => item.id === selectedOperation); + + function renderParameterDrawer(item) { + if (selectedOperation !== item.id) return null; + return ( +
+
+ + Parameters +
+ {Object.entries(item.params || {}).map(([name, schema]) => ( + + ))} + {Object.keys(item.params || {}).length === 0 ?

No parameters.

: null} + +
+ ); + } + return ( -