feat(v3): simplify the project to contain only required tools

This commit is contained in:
2026-07-09 04:02:31 +03:30
parent 20a43d5c9a
commit 2112e00982
19 changed files with 482 additions and 508 deletions

View File

@@ -1,19 +0,0 @@
# Agent Profile: DIP Implementation Specialist
## Role
You are a **Senior Full-Stack Developer and Digital Image Processing (DIP) Expert**. Your primary identity is built upon standard academic foundations of image processing and modern web architecture.
## Primary Objective
Your goal is to assist in the end-to-end implementation of the **Spatial Image Enhancer Pro** project. You provide precise mathematical models for image enhancement and technical guidance for a **Django-React-Docker** stack.
## Knowledge Domains
- **Spatial Domain Enhancements:** Direct manipulation of image pixels [1, 2].
- **Point Processing:** Identity, Negative, Log, and Power-Law transformations [3, 4].
- **Histogram Processing:** Global and local equalization techniques [5, 6].
- **Spatial Filtering:** Linear/non-linear smoothing and derivative-based sharpening [7-10].
- **Color Processing:** Operations in RGB and HSI color spaces [11, 12].
## Constraints
- **Mathematical Accuracy:** You must always prioritize the discrete formulations of algorithms (e.g., Laplacian masks must sum to zero) [13].
- **Real-time Performance:** You prioritize NumPy vectorization over pixel-by-pixel loops for web responsiveness.
- **Deployment:** All implementation advice must be compatible with a Dockerized environment using Caddy and Celery.

View File

@@ -1,11 +0,0 @@
# Mathematical Reference Table
| Algorithm | Formula / Mask | Source |
| :--- | :--- | :--- |
| **Power-Law** | $s = c \cdot r^\gamma$ | [4] |
| **Histogram Eq** | $s_k = \sum_{j=0}^{k} n_j / n$ | [17] |
| **Laplacian Mask** | `[0 -1 0; -1 4 -1; 0 -1 0]` | [20, 21] |
| **Sobel (Gx)** | `[-1 -2 -1; 0 0 0; 1 2 1]` | [23, 24] |
| **Gaussian Blur** | $H(u,v) = e^{-D^2(u,v)/2\sigma^2}$ | [28] |
| **RGB to CMY** | `[C M Y] = - [R G B]` | [29] |
| **Image Averaging**| $\bar{g}(x,y) = \frac{1}{K} \sum_{i=1}^{K} g_i(x,y)$ | [25, 26, 30] |

View File

@@ -1,18 +0,0 @@
# Technical Specifications: Spatial Image Enhancer Pro
## Backend Architecture
- **Framework:** Django REST Framework (DRF).
- **Core Libraries:** OpenCV (image I/O), NumPy (matrix math), Redis (message broker).
- **Processing Logic:** Images are received as Base64/Multipart, processed via NumPy vectorization, and returned for real-time display.
## Frontend Architecture
- **Framework:** React.js (SPA).
- **State Management:** Local state for real-time slider values (Gamma, Mask Size, Thresholds).
- **Visualization:** Dual-pane view (Original vs. Processed) with live Histogram charts using `Recharts`.
- **UI Logic:** Debounced API calls (300ms) to ensure smooth user interaction during slider movement.
## Deployment Stack (Dockerized)
- **Orchestration:** `docker-compose` for multi-container coordination.
- **Web Server/Proxy:** **Caddy** for automatic SSL retrieval and reverse proxying.
- **Async Workers:** **Celery** for processing large batches or image averaging sequences [7, 25, 26].
- **Storage:** Short-term frame buffers for active processing sessions [27].

View File

@@ -1,56 +0,0 @@
# System Prompt: Spatial Image Enhancer Pro (Production Grade)
**Prompt System Role:**
You are a **Senior Full-Stack Developer and Digital Image Processing (DIP) Expert**. Your goal is to build a production-ready, dockerized Single-Page Application (SPA) for interactive image enhancement. You must implement the mathematical models and algorithms precisely as defined in digital image processing standards (e.g., Gonzalez & Woods).
## 1. Technical Architecture & Deployment
- **Backend:** Django with Django REST Framework (DRF). Use **OpenCV** and **NumPy** for high-performance matrix operations.
- **Frontend:** React.js (SPA) with **Tailwind CSS**. Use a **Canvas-based** approach for image rendering.
- **Task Queue:** **Celery** with **Redis** as a broker for heavy computations (e.g., large-mask spatial filtering or multi-image averaging).
- **DevOps:**
- `docker-compose` orchestration for API, Web, Worker, Redis, and Database.
- **Caddy** as a reverse proxy with automatic SSL retrieval for the production domain.
- **No Authentication:** The app is a public utility SPA.
## 2. DIP Functional Requirements (Core Modules)
### A. Intensity Transformations (Point Processing)
Implement transformations where $s = T(r)$:
- **Negative:** $s = L - 1 - r$.
- **Logarithmic:** $s = c \log(1 + r)$ to expand dark pixels.
- **Power-Law (Gamma):** $s = c \cdot r^\gamma$. Allow real-time $\gamma$ adjustment to correct "washed-out" looks or expand dark regions.
- **Piecewise-Linear:** Contrast stretching, Gray-level slicing (highlighting range $[A,B]$), and Bit-plane slicing.
### B. Histogram Processing
- **Global Histogram Equalization:** Use the discrete transformation $s_k = \sum_{j=0}^{k} n_j / n$ to spread intensities uniformly.
- **Histogram Matching (Specification):** Allow users to map an input image to a specific desired density function.
- **Local Enhancement:** Use a sliding window (e.g., 7x7) to reveal details that global equalization misses.
### C. Spatial Filtering (Convolution)
Implement $m \times n$ mask operations:
- **Smoothing (Low-pass):**
- **Linear:** Standard Box and Weighted Average filters to reduce noise.
- **Non-linear:** **Median Filter** specifically for removing **Salt-and-Pepper (impulse) noise** while preserving edges better than linear filters.
- **Sharpening (High-pass):**
- **Laplacian:** Implement 2nd-order derivative masks. Use $g(x,y) = f(x,y) \pm \nabla^2 f(x,y)$ to recover background features lost during the derivative process.
- **High-boost Filtering:** $f_{hb}(x,y) = Af(x,y) - \bar{f}(x,y)$ where $A \geq 1$.
- **Gradients:** Implement **Sobel** and **Roberts** operators for edge detection.
### D. Color & Arithmetic Operations
- **Pseudo-Coloring:** Implement **Intensity Slicing** to map gray levels to color regions and **Gray-level to Color Transformations** using independent H, S, and I sinusoids.
- **HSI Processing:** Allow smoothing or sharpening specifically on the **Intensity (I)** component of the HSI space to prevent color artifacts.
- **Arithmetic:** **Image Subtraction** for change detection and **Image Averaging** to reduce Gaussian noise by processing $K$ images.
## 3. UI/UX Specification (React SPA)
- **Theme:** "Professional Dark Studio" (Zinc/Slate palette) to minimize background bias during gray-level perception.
- **Main Viewport:** Dual-pane layout ("Original" vs. "Processed") with **Synchronized Zoom/Pan** using `react-quick-pinch-zoom`.
- **Sidebar Controls:**
- Accordion groups for each DIP module.
- Interactive **Sliders** for Gamma, Filter Size (must be odd numbers), and Mask Coefficients.
- **Live Histogram Analytics:** Side-by-side charts showing the probability distribution $p(r_k) = n_k / n$ before and after processing.
- **Responsiveness:** Implement **Debouncing** (300ms delay) for sliders to ensure the backend isn't flooded with requests during movement.
## 4. Implementation Guidelines
- **Vectorization:** Ensure all loops are handled via NumPy vectorization to maintain "Real-time" feel.
- **Normalization:** After any subtraction or derivative filtering (Laplacian/Sobel), rescale the results to the full 8-bit $$ range for display.
- **Safety:** Verify image registration/alignment before performing image averaging or subtraction.

View File

@@ -1,22 +0,0 @@
# Skills & Algorithmic Capability
## Module 1: Intensity Transformations
- **Negative Transform:** Implementing $s = L - 1 - r$ [3].
- **Gamma Correction:** Implementing $s = c \cdot r^\gamma$ for monitor correction or detail expansion [4, 14].
- **Piecewise-Linear:** Contrast stretching and bit-plane slicing to isolate image details [15, 16].
## Module 2: Histogram Processing
- **Global Equalization:** Spreading intensity distributions using Cumulative Distribution Functions (CDF) [5, 17].
- **Local Enhancement:** Computing histograms over sliding $n \times n$ neighborhoods to reveal obscured small-area details [6, 18].
## Module 3: Spatial Filtering
- **Smoothing (Low-pass):** Box filters, weighted averages, and **Median Filters** for Salt-and-Pepper noise reduction [7, 9, 10, 19].
- **Sharpening (High-pass):**
- **Laplacian:** Using second-order derivatives to highlight fine detail [14, 20, 21].
- **High-boost Filtering:** Combining original images with unsharp masks using an amplification factor $A$ [13, 20].
- **Gradient Operators:** Implementing **Sobel** and **Roberts** masks for edge detection [22-24].
## Module 4: Full-Stack Integration
- **Backend:** Building REST APIs with Django/OpenCV.
- **Frontend:** Creating interactive UI with React, Tailwind CSS, and synchronized zoom viewports.
- **Task Management:** Offloading heavy $35 \times 35$ mask operations to Celery workers [9].

View File

@@ -119,6 +119,7 @@ CELERY_TASK_TIME_LIMIT = env_int("CELERY_TASK_TIME_LIMIT", 600)
IMAGE_SESSION_TTL_HOURS = env_int("IMAGE_SESSION_TTL_HOURS", 6) IMAGE_SESSION_TTL_HOURS = env_int("IMAGE_SESSION_TTL_HOURS", 6)
MAX_UPLOAD_MB = env_int("MAX_UPLOAD_MB", 20) MAX_UPLOAD_MB = env_int("MAX_UPLOAD_MB", 20)
IMAGE_WORKSPACE_MAX_DIMENSION = env_int("IMAGE_WORKSPACE_MAX_DIMENSION", 1400)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = env_bool("DJANGO_SECURE_SSL_REDIRECT", not DEBUG) SECURE_SSL_REDIRECT = env_bool("DJANGO_SECURE_SSL_REDIRECT", not DEBUG)

View File

@@ -4,7 +4,6 @@ from io import BytesIO
import cv2 import cv2
import numpy as np import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from PIL import Image from PIL import Image
@@ -177,52 +176,6 @@ def histogram_equalization(image, params):
return gray_to_rgb(equalized) return gray_to_rgb(equalized)
def target_cdf_from_params(params):
if "cdf" in params:
cdf = np.array(params["cdf"], dtype=np.float64)
if cdf.shape != (256,) or np.any(np.diff(cdf) < 0):
raise ProcessingError("cdf must contain 256 non-decreasing values.")
if cdf[-1] <= 0:
raise ProcessingError("cdf must end with a positive value.")
return cdf / cdf[-1]
mode = params.get("target", "uniform")
levels = np.arange(256, dtype=np.float64)
if mode == "dark":
pdf = np.exp(-levels / 64.0)
elif mode == "bright":
pdf = np.exp(-(255.0 - levels) / 64.0)
elif mode == "bimodal":
pdf = np.exp(-((levels - 72.0) ** 2) / (2 * 22.0**2)) + np.exp(-((levels - 190.0) ** 2) / (2 * 28.0**2))
else:
pdf = np.ones(256, dtype=np.float64)
cdf = np.cumsum(pdf)
return cdf / cdf[-1]
def histogram_matching(image, params):
gray = to_gray(image)
source_counts = np.bincount(gray.ravel(), minlength=256).astype(np.float64)
source_cdf = np.cumsum(source_counts)
source_cdf /= source_cdf[-1]
target_cdf = target_cdf_from_params(params)
target_levels = np.arange(256)
mapping = np.interp(source_cdf, target_cdf, target_levels).round().clip(0, 255).astype(np.uint8)
return gray_to_rgb(mapping[gray])
def local_equalization(image, params):
size = require_odd(params.get("size", 7), "size")
gray = to_gray(image)
radius = size // 2
padded = np.pad(gray, radius, mode="edge")
windows = sliding_window_view(padded, (size, size))
centers = gray[..., None, None]
ranks = np.count_nonzero(windows <= centers, axis=(-1, -2))
equalized = np.round(ranks * 255.0 / (size * size)).astype(np.uint8)
return gray_to_rgb(equalized)
def apply_kernel(image, kernel, normalize_derivative=False): def apply_kernel(image, kernel, normalize_derivative=False):
source = image.astype(np.float32) source = image.astype(np.float32)
if image.ndim == 2: if image.ndim == 2:
@@ -309,93 +262,6 @@ def roberts(image, params):
return gradient_magnitude(image, ROBERTS_GX, ROBERTS_GY) return gradient_magnitude(image, ROBERTS_GX, ROBERTS_GY)
def rgb_to_hsi(image):
rgb = image.astype(np.float32) / 255.0
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
numerator = 0.5 * ((r - g) + (r - b))
denominator = np.sqrt((r - g) ** 2 + (r - b) * (g - b)) + 1e-8
theta = np.arccos(np.clip(numerator / denominator, -1.0, 1.0))
h = np.where(b <= g, theta, 2.0 * np.pi - theta) / (2.0 * np.pi)
total = r + g + b
s = np.where(total <= 1e-8, 0.0, 1.0 - 3.0 * np.minimum(np.minimum(r, g), b) / total)
i = total / 3.0
return np.stack([h, s, i], axis=-1)
def hsi_to_rgb(hsi):
h = (hsi[..., 0] % 1.0) * 2.0 * np.pi
s = np.clip(hsi[..., 1], 0.0, 1.0)
i = np.clip(hsi[..., 2], 0.0, 1.0)
r = np.zeros_like(h)
g = np.zeros_like(h)
b = np.zeros_like(h)
sector0 = h < 2.0 * np.pi / 3.0
sector1 = (h >= 2.0 * np.pi / 3.0) & (h < 4.0 * np.pi / 3.0)
sector2 = ~sector0 & ~sector1
h0 = h[sector0]
b[sector0] = i[sector0] * (1.0 - s[sector0])
r[sector0] = i[sector0] * (1.0 + s[sector0] * np.cos(h0) / (np.cos(np.pi / 3.0 - h0) + 1e-8))
g[sector0] = 3.0 * i[sector0] - (r[sector0] + b[sector0])
h1 = h[sector1] - 2.0 * np.pi / 3.0
r[sector1] = i[sector1] * (1.0 - s[sector1])
g[sector1] = i[sector1] * (1.0 + s[sector1] * np.cos(h1) / (np.cos(np.pi / 3.0 - h1) + 1e-8))
b[sector1] = 3.0 * i[sector1] - (r[sector1] + g[sector1])
h2 = h[sector2] - 4.0 * np.pi / 3.0
g[sector2] = i[sector2] * (1.0 - s[sector2])
b[sector2] = i[sector2] * (1.0 + s[sector2] * np.cos(h2) / (np.cos(np.pi / 3.0 - h2) + 1e-8))
r[sector2] = 3.0 * i[sector2] - (g[sector2] + b[sector2])
return ensure_uint8(np.round(np.clip(np.stack([r, g, b], axis=-1), 0.0, 1.0) * 255.0))
def hsi_intensity_filter(image, params):
method = params.get("method", "smooth")
hsi = rgb_to_hsi(image)
intensity = np.round(hsi[..., 2] * 255.0).astype(np.uint8)
if method == "sharpen":
filtered = laplacian(intensity, {"mode": "sharpen", "sign": params.get("sign", "add")})
else:
filtered = box_filter(intensity, {"size": params.get("size", 3)})
hsi[..., 2] = filtered.astype(np.float32) / 255.0
return hsi_to_rgb(hsi)
def pseudo_color_slices(image, params):
gray = to_gray(image)
slices = params.get(
"slices",
[
{"start": 0, "end": 85, "color": [59, 130, 246]},
{"start": 86, "end": 170, "color": [34, 197, 94]},
{"start": 171, "end": 255, "color": [239, 68, 68]},
],
)
output = np.zeros((*gray.shape, 3), dtype=np.uint8)
for item in slices:
start = int(item["start"])
end = int(item["end"])
color = np.array(item["color"], dtype=np.uint8)
if start < 0 or end > 255 or start > end or color.shape != (3,):
raise ProcessingError("Each pseudo-color slice requires start/end in 0..255 and an RGB color.")
output[(gray >= start) & (gray <= end)] = color
return output
def gray_to_color_sinusoidal(image, params):
gray = to_gray(image).astype(np.float32) / 255.0
hue_frequency = float(params.get("hue_frequency", 1.0))
saturation_frequency = float(params.get("saturation_frequency", 0.5))
intensity_frequency = float(params.get("intensity_frequency", 0.25))
h = (0.5 + 0.5 * np.sin(2.0 * np.pi * hue_frequency * gray)) % 1.0
s = 0.55 + 0.4 * np.sin(2.0 * np.pi * saturation_frequency * gray + np.pi / 3.0)
i = 0.5 + 0.45 * np.sin(2.0 * np.pi * intensity_frequency * gray - np.pi / 2.0)
return hsi_to_rgb(np.stack([h, np.clip(s, 0, 1), np.clip(i, 0, 1)], axis=-1))
OPERATIONS = { OPERATIONS = {
"negative": negative, "negative": negative,
"log": logarithmic, "log": logarithmic,
@@ -404,8 +270,6 @@ OPERATIONS = {
"gray_slice": gray_slice, "gray_slice": gray_slice,
"bit_plane": bit_plane, "bit_plane": bit_plane,
"hist_equalization": histogram_equalization, "hist_equalization": histogram_equalization,
"hist_match": histogram_matching,
"local_equalization": local_equalization,
"box_filter": box_filter, "box_filter": box_filter,
"weighted_average": weighted_average, "weighted_average": weighted_average,
"median_filter": median_filter, "median_filter": median_filter,
@@ -413,9 +277,6 @@ OPERATIONS = {
"high_boost": high_boost, "high_boost": high_boost,
"sobel": sobel, "sobel": sobel,
"roberts": roberts, "roberts": roberts,
"hsi_intensity_filter": hsi_intensity_filter,
"pseudo_color_slices": pseudo_color_slices,
"gray_to_color_sinusoidal": gray_to_color_sinusoidal,
} }

View File

@@ -10,20 +10,11 @@ from .algorithms import (
contrast_stretch, contrast_stretch,
gamma, gamma,
gray_slice, gray_slice,
gray_to_color_sinusoidal,
gray_to_rgb, gray_to_rgb,
histogram_equalization, histogram_equalization,
histogram_matching,
hsi_intensity_filter,
hsi_to_rgb,
logarithmic, logarithmic,
local_equalization,
negative, negative,
normalize_to_uint8, normalize_to_uint8,
pseudo_color_slices,
rgb_to_hsi,
roberts,
sobel,
to_gray, to_gray,
weighted_average, weighted_average,
median_filter, median_filter,
@@ -38,71 +29,67 @@ CH4 = "Image Enhancement in the Frequency Domain"
CH6 = "Color Image Processing" CH6 = "Color Image Processing"
def odd_param(default=3, max_value=35): def with_meta(schema, *, label=None, description=None, show_when=None):
return {"type": "int", "default": default, "min": 3, "max": max_value, "step": 2, "odd": True} if label:
schema["label"] = label
if description:
schema["description"] = description
if show_when:
schema["show_when"] = show_when
return schema
def float_param(default, min_value, max_value, step=0.1): def odd_param(default=3, max_value=35, **meta):
return {"type": "float", "default": default, "min": min_value, "max": max_value, "step": step} return with_meta({"type": "int", "default": default, "min": 3, "max": max_value, "step": 2, "odd": True}, **meta)
def int_param(default, min_value, max_value, step=1): def float_param(default, min_value, max_value, step=0.1, **meta):
return {"type": "int", "default": default, "min": min_value, "max": max_value, "step": step} return with_meta({"type": "float", "default": default, "min": min_value, "max": max_value, "step": step}, **meta)
def select_param(default, choices): def int_param(default, min_value, max_value, step=1, **meta):
return {"type": "select", "default": default, "choices": choices} return with_meta({"type": "int", "default": default, "min": min_value, "max": max_value, "step": step}, **meta)
def bool_param(default=False): def select_param(default, choices, **meta):
return {"type": "bool", "default": default} return with_meta({"type": "select", "default": default, "choices": choices}, **meta)
def crop(image, params): def bool_param(default=False, **meta):
x = max(0, int(params.get("x", 0))) return with_meta({"type": "bool", "default": default}, **meta)
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): def kernel_preview(title, matrix, scale=None):
return image.copy() return {"title": title, "matrix": matrix, "scale": scale}
def inverse_log(image, params): def kernel_pair_preview(title, gx, gy):
c = float(params.get("c", 1.0)) return {"title": title, "kernels": [{"label": "Gx", "matrix": gx}, {"label": "Gy", "matrix": gy}]}
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): def mask_param(default=3, max_value=35, label="Mask size"):
level = int(params.get("level", 128)) schema = odd_param(default, max_value)
high = int(params.get("high", 255)) schema["label"] = label
low = int(params.get("low", 0)) return schema
gray = to_gray(image)
return gray_to_rgb(np.where(gray >= level, high, low).astype(np.uint8))
def histeq(image, params): def histeq(image, params):
mode = params.get("mode", "intensity") if image.ndim == 2:
if image.ndim == 2 or mode == "grayscale":
return histogram_equalization(image, params) return histogram_equalization(image, params)
if mode == "rgb":
channels = [histogram_equalization(image[:, :, idx], params)[:, :, 0] for idx in range(3)] channels = [histogram_equalization(image[:, :, idx], params)[:, :, 0] for idx in range(3)]
return np.stack(channels, axis=2).astype(np.uint8) 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 def rgb_to_gray_matlab(image, params):
return hsi_to_rgb(hsi) red_weight = float(params.get("red_weight", 0.299))
green_weight = float(params.get("green_weight", 0.587))
blue_weight = float(params.get("blue_weight", 0.114))
total = red_weight + green_weight + blue_weight
if not np.isfinite(total) or math.isclose(total, 0.0):
raise ProcessingError("Grayscale weights must have a non-zero finite sum.")
weights = np.array([red_weight, green_weight, blue_weight], dtype=np.float32) / total
gray = np.tensordot(image.astype(np.float32), weights, axes=([2], [0]))
return gray_to_rgb(ensure_uint8(np.round(gray)))
def gaussian_noise(image, params): def gaussian_noise(image, params):
@@ -126,49 +113,70 @@ def salt_pepper_noise(image, params):
return output return output
def speckle_noise(image, params): def noise_filter(image, params):
variance = float(params.get("variance", 0.04)) kind = params.get("kind", "gaussian")
noise = np.random.default_rng().normal(0, math.sqrt(max(variance, 0.0)), size=image.shape) if kind == "salt_pepper":
return ensure_uint8(np.round(image.astype(np.float32) + image.astype(np.float32) * noise)) return salt_pepper_noise(image, params)
return gaussian_noise(image, params)
def gaussian_filter(image, params): def gaussian_filter(image, params):
size = int(params.get("size", 3)) size = int(params.get("K", params.get("size", 3)))
variance = float(params.get("variance", 1.0)) variance = float(params.get("Q", params.get("variance", 1.0)))
if size < 3 or size % 2 == 0: if size < 3 or size % 2 == 0:
raise ProcessingError("size must be an odd integer >= 3.") raise ProcessingError("K must be an odd integer >= 3.")
sigma = math.sqrt(max(variance, 1e-8)) sigma = math.sqrt(max(variance, 1e-8))
return cv2.GaussianBlur(image, (size, size), sigmaX=sigma, sigmaY=sigma, borderType=cv2.BORDER_REFLECT) return cv2.GaussianBlur(image, (size, size), sigmaX=sigma, sigmaY=sigma, borderType=cv2.BORDER_REFLECT)
def max_filter(image, params): def max_filter(image, params):
size = int(params.get("size", 3)) size = int(params.get("mask_size", params.get("N", params.get("size", 3))))
if size < 3 or size % 2 == 0: if size < 3 or size % 2 == 0:
raise ProcessingError("size must be an odd integer >= 3.") raise ProcessingError("Mask size must be an odd integer >= 3.")
return cv2.dilate(image, np.ones((size, size), np.uint8)) return cv2.dilate(image, np.ones((size, size), np.uint8))
def min_filter(image, params): def min_filter(image, params):
size = int(params.get("size", 3)) size = int(params.get("mask_size", params.get("N", params.get("size", 3))))
if size < 3 or size % 2 == 0: if size < 3 or size % 2 == 0:
raise ProcessingError("size must be an odd integer >= 3.") raise ProcessingError("Mask size must be an odd integer >= 3.")
return cv2.erode(image, np.ones((size, size), np.uint8)) return cv2.erode(image, np.ones((size, size), np.uint8))
def box_denoise(image, params):
return box_filter(image, {"size": params.get("K", params.get("mask_size", params.get("size", 3)))})
def weighted_denoise(image, params):
return weighted_average(image, {"size": 3})
def median_denoise(image, params):
return median_filter(image, {"size": params.get("mask_size", params.get("N", params.get("size", 3)))})
def gaussian_denoise(image, params):
size = params.get("K", params.get("mask_size", params.get("size", 3)))
variance = params.get("Q", params.get("variance", 1.0))
return gaussian_filter(image, {"K": size, "Q": variance})
def high_boost_slide(image, params):
return high_boost(image, {"amplification": params.get("A", params.get("amplification", 1.5)), "size": params.get("K", params.get("size", 3))})
def laplacian_slide(image, params): def laplacian_slide(image, params):
mask_name = params.get("mask", "cross") mask_name = params.get("mask", "cross")
kernels = { kernels = {
"cross": np.array([[0, 1, 0], [1, -5, 1], [0, 1, 0]], dtype=np.float32), "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), "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) kernel = kernels.get(mask_name)
if kernel is None: if kernel is None:
raise ProcessingError("Unknown Laplacian mask.") 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])] 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) result = np.stack(channels, axis=2)
return normalize_to_uint8(result) if params.get("mode", "sharpen") == "detail" else ensure_uint8(result) return ensure_uint8(result)
def gradient_abs_sum(image, params): def gradient_abs_sum(image, params):
@@ -191,13 +199,6 @@ def rgb_channel(image, params):
return gray_to_rgb(image[:, :, index]) 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): def fft_spectrum(image, params):
gray = to_gray(image).astype(np.float32) gray = to_gray(image).astype(np.float32)
spectrum = np.fft.fftshift(np.fft.fft2(gray)) spectrum = np.fft.fftshift(np.fft.fft2(gray))
@@ -210,72 +211,7 @@ def fft_spectrum(image, params):
return gray_to_rgb(normalize_to_uint8(magnitude)) return gray_to_rgb(normalize_to_uint8(magnitude))
def distance_grid(shape): def operation(id, label, chapter, slide_group, func, params=None, supports="both", matrices=None, formula="", repeatable=True):
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 { return {
"id": id, "id": id,
"label": label, "label": label,
@@ -283,49 +219,46 @@ def operation(id, label, chapter, slide_group, func, params=None, supports="both
"slide_group": slide_group, "slide_group": slide_group,
"params": params or {}, "params": params or {},
"supports": supports, "supports": supports,
"matrices": matrices or [],
"formula": formula,
"repeatable": repeatable,
"func": func, "func": func,
} }
OPERATIONS = [ 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("histeq", "Histogram Equalization", CH_BASIC, "Histogram", histeq, formula="Default histeq: map gray levels by the cumulative histogram CDF.", repeatable=False),
operation("identity", "Identity", CH3, "Point Processing", identity), operation("negative", "Negative", CH3, "Point Processing", negative, formula="s = 255 - r", repeatable=False),
operation("negative", "Negative", CH3, "Point Processing", negative), operation("log", "Log", CH3, "Point Processing", logarithmic, {"c": float_param(1.44, 0.1, 5, 0.05, description="Scale factor in s = c log(1 + r).")}, formula="s = c log(1 + r)", repeatable=False),
operation("log", "Log", CH3, "Point Processing", logarithmic, {"c": float_param(1.44, 0.1, 5, 0.05)}), operation("gamma", "Power-Law / Gamma", CH3, "Point Processing", gamma, {"gamma": float_param(1.0, 0.1, 5, 0.05, description="Exponent gamma in s = c r^gamma."), "c": float_param(1.0, 0.1, 3, 0.05, description="Scale factor c in s = c r^gamma.")}, formula="s = c r^gamma", repeatable=False),
operation("inverse_log", "Inverse Log", CH3, "Point Processing", inverse_log, {"c": float_param(1.0, 0.1, 5, 0.05)}), operation("contrast_stretch", "Gray-Level Dynamic Range", CH3, "Piecewise Linear", contrast_stretch, {"low": int_param(30, 0, 254, description="Input gray level mapped toward 0."), "high": int_param(220, 1, 255, description="Input gray level mapped toward 255.")}, formula="Stretch [low, high] to [0, 255].", repeatable=False),
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("gray_slice", "Gray-Level Slicing", CH3, "Piecewise Linear", gray_slice, {"start": int_param(96, 0, 255, description="Lower bound A of highlighted range [A, B]."), "end": int_param(160, 0, 255, description="Upper bound B of highlighted range [A, B]."), "preserve_background": bool_param(True, description="Keep original background outside [A, B].")}, formula="Highlight gray range A <= r <= B.", repeatable=False),
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("bit_plane", "Bit-Plane Slicing", CH3, "Piecewise Linear", bit_plane, {"bit": int_param(7, 0, 7, description="Bit plane index, 0 least significant through 7 most significant.")}, formula="Output bit k of each gray level.", repeatable=False),
operation("contrast_stretch", "Contrast Stretching", CH3, "Piecewise Linear", contrast_stretch, {"low": int_param(30, 0, 254), "high": int_param(220, 1, 255)}), operation("noise_filter", "Noise Filter", CH3, "Noise and Denoising", noise_filter, {
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)}), "kind": select_param("gaussian", ["gaussian", "salt_pepper"], description="Select the noise model."),
operation("bit_plane", "Bit-Plane Slicing", CH3, "Piecewise Linear", bit_plane, {"bit": int_param(7, 0, 7)}), "mean": float_param(0, -1, 1, 0.01, description="Gaussian mean in normalized intensity units.", show_when={"param": "kind", "value": "gaussian"}),
operation("histeq", "histeq()", CH3, "Histogram Processing", histeq, {"mode": select_param("intensity", ["intensity", "rgb", "grayscale"])}), "variance": float_param(0.01, 0, 0.2, 0.005, description="Gaussian variance; sigma = sqrt(variance).", show_when={"param": "kind", "value": "gaussian"}),
operation("hist_match", "Histogram Specification", CH3, "Histogram Processing", histogram_matching, {"target": select_param("uniform", ["uniform", "dark", "bright", "bimodal"])}), "amount": float_param(0.03, 0, 0.5, 0.01, description="Salt-and-pepper probability per pixel.", show_when={"param": "kind", "value": "salt_pepper"}),
operation("local_equalization", "Local Enhancement", CH3, "Histogram Processing", local_equalization, {"size": odd_param(7, 31)}), "salt_ratio": float_param(0.5, 0, 1, 0.05, description="Fraction of impulse noise assigned to salt.", show_when={"param": "kind", "value": "salt_pepper"}),
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)}), }, formula="Gaussian: g=f+n. Salt-pepper: pixels become 0 or 255."),
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("box_filter", "Average / Box Filter", CH3, "Linear Low-Pass Filters", box_denoise, {"K": odd_param(3, 35, description="Odd mask dimension K for the K x K average mask.")}, matrices=[kernel_preview("1 / K^2 box mask", [["1", "1", "1"], ["1", "1", "1"], ["1", "1", "1"]], "1 / K^2")], formula="g = imfilter(f, ones(K,K)/K^2)"),
operation("speckle_noise", "Add Speckle Noise", CH3, "Noise and Denoising", speckle_noise, {"variance": float_param(0.04, 0, 0.3, 0.01)}), operation("weighted_average", "Weighted Average Filter", CH3, "Linear Low-Pass Filters", weighted_denoise, matrices=[kernel_preview("Weighted average mask", [[1, 2, 1], [2, 4, 2], [1, 2, 1]], "1 / 16")], formula="g = imfilter(f, weighted mask)"),
operation("box_filter", "Box / Average Filter", CH3, "Smoothing Linear Filters", box_filter, {"size": odd_param(3, 35)}), operation("gaussian_filter", "Gaussian Filter", CH3, "Linear Low-Pass Filters", gaussian_denoise, {"K": odd_param(3, 35, description="Odd Gaussian mask dimension K."), "Q": float_param(1.0, 0.01, 25, 0.1, description="Variance Q of the Gaussian mask.")}, formula="Gaussian mask controlled by K and variance Q."),
operation("weighted_average", "Weighted Average Filter", CH3, "Smoothing Linear Filters", weighted_average, {"size": odd_param(3, 35)}), operation("median_filter", "Median Filter", CH3, "Order-Statistics Filters", median_denoise, {"mask_size": mask_param(3, 25, "Window size")}, formula="g(x,y) = median of neighborhood."),
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("max_filter", "Max Filter", CH3, "Order-Statistics Filters", max_filter, {"mask_size": mask_param(3, 25, "Window size")}, formula="g(x,y) = max of neighborhood."),
operation("median_filter", "Median Filter", CH3, "Order-Statistics Filters", median_filter, {"size": odd_param(3, 25)}), operation("min_filter", "Min Filter", CH3, "Order-Statistics Filters", min_filter, {"mask_size": mask_param(3, 25, "Window size")}, formula="g(x,y) = min of neighborhood."),
operation("max_filter", "Max Filter", CH3, "Order-Statistics Filters", max_filter, {"size": odd_param(3, 25)}), operation("laplacian_slide", "Laplacian Sharpening Masks", CH3, "Sharpening Spatial Filters", laplacian_slide, {"mask": select_param("cross", ["cross", "diagonal"], description="Choose one of the taught sharpening masks.")}, matrices=[
operation("min_filter", "Min Filter", CH3, "Order-Statistics Filters", min_filter, {"size": odd_param(3, 25)}), kernel_preview("Sharpening cross mask", [[0, 1, 0], [1, -5, 1], [0, 1, 0]]),
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"])}), kernel_preview("Sharpening diagonal mask", [[1, 1, 1], [1, -9, 1], [1, 1, 1]]),
operation("gradient_abs_sum", "Gradient abs(imfilter Gx)+abs(imfilter Gy)", CH3, "Gradient Operator", gradient_abs_sum, {"operator": select_param("sobel", ["sobel", "roberts"])}), ], formula="Sharpen with selected Laplacian mask."),
operation("sobel", "Sobel Magnitude", CH3, "Gradient Operator", sobel), operation("gradient_abs_sum", "Gradient Operators", CH3, "Gradient Operator", gradient_abs_sum, {"operator": select_param("sobel", ["sobel", "roberts"], description="Choose Gx/Gy pair.")}, matrices=[
operation("roberts", "Roberts Magnitude", CH3, "Gradient Operator", roberts), kernel_pair_preview("Roberts Cross-Gradient", [[-1, 0], [0, 1]], [[0, -1], [1, 0]]),
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)}), kernel_pair_preview("Sobel", [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]),
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)}), ], formula="Gradient image = abs(imfilter(f,Gx)) + abs(imfilter(f,Gy))."),
operation("fft_spectrum", "FFT/DFT Spectrum View", CH4, "DFT and FFT", fft_spectrum, {"mode": select_param("log_magnitude", ["magnitude", "log_magnitude", "phase"])}), operation("high_boost", "High-Boost / Edge Emphasis", CH3, "High-Boost Filtering", high_boost_slide, {"A": float_param(1.5, 1, 6, 0.1, description="Boost factor A, where A >= 1."), "K": odd_param(3, 35, description="Odd averaging mask size used for the blurred image.")}, formula="f_hb = A f - blurred(f)."),
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("fft_spectrum", "FFT/DFT Spectrum View", CH4, "DFT and FFT", fft_spectrum, {"mode": select_param("log_magnitude", ["magnitude", "log_magnitude", "phase"], description="Choose magnitude, log magnitude, or phase display.")}, formula="F(u,v) = DFT{f(x,y)}", repeatable=False),
operation("frequency_laplacian", "Laplacian in Frequency Domain", CH4, "Sharpening Highpass Filtering", frequency_laplacian), operation("rgb_to_gray", "Convert to Grayscale", CH6, "Color Conversion", rgb_to_gray_matlab, {"red_weight": float_param(0.299, 0, 1, 0.001, description="R coefficient in gray = aR + bG + cB."), "green_weight": float_param(0.587, 0, 1, 0.001, description="G coefficient in gray = aR + bG + cB."), "blue_weight": float_param(0.114, 0, 1, 0.001, description="B coefficient in gray = aR + bG + cB.")}, formula="gray = 0.299R + 0.587G + 0.114B by default.", repeatable=False),
operation("convolution", "Convolution Utility", CH4, "Convolution", convolution), operation("rgb_channel", "RGB Channel View", CH6, "RGB color model", rgb_channel, {"channel": select_param("r", ["r", "g", "b"], description="Select the RGB channel to view.")}, formula="Show one RGB channel as grayscale.", repeatable=False),
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} OPERATION_MAP = {item["id"]: item for item in OPERATIONS}
@@ -339,4 +272,11 @@ def apply_registered_operation(image, operation_id, params=None):
item = OPERATION_MAP.get(operation_id) item = OPERATION_MAP.get(operation_id)
if item is None: if item is None:
raise ProcessingError(f"Unsupported operation '{operation_id}'.") raise ProcessingError(f"Unsupported operation '{operation_id}'.")
return ensure_uint8(item["func"](ensure_uint8(image), params or {})) operation_params = dict(params or {})
repeat_count = int(operation_params.pop("_repeat", 1))
if repeat_count < 1 or repeat_count > 20:
raise ProcessingError("N must be between 1 and 20.")
result = ensure_uint8(image)
for _ in range(repeat_count):
result = ensure_uint8(item["func"](result, operation_params))
return result

View File

@@ -1,13 +1,15 @@
import time import time
import cv2
import numpy as np import numpy as np
from django.conf import settings from django.conf import settings
from django.db.models import Max
from django.utils import timezone from django.utils import timezone
from .algorithms import ProcessingError, average_images, decode_image, histogram, histogram_payload, normalize_to_uint8, process_image, verify_registration 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 .models import ImageSession, ImageState, ProcessingJob
from .registry import apply_registered_operation from .registry import apply_registered_operation
from .storage import load_image_array, payload_for_image, save_image_array from .storage import delete_relative_file, load_image_array, payload_for_image, save_image_array
from .tasks import run_batch_job from .tasks import run_batch_job
@@ -15,7 +17,7 @@ def image_session_create(*, uploaded_file=None, image_base64=None):
if uploaded_file and uploaded_file.size > settings.MAX_UPLOAD_MB * 1024 * 1024: if uploaded_file and uploaded_file.size > settings.MAX_UPLOAD_MB * 1024 * 1024:
raise ProcessingError(f"Upload exceeds {settings.MAX_UPLOAD_MB} MB.") raise ProcessingError(f"Upload exceeds {settings.MAX_UPLOAD_MB} MB.")
image = decode_image(uploaded_file=uploaded_file, base64_image=image_base64) image = compact_workspace_image(decode_image(uploaded_file=uploaded_file, base64_image=image_base64))
relative_path = save_image_array(image, "original") relative_path = save_image_array(image, "original")
hist = histogram(image) hist = histogram(image)
session = ImageSession.objects.create( session = ImageSession.objects.create(
@@ -52,9 +54,23 @@ def image_session_create(*, uploaded_file=None, image_base64=None):
return payload return payload
def compact_workspace_image(image):
max_dimension = int(getattr(settings, "IMAGE_WORKSPACE_MAX_DIMENSION", 1400))
if max_dimension <= 0:
return image
height, width = image.shape[:2]
longest = max(width, height)
if longest <= max_dimension:
return image
scale = max_dimension / float(longest)
next_size = (max(1, int(round(width * scale))), max(1, int(round(height * scale))))
return cv2.resize(image, next_size, interpolation=cv2.INTER_AREA).astype(np.uint8)
def image_state_create(*, session, parent, image, operation, params, label=None, prefix="state"): def image_state_create(*, session, parent, image, operation, params, label=None, prefix="state"):
relative_path = save_image_array(image, prefix) relative_path = save_image_array(image, prefix)
sequence = session.states.count() max_sequence = session.states.aggregate(value=Max("sequence"))["value"]
sequence = 0 if max_sequence is None else max_sequence + 1
state = ImageState.objects.create( state = ImageState.objects.create(
session=session, session=session,
parent=parent, parent=parent,
@@ -101,6 +117,15 @@ def image_states_payload(*, states):
return [image_state_payload(state=state, include_image=True) for state in states] return [image_state_payload(state=state, include_image=True) for state in states]
def image_state_delete(*, state):
if state.sequence == 0 or state.operation == "upload":
raise ProcessingError("The original S0 upload state cannot be deleted.")
image_path = state.image
state.children.update(parent=None)
state.delete()
delete_relative_file(image_path)
def image_state_apply_operation(*, state, operation, params): def image_state_apply_operation(*, state, operation, params):
if state.session.expired: if state.session.expired:
raise ProcessingError("Image session has expired.") raise ProcessingError("Image session has expired.")

View File

@@ -50,6 +50,140 @@ class ApiTests(TestCase):
) )
self.assertEqual(processed.status_code, 400) self.assertEqual(processed.status_code, 400)
def test_operations_are_core_slide_set(self):
response = self.client.get("/api/operations/")
self.assertEqual(response.status_code, 200)
operation_ids = {item["id"] for item in response.data["operations"]}
operations = {item["id"]: item for item in response.data["operations"]}
self.assertIn("histeq", operation_ids)
self.assertIn("box_filter", operation_ids)
self.assertIn("median_filter", operation_ids)
self.assertIn("noise_filter", operation_ids)
self.assertIn("rgb_to_gray", operation_ids)
self.assertEqual(operations["histeq"]["params"], {})
self.assertEqual(operations["noise_filter"]["label"], "Noise Filter")
self.assertEqual(operations["box_filter"]["label"], "Average / Box Filter")
self.assertEqual(operations["gaussian_filter"]["label"], "Gaussian Filter")
self.assertFalse(operations["negative"]["repeatable"])
self.assertFalse(operations["rgb_to_gray"]["repeatable"])
self.assertEqual(operations["rgb_to_gray"]["params"]["red_weight"]["default"], 0.299)
self.assertEqual(operations["rgb_to_gray"]["params"]["green_weight"]["default"], 0.587)
self.assertEqual(operations["rgb_to_gray"]["params"]["blue_weight"]["default"], 0.114)
self.assertNotIn("crop", operation_ids)
self.assertNotIn("identity", operation_ids)
self.assertNotIn("threshold", operation_ids)
self.assertNotIn("hist_match", operation_ids)
self.assertNotIn("convolution", operation_ids)
self.assertNotIn("bone_scan_workflow", operation_ids)
self.assertNotIn("gaussian_noise", operation_ids)
self.assertNotIn("salt_pepper_noise", operation_ids)
self.assertNotIn("speckle_noise", operation_ids)
self.assertNotIn("hsi_view", operation_ids)
self.assertNotIn("hsi_intensity_filter", operation_ids)
self.assertNotIn("frequency_filter", operation_ids)
self.assertNotIn("frequency_laplacian", operation_ids)
self.assertNotIn("pseudo_color_slices", operation_ids)
self.assertNotIn("gray_to_color_transform", operation_ids)
def test_upload_compacts_large_image(self):
with override_settings(IMAGE_WORKSPACE_MAX_DIMENSION=4):
upload = self.client.post("/api/images/", {"image": png_upload(size=(8, 4))}, format="multipart")
self.assertEqual(upload.status_code, 201)
self.assertEqual(upload.data["width"], 4)
self.assertEqual(upload.data["height"], 2)
def test_state_delete_removes_non_s0_and_keeps_children(self):
upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart")
s0_id = upload.data["states"][0]["state_id"]
first = self.client.post(
f"/api/states/{s0_id}/operations/",
{"operation": "negative", "params": {}},
format="json",
)
second = self.client.post(
f"/api/states/{first.data['state_id']}/operations/",
{"operation": "gamma", "params": {"gamma": 1, "c": 1}},
format="json",
)
image_path = Path(self.tmp.name) / first.data["image_path"]
self.assertTrue(image_path.exists())
deleted = self.client.delete(f"/api/states/{first.data['state_id']}/")
self.assertEqual(deleted.status_code, 204)
self.assertFalse(image_path.exists())
states = self.client.get(f"/api/sessions/{upload.data['session_id']}/states/")
child = next(item for item in states.data["states"] if item["state_id"] == second.data["state_id"])
self.assertIsNone(child["parent_state_id"])
def test_state_numbering_does_not_reuse_deleted_sequence(self):
upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart")
s0_id = upload.data["states"][0]["state_id"]
first = self.client.post(
f"/api/states/{s0_id}/operations/",
{"operation": "negative", "params": {}},
format="json",
)
second = self.client.post(
f"/api/states/{first.data['state_id']}/operations/",
{"operation": "gamma", "params": {"gamma": 1, "c": 1}},
format="json",
)
self.assertEqual(first.data["sequence"], 1)
self.assertEqual(second.data["sequence"], 2)
deleted = self.client.delete(f"/api/states/{first.data['state_id']}/")
self.assertEqual(deleted.status_code, 204)
third = self.client.post(
f"/api/states/{second.data['state_id']}/operations/",
{"operation": "negative", "params": {}},
format="json",
)
self.assertEqual(third.data["sequence"], 3)
self.assertTrue(third.data["label"].startswith("S3 "))
def test_state_delete_blocks_s0(self):
upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart")
s0_id = upload.data["states"][0]["state_id"]
response = self.client.delete(f"/api/states/{s0_id}/")
self.assertEqual(response.status_code, 400)
def test_single_image_operation_uses_repeat_count(self):
upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart")
s0_id = upload.data["states"][0]["state_id"]
response = self.client.post(
f"/api/states/{s0_id}/operations/",
{"operation": "box_filter", "params": {"_repeat": 2, "K": 3}},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["params"]["_repeat"], 2)
self.assertEqual(response.data["operation"], "box_filter")
def test_merged_noise_filter_creates_state(self):
upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart")
s0_id = upload.data["states"][0]["state_id"]
response = self.client.post(
f"/api/states/{s0_id}/operations/",
{"operation": "noise_filter", "params": {"kind": "salt_pepper", "amount": 0.1, "salt_ratio": 0.5}},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["operation"], "noise_filter")
self.assertEqual(response.data["params"]["kind"], "salt_pepper")
def test_grayscale_operation_creates_state(self):
upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart")
s0_id = upload.data["states"][0]["state_id"]
gray = self.client.post(
f"/api/states/{s0_id}/operations/",
{"operation": "rgb_to_gray", "params": {"red_weight": 0.299, "green_weight": 0.587, "blue_weight": 0.114}},
format="json",
)
self.assertEqual(gray.status_code, 201)
self.assertEqual(gray.data["operation"], "rgb_to_gray")
@patch("processing.services.run_batch_job.delay") @patch("processing.services.run_batch_job.delay")
def test_batch_returns_job_id(self, delay): def test_batch_returns_job_id(self, delay):
first = self.client.post("/api/images/", {"image": png_upload(name="a.png")}, format="multipart") first = self.client.post("/api/images/", {"image": png_upload(name="a.png")}, format="multipart")

View File

@@ -9,6 +9,7 @@ from .views import (
ProcessView, ProcessView,
SessionStatesView, SessionStatesView,
StateCombineView, StateCombineView,
StateDetailView,
StateHistogramView, StateHistogramView,
StateOperationView, StateOperationView,
) )
@@ -19,6 +20,7 @@ urlpatterns = [
path("images/", ImageUploadView.as_view(), name="image-upload"), path("images/", ImageUploadView.as_view(), name="image-upload"),
path("operations/", OperationsView.as_view(), name="operations"), path("operations/", OperationsView.as_view(), name="operations"),
path("sessions/<uuid:session_id>/states/", SessionStatesView.as_view(), name="session-states"), path("sessions/<uuid:session_id>/states/", SessionStatesView.as_view(), name="session-states"),
path("states/<uuid:state_id>/", StateDetailView.as_view(), name="state-detail"),
path("states/<uuid:state_id>/operations/", StateOperationView.as_view(), name="state-operation"), 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/<uuid:state_id>/histogram/", StateHistogramView.as_view(), name="state-histogram"),
path("states/combine/", StateCombineView.as_view(), name="state-combine"), path("states/combine/", StateCombineView.as_view(), name="state-combine"),

View File

@@ -11,6 +11,7 @@ from .services import (
image_session_create, image_session_create,
image_session_process, image_session_process,
image_state_apply_operation, image_state_apply_operation,
image_state_delete,
image_state_payload, image_state_payload,
image_states_payload, image_states_payload,
processing_job_payload, processing_job_payload,
@@ -105,6 +106,18 @@ class StateOperationView(APIView):
return error_response(str(exc), code) return error_response(str(exc), code)
class StateDetailView(APIView):
def delete(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)
try:
image_state_delete(state=state)
return Response(status=status.HTTP_204_NO_CONTENT)
except ProcessingError as exc:
return error_response(str(exc))
class StateCombineView(APIView): class StateCombineView(APIView):
class InputSerializer(serializers.Serializer): class InputSerializer(serializers.Serializer):
operation = serializers.ChoiceField(choices=["add", "subtract", "dot_product", "average", "and", "or"]) operation = serializers.ChoiceField(choices=["add", "subtract", "dot_product", "average", "and", "or"])

View File

@@ -48,6 +48,7 @@ services:
CELERY_BROKER_URL: redis://redis:6379/0 CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0 CELERY_RESULT_BACKEND: redis://redis:6379/0
IMAGE_SESSION_TTL_HOURS: ${IMAGE_SESSION_TTL_HOURS:-6} IMAGE_SESSION_TTL_HOURS: ${IMAGE_SESSION_TTL_HOURS:-6}
IMAGE_WORKSPACE_MAX_DIMENSION: ${IMAGE_WORKSPACE_MAX_DIMENSION:-1400}
volumes: volumes:
- media_data:/app/media - media_data:/app/media
depends_on: depends_on:
@@ -80,6 +81,7 @@ services:
CELERY_BROKER_URL: redis://redis:6379/0 CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0 CELERY_RESULT_BACKEND: redis://redis:6379/0
IMAGE_SESSION_TTL_HOURS: ${IMAGE_SESSION_TTL_HOURS:-6} IMAGE_SESSION_TTL_HOURS: ${IMAGE_SESSION_TTL_HOURS:-6}
IMAGE_WORKSPACE_MAX_DIMENSION: ${IMAGE_WORKSPACE_MAX_DIMENSION:-1400}
volumes: volumes:
- media_data:/app/media - media_data:/app/media
depends_on: depends_on:

View File

@@ -2,16 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import CanvasPane, { CanvasThumbnail } from "./components/CanvasPane.jsx"; import CanvasPane, { CanvasThumbnail } from "./components/CanvasPane.jsx";
import Controls from "./components/Controls.jsx"; import Controls from "./components/Controls.jsx";
import HistogramPanel from "./components/HistogramPanel.jsx"; import HistogramPanel from "./components/HistogramPanel.jsx";
import { applyStateOperation, combineStates, getOperations, listStates, uploadImage } from "./lib/api.js"; import { applyStateOperation, combineStates, deleteState, 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() { export default function App() {
const [session, setSession] = useState(null); const [session, setSession] = useState(null);
@@ -29,10 +20,10 @@ export default function App() {
getOperations() getOperations()
.then((payload) => { .then((payload) => {
setOperations(payload.operations || []); setOperations(payload.operations || []);
const first = payload.operations?.find((operation) => operation.id === "crop") || payload.operations?.[0]; const first = payload.operations?.[0];
if (first) { if (first) {
setSelectedOperation(first.id); setSelectedOperation(first.id);
setParams(Object.fromEntries(Object.entries(first.params || {}).map(([key, schema]) => [key, schema.default]))); setParams({ _repeat: 1, ...Object.fromEntries(Object.entries(first.params || {}).map(([key, schema]) => [key, schema.default])) });
} }
}) })
.catch((error) => setStatus(error.message)); .catch((error) => setStatus(error.message));
@@ -59,7 +50,6 @@ export default function App() {
setSelectedStateIds(initialStates[0] ? [initialStates[0].state_id] : []); setSelectedStateIds(initialStates[0] ? [initialStates[0].state_id] : []);
setTransform({ x: 0, y: 0, scale: 1 }); setTransform({ x: 0, y: 0, scale: 1 });
setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded as S0.`); setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded as S0.`);
if (selectedOperation === "crop") setParams(defaultCropParams(initialStates[0]));
} catch (error) { } catch (error) {
setStatus(error.message); setStatus(error.message);
} finally { } finally {
@@ -69,7 +59,7 @@ export default function App() {
function handleSelectOperation(operationId, nextParams) { function handleSelectOperation(operationId, nextParams) {
setSelectedOperation(operationId); setSelectedOperation(operationId);
setParams(operationId === "crop" ? { ...nextParams, ...defaultCropParams(activeState) } : nextParams); setParams(nextParams);
} }
function handleParamChange(key, value) { function handleParamChange(key, value) {
@@ -112,14 +102,35 @@ export default function App() {
setSelectedStateIds((current) => current.includes(stateId) ? current.filter((id) => id !== stateId) : [...current, stateId]); setSelectedStateIds((current) => current.includes(stateId) ? current.filter((id) => id !== stateId) : [...current, stateId]);
} }
async function handleDeleteState(state) {
if (!state || !session) return;
setBusy(true);
setStatus(`Deleting ${state.label}...`);
try {
await deleteState(state.state_id);
const remaining = await listStates(session.session_id);
const nextStates = remaining.states || [];
setStates(nextStates);
const currentActive = nextStates.find((item) => item.state_id === activeState?.state_id);
const fallback = activeState?.state_id === state.state_id ? nextStates.at(-1) || nextStates[0] || null : currentActive || nextStates.at(-1) || nextStates[0] || null;
setActiveState(fallback);
setSelectedStateIds((current) => current.filter((id) => id !== state.state_id));
setStatus(`${state.label} deleted.`);
} catch (error) {
setStatus(error.message);
} finally {
setBusy(false);
}
}
const originalState = states[0] || null; const originalState = states[0] || null;
const viewportTitle = useMemo(() => { const viewportTitle = useMemo(() => {
if (!activeState) return "No active state"; if (!activeState) return "No active state";
return `${activeState.label} · ${activeState.width} x ${activeState.height} ${activeState.color_mode}`; return `${activeState.label} - ${activeState.width} x ${activeState.height} ${activeState.color_mode}`;
}, [activeState]); }, [activeState]);
return ( return (
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:flex-row"> <div className="flex h-screen overflow-hidden bg-zinc-950 text-zinc-100">
<Controls <Controls
operations={operations} operations={operations}
selectedOperation={selectedOperation} selectedOperation={selectedOperation}
@@ -134,13 +145,13 @@ export default function App() {
onApply={handleApply} onApply={handleApply}
onSelectState={(state) => { onSelectState={(state) => {
setActiveState(state); setActiveState(state);
if (selectedOperation === "crop") setParams(defaultCropParams(state));
}} }}
onToggleCombineState={toggleCombineState} onToggleCombineState={toggleCombineState}
onCombine={handleCombine} onCombine={handleCombine}
onDeleteState={handleDeleteState}
/> />
<main className="flex min-h-0 flex-1 flex-col"> <main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-800 bg-zinc-950 px-5 py-3"> <header className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-800 bg-zinc-950 px-5 py-3">
<div> <div>
<p className="text-xs uppercase tracking-[0.18em] text-cyan-300">Professor Slide Workspace</p> <p className="text-xs uppercase tracking-[0.18em] text-cyan-300">Professor Slide Workspace</p>
@@ -149,9 +160,15 @@ export default function App() {
<div className="text-sm text-zinc-400">{busy ? "Working..." : status}</div> <div className="text-sm text-zinc-400">{busy ? "Working..." : status}</div>
</header> </header>
<div className="relative min-h-0 flex-1 bg-zinc-800"> <div className="relative min-h-0 flex-1 overflow-hidden bg-zinc-800">
<CanvasThumbnail title="S0 Original" imageData={originalState?.image_data} /> <CanvasThumbnail title="S0 Original" imageData={originalState?.image_data} />
<CanvasPane title="Active State" imageData={activeState?.image_data} histogram={activeState?.histogram} transform={transform} onTransform={setTransform} /> <CanvasPane
title="Active State"
imageData={activeState?.image_data}
histogram={activeState?.histogram}
transform={transform}
onTransform={setTransform}
/>
</div> </div>
<HistogramPanel original={originalState?.histogram} processed={activeState?.histogram} /> <HistogramPanel original={originalState?.histogram} processed={activeState?.histogram} />

View File

@@ -6,12 +6,13 @@ vi.mock("./lib/api.js", () => ({
listStates: () => Promise.resolve({ states: [] }), listStates: () => Promise.resolve({ states: [] }),
uploadImage: vi.fn(), uploadImage: vi.fn(),
applyStateOperation: vi.fn(), applyStateOperation: vi.fn(),
combineStates: vi.fn() combineStates: vi.fn(),
deleteState: vi.fn()
})); }));
describe("App real render", () => { describe("App real render", () => {
it("mounts without mocking third-party components", () => { it("mounts without mocking third-party components", () => {
render(<App />); render(<App />);
expect(screen.getByText("Academic Image Processing Workspace")).toBeInTheDocument(); expect(screen.getByText("Image Processing Workspace")).toBeInTheDocument();
}); });
}); });

View File

@@ -6,18 +6,23 @@ vi.mock("react-quick-pinch-zoom", () => ({
})); }));
vi.mock("./lib/api.js", () => ({ vi.mock("./lib/api.js", () => ({
getOperations: () => Promise.resolve({ operations: [] }), getOperations: () => Promise.resolve({
operations: [
{ id: "histeq", label: "Histogram Equalization", chapter: "Basic", slide_group: "Histogram", params: {}, matrices: [] }
]
}),
listStates: () => Promise.resolve({ states: [] }), listStates: () => Promise.resolve({ states: [] }),
uploadImage: vi.fn(), uploadImage: vi.fn(),
applyStateOperation: vi.fn(), applyStateOperation: vi.fn(),
combineStates: vi.fn() combineStates: vi.fn(),
deleteState: vi.fn()
})); }));
describe("App", () => { describe("App", () => {
it("renders the academic workspace immediately", () => { it("renders the workspace immediately", async () => {
render(<App />); render(<App />);
expect(screen.getByText("Academic Image Processing Workspace")).toBeInTheDocument(); expect(screen.getByText("Image Processing Workspace")).toBeInTheDocument();
expect(screen.getByText("Image States")).toBeInTheDocument(); expect(screen.getByText("Image States")).toBeInTheDocument();
expect(screen.getByText("Combine Selected States")).toBeInTheDocument(); expect(await screen.findByText("Arithmetic / Logic")).toBeInTheDocument();
}); });
}); });

View File

@@ -1,9 +1,14 @@
import { Combine, Crop, Layers, SlidersHorizontal, Upload } from "lucide-react"; import { Combine, Layers, SlidersHorizontal, Trash2, Upload } from "lucide-react";
const REPEAT_SCHEMA = { type: "int", default: 1, min: 1, max: 20, step: 1, label: "N (times)" };
function defaultParams(operation) { function defaultParams(operation) {
return Object.fromEntries( return {
_repeat: 1,
...Object.fromEntries(
Object.entries(operation?.params || {}).map(([key, schema]) => [key, schema.default]) Object.entries(operation?.params || {}).map(([key, schema]) => [key, schema.default])
); )
};
} }
function groupOperations(operations) { function groupOperations(operations) {
@@ -16,15 +21,24 @@ function groupOperations(operations) {
} }
function ParamControl({ name, schema, value, onChange }) { function ParamControl({ name, schema, value, onChange }) {
const label = schema.label || name;
function parseNumeric(raw) {
let next = schema.type === "int" ? parseInt(raw || schema.default, 10) : Number(raw);
if (schema.type === "int" && schema.odd && next % 2 === 0) next += 1;
if (Number.isFinite(schema.min)) next = Math.max(schema.min, next);
if (Number.isFinite(schema.max)) next = Math.min(schema.max, next);
return next;
}
if (schema.type === "select") { if (schema.type === "select") {
return ( return (
<label className="mb-3 block text-xs text-zinc-300"> <label className="mb-3 block text-xs text-zinc-300">
<span className="mb-1 block">{name}</span> <span className="mb-1 block">{label}</span>
<select className="w-full border border-zinc-700 bg-zinc-950 px-2 py-2 text-sm" value={value ?? schema.default} onChange={(event) => onChange(name, event.target.value)}> <select className="w-full border border-zinc-700 bg-zinc-950 px-2 py-2 text-sm" value={value ?? schema.default} onChange={(event) => onChange(name, event.target.value)}>
{schema.choices.map((choice) => ( {schema.choices.map((choice) => (
<option key={choice} value={choice}>{choice}</option> <option key={choice} value={choice}>{choice}</option>
))} ))}
</select> </select>
{schema.description ? <span className="mt-1 block text-[11px] leading-relaxed text-zinc-500">{schema.description}</span> : null}
</label> </label>
); );
} }
@@ -32,14 +46,15 @@ function ParamControl({ name, schema, value, onChange }) {
return ( return (
<label className="mb-3 flex items-center gap-2 text-xs text-zinc-300"> <label className="mb-3 flex items-center gap-2 text-xs text-zinc-300">
<input type="checkbox" checked={Boolean(value ?? schema.default)} onChange={(event) => onChange(name, event.target.checked)} /> <input type="checkbox" checked={Boolean(value ?? schema.default)} onChange={(event) => onChange(name, event.target.checked)} />
{name} {label}
{schema.description ? <span className="text-[11px] leading-relaxed text-zinc-500">{schema.description}</span> : null}
</label> </label>
); );
} }
return ( return (
<label className="mb-4 block"> <label className="mb-4 block">
<div className="mb-2 flex items-center justify-between text-xs text-zinc-300"> <div className="mb-2 flex items-center justify-between text-xs text-zinc-300">
<span>{name}{schema.odd ? " (odd)" : ""}</span> <span>{label}{schema.odd ? " (odd)" : ""}</span>
<input <input
className="w-20 border border-zinc-700 bg-zinc-950 px-2 py-1 text-right tabular-nums" className="w-20 border border-zinc-700 bg-zinc-950 px-2 py-1 text-right tabular-nums"
type="number" type="number"
@@ -47,7 +62,7 @@ function ParamControl({ name, schema, value, onChange }) {
max={schema.max} max={schema.max}
step={schema.step} step={schema.step}
value={value ?? schema.default} value={value ?? schema.default}
onChange={(event) => onChange(name, schema.type === "int" ? parseInt(event.target.value || schema.default, 10) : Number(event.target.value))} onChange={(event) => onChange(name, parseNumeric(event.target.value))}
/> />
</div> </div>
<input <input
@@ -56,13 +71,19 @@ function ParamControl({ name, schema, value, onChange }) {
max={schema.max} max={schema.max}
step={schema.step} step={schema.step}
value={value ?? schema.default} value={value ?? schema.default}
onChange={(event) => onChange(name, schema.type === "int" ? parseInt(event.target.value, 10) : Number(event.target.value))} onChange={(event) => onChange(name, parseNumeric(event.target.value))}
className="w-full accent-cyan-400" className="w-full accent-cyan-400"
/> />
{schema.description ? <span className="mt-1 block text-[11px] leading-relaxed text-zinc-500">{schema.description}</span> : null}
</label> </label>
); );
} }
function shouldShowParam(schema, params) {
if (!schema.show_when) return true;
return params?.[schema.show_when.param] === schema.show_when.value;
}
export default function Controls({ export default function Controls({
operations, operations,
selectedOperation, selectedOperation,
@@ -78,23 +99,91 @@ export default function Controls({
onSelectState, onSelectState,
onToggleCombineState, onToggleCombineState,
onCombine, onCombine,
onDeleteState,
}) { }) {
const grouped = groupOperations(operations); const grouped = groupOperations(operations);
const operation = operations.find((item) => item.id === selectedOperation); const operation = operations.find((item) => item.id === selectedOperation);
const combineActions = [
["add", "add"],
["subtract", "subtract"],
["dot_product", "dot product"],
["average", "average selected"],
["and", "and"],
["or", "or"]
];
function renderMatrixPreview(matrix) {
if (matrix.kernels) {
return (
<div key={matrix.title} className="border border-zinc-800 bg-zinc-900/70 p-2">
<div className="mb-2 text-xs font-semibold text-cyan-200">{matrix.title}</div>
<div className="grid grid-cols-2 gap-2">
{matrix.kernels.map((kernel) => (
<div key={kernel.label}>
<div className="mb-1 text-[11px] text-zinc-400">{kernel.label}</div>
<div className="grid w-max gap-1" style={{ gridTemplateColumns: `repeat(${kernel.matrix[0].length}, minmax(1.75rem, auto))` }}>
{kernel.matrix.flat().map((value, index) => (
<span key={index} className="border border-zinc-700 px-2 py-1 text-center text-xs tabular-nums text-zinc-200">{value}</span>
))}
</div>
</div>
))}
</div>
</div>
);
}
return (
<div key={matrix.title} className="border border-zinc-800 bg-zinc-900/70 p-2">
<div className="mb-1 text-xs font-semibold text-cyan-200">{matrix.title}{matrix.scale ? ` (${matrix.scale})` : ""}</div>
<div className="grid w-max gap-1" style={{ gridTemplateColumns: `repeat(${matrix.matrix[0].length}, minmax(1.75rem, auto))` }}>
{matrix.matrix.flat().map((value, index) => (
<span key={index} className="border border-zinc-700 px-2 py-1 text-center text-xs tabular-nums text-zinc-200">{value}</span>
))}
</div>
</div>
);
}
function renderCombineActions() {
return (
<div className="border border-zinc-800 bg-zinc-950/70 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-cyan-200">
<Combine size={14} />
Arithmetic / Logic
</div>
<div className="grid grid-cols-2 gap-2">
{combineActions.map(([kind, label]) => (
<button key={kind} disabled={selectedStateIds.length < 2 || busy} onClick={() => onCombine(kind)} className="border border-zinc-700 bg-zinc-900 px-2 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-40">
{label}
</button>
))}
</div>
</div>
);
}
function renderParameterDrawer(item) { function renderParameterDrawer(item) {
if (selectedOperation !== item.id) return null; if (selectedOperation !== item.id) return null;
const canApply = Boolean(activeState) && !busy;
const visibleParams = Object.entries(item.params || {}).filter(([, schema]) => shouldShowParam(schema, params));
return ( return (
<div className="border border-emerald-700 bg-zinc-950 p-3"> <div className="border border-emerald-700 bg-zinc-950 p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-300"> <div className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-300">
<SlidersHorizontal size={14} /> <SlidersHorizontal size={14} />
Parameters Parameters
</div> </div>
{Object.entries(item.params || {}).map(([name, schema]) => ( {item.formula ? <div className="mb-3 border border-zinc-800 bg-zinc-900/70 p-2 text-xs leading-relaxed text-zinc-300">{item.formula}</div> : null}
{item.repeatable ? <ParamControl name="_repeat" schema={REPEAT_SCHEMA} value={params._repeat} onChange={onParamChange} /> : null}
{visibleParams.map(([name, schema]) => (
<ParamControl key={name} name={name} schema={schema} value={params[name]} onChange={onParamChange} /> <ParamControl key={name} name={name} schema={schema} value={params[name]} onChange={onParamChange} />
))} ))}
{Object.keys(item.params || {}).length === 0 ? <p className="mb-3 text-sm text-zinc-500">No parameters.</p> : null} {visibleParams.length === 0 ? <p className="mb-3 text-sm text-zinc-500">No operation-specific parameters.</p> : null}
<button disabled={!activeState || busy} onClick={onApply} className="w-full border border-emerald-600 bg-emerald-950/60 px-3 py-2 text-sm font-semibold text-emerald-100 disabled:cursor-not-allowed disabled:opacity-40"> {item.matrices?.length ? (
<div className="mb-3 space-y-2">
{item.matrices.map(renderMatrixPreview)}
</div>
) : null}
<button disabled={!canApply} onClick={onApply} className="w-full border border-emerald-600 bg-emerald-950/60 px-3 py-2 text-sm font-semibold text-emerald-100 disabled:cursor-not-allowed disabled:opacity-40">
Apply to Active State Apply to Active State
</button> </button>
</div> </div>
@@ -102,9 +191,9 @@ export default function Controls({
} }
return ( return (
<aside className="flex h-full w-full flex-col border-r border-zinc-800 bg-zinc-950 lg:w-[430px]"> <aside className="flex h-screen w-[430px] shrink-0 flex-col overflow-hidden border-r border-zinc-800 bg-zinc-950">
<div className="border-b border-zinc-800 px-5 py-4"> <div className="border-b border-zinc-800 px-5 py-4">
<h1 className="text-lg font-semibold text-zinc-50">Academic Image Processing Workspace</h1> <h1 className="text-lg font-semibold text-zinc-50">Image Processing Workspace</h1>
<p className="mt-1 text-xs text-zinc-400">MATLAB-like states organized by lecture chapters</p> <p className="mt-1 text-xs text-zinc-400">MATLAB-like states organized by lecture chapters</p>
</div> </div>
@@ -129,33 +218,29 @@ export default function Controls({
<div className="truncate text-sm text-zinc-100">{state.label}</div> <div className="truncate text-sm text-zinc-100">{state.label}</div>
<div className="truncate text-xs text-zinc-500">{state.width}x{state.height} - {state.operation}</div> <div className="truncate text-xs text-zinc-500">{state.width}x{state.height} - {state.operation}</div>
</button> </button>
</div> <button
))} type="button"
</div> disabled={state.sequence === 0 || busy}
</section> onClick={() => onDeleteState(state)}
className="border border-zinc-700 p-1.5 text-zinc-400 hover:border-red-500 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-30"
<section className="border-b border-zinc-800 p-4"> title={state.sequence === 0 ? "S0 cannot be deleted" : `Delete ${state.label}`}
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-100"> >
<Combine size={16} className="text-cyan-300" /> <Trash2 size={14} />
Combine Selected States
</div>
<div className="grid grid-cols-3 gap-2">
{["add", "subtract", "dot_product", "average", "and", "or"].map((kind) => (
<button key={kind} disabled={selectedStateIds.length < 2 || busy} onClick={() => onCombine(kind)} className="border border-zinc-700 bg-zinc-900 px-2 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-40">
{kind}
</button> </button>
</div>
))} ))}
</div> </div>
</section> </section>
<div className="min-h-0 flex-1 overflow-y-auto p-4"> <div className="min-h-0 flex-1 overflow-y-auto p-4">
{Object.entries(grouped).map(([chapter, groups]) => ( {Object.entries(grouped).map(([chapter, groups]) => (
<details key={chapter} open={chapter.includes("Chapter 3")} className="mb-3 border border-zinc-800 bg-zinc-900/60"> <details key={chapter} open={chapter === "Basic" || chapter.includes("Chapter 3")} className="mb-3 border border-zinc-800 bg-zinc-900/60">
<summary className="cursor-pointer px-3 py-3 text-sm font-semibold text-zinc-100">{chapter}</summary> <summary className="cursor-pointer px-3 py-3 text-sm font-semibold text-zinc-100">{chapter}</summary>
<div className="space-y-3 border-t border-zinc-800 p-3"> <div className="space-y-3 border-t border-zinc-800 p-3">
{chapter === "Basic" ? renderCombineActions() : null}
{Object.entries(groups).map(([slideGroup, items]) => ( {Object.entries(groups).map(([slideGroup, items]) => (
<details key={slideGroup} className="border border-zinc-800 bg-zinc-950/70"> <section key={slideGroup} className="border border-zinc-800 bg-zinc-950/70">
<summary className="cursor-pointer px-3 py-2 text-xs font-semibold text-cyan-200">{slideGroup}</summary> <div className="border-b border-zinc-800 px-3 py-2 text-xs font-semibold text-cyan-200">{slideGroup}</div>
<div className="grid grid-cols-1 gap-2 p-2"> <div className="grid grid-cols-1 gap-2 p-2">
{items.map((item) => ( {items.map((item) => (
<div key={item.id} className="space-y-2"> <div key={item.id} className="space-y-2">
@@ -164,14 +249,13 @@ export default function Controls({
onClick={() => onSelectOperation(item.id, defaultParams(item))} onClick={() => onSelectOperation(item.id, defaultParams(item))}
className={`w-full border px-3 py-2 text-left text-sm ${selectedOperation === item.id ? "border-emerald-500 bg-emerald-950/50 text-emerald-100" : "border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800"} disabled:cursor-not-allowed disabled:opacity-40`} className={`w-full border px-3 py-2 text-left text-sm ${selectedOperation === item.id ? "border-emerald-500 bg-emerald-950/50 text-emerald-100" : "border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800"} disabled:cursor-not-allowed disabled:opacity-40`}
> >
{item.id === "crop" ? <Crop size={14} className="mr-2 inline" /> : null}
{item.label} {item.label}
</button> </button>
{renderParameterDrawer(item)} {renderParameterDrawer(item)}
</div> </div>
))} ))}
</div> </div>
</details> </section>
))} ))}
</div> </div>
</details> </details>

View File

@@ -51,6 +51,14 @@ export async function getStateHistogram(stateId) {
return parseResponse(response); return parseResponse(response);
} }
export async function deleteState(stateId) {
const response = await fetch(`${API_BASE}/api/states/${stateId}/`, {
method: "DELETE"
});
if (response.status === 204) return {};
return parseResponse(response);
}
export async function processImage(sessionId, operation, params) { export async function processImage(sessionId, operation, params) {
const response = await fetch(`${API_BASE}/api/process/`, { const response = await fetch(`${API_BASE}/api/process/`, {
method: "POST", method: "POST",

View File

@@ -13,6 +13,13 @@ body {
min-height: 100vh; min-height: 100vh;
background: #09090b; background: #09090b;
color: #e4e4e7; color: #e4e4e7;
overflow: hidden;
}
html,
body,
#root {
height: 100%;
} }
canvas { canvas {