feat(v3): simplify the project to contain only required tools
This commit is contained in:
19
AGENTS.md
19
AGENTS.md
@@ -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.
|
|
||||||
@@ -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] |
|
|
||||||
@@ -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].
|
|
||||||
56
PROMPT.md
56
PROMPT.md
@@ -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.
|
|
||||||
22
SKILL.md
22
SKILL.md
@@ -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].
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user