feat(v2): add multiple extra features from the pdf slides
This commit is contained in:
@@ -69,6 +69,16 @@ def histogram(image):
|
|||||||
return probabilities.round(8).tolist()
|
return probabilities.round(8).tolist()
|
||||||
|
|
||||||
|
|
||||||
|
def histogram_payload(image):
|
||||||
|
gray = to_gray(image)
|
||||||
|
payload = {"intensity": histogram(gray)}
|
||||||
|
if image.ndim == 3:
|
||||||
|
payload["r"] = (np.bincount(image[:, :, 0].ravel(), minlength=256).astype(np.float64) / image[:, :, 0].size).round(8).tolist()
|
||||||
|
payload["g"] = (np.bincount(image[:, :, 1].ravel(), minlength=256).astype(np.float64) / image[:, :, 1].size).round(8).tolist()
|
||||||
|
payload["b"] = (np.bincount(image[:, :, 2].ravel(), minlength=256).astype(np.float64) / image[:, :, 2].size).round(8).tolist()
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def image_to_data_url(image):
|
def image_to_data_url(image):
|
||||||
pil_image = Image.fromarray(ensure_uint8(image))
|
pil_image = Image.fromarray(ensure_uint8(image))
|
||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
|
|||||||
51
backend/processing/migrations/0002_imagestate.py
Normal file
51
backend/processing/migrations/0002_imagestate.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("processing", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ImageState",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("sequence", models.PositiveIntegerField(default=0)),
|
||||||
|
("label", models.CharField(max_length=120)),
|
||||||
|
("operation", models.CharField(max_length=96)),
|
||||||
|
("params", models.JSONField(blank=True, default=dict)),
|
||||||
|
("image", models.CharField(max_length=255)),
|
||||||
|
("width", models.PositiveIntegerField()),
|
||||||
|
("height", models.PositiveIntegerField()),
|
||||||
|
("channels", models.PositiveSmallIntegerField()),
|
||||||
|
("color_mode", models.CharField(max_length=16)),
|
||||||
|
("histogram", models.JSONField(default=dict)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"parent",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="children",
|
||||||
|
to="processing.imagestate",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="states",
|
||||||
|
to="processing.imagesession",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["sequence", "created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -28,6 +28,26 @@ class ImageSession(models.Model):
|
|||||||
return timezone.now() >= self.expires_at
|
return timezone.now() >= self.expires_at
|
||||||
|
|
||||||
|
|
||||||
|
class ImageState(models.Model):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
session = models.ForeignKey(ImageSession, related_name="states", on_delete=models.CASCADE)
|
||||||
|
parent = models.ForeignKey("self", related_name="children", on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
sequence = models.PositiveIntegerField(default=0)
|
||||||
|
label = models.CharField(max_length=120)
|
||||||
|
operation = models.CharField(max_length=96)
|
||||||
|
params = models.JSONField(default=dict, blank=True)
|
||||||
|
image = models.CharField(max_length=255)
|
||||||
|
width = models.PositiveIntegerField()
|
||||||
|
height = models.PositiveIntegerField()
|
||||||
|
channels = models.PositiveSmallIntegerField()
|
||||||
|
color_mode = models.CharField(max_length=16)
|
||||||
|
histogram = models.JSONField(default=dict)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["sequence", "created_at"]
|
||||||
|
|
||||||
|
|
||||||
class ProcessingJob(models.Model):
|
class ProcessingJob(models.Model):
|
||||||
STATUS_PENDING = "pending"
|
STATUS_PENDING = "pending"
|
||||||
STATUS_RUNNING = "running"
|
STATUS_RUNNING = "running"
|
||||||
|
|||||||
342
backend/processing/registry.py
Normal file
342
backend/processing/registry.py
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .algorithms import (
|
||||||
|
ProcessingError,
|
||||||
|
bit_plane,
|
||||||
|
box_filter,
|
||||||
|
contrast_stretch,
|
||||||
|
gamma,
|
||||||
|
gray_slice,
|
||||||
|
gray_to_color_sinusoidal,
|
||||||
|
gray_to_rgb,
|
||||||
|
histogram_equalization,
|
||||||
|
histogram_matching,
|
||||||
|
hsi_intensity_filter,
|
||||||
|
hsi_to_rgb,
|
||||||
|
logarithmic,
|
||||||
|
local_equalization,
|
||||||
|
negative,
|
||||||
|
normalize_to_uint8,
|
||||||
|
pseudo_color_slices,
|
||||||
|
rgb_to_hsi,
|
||||||
|
roberts,
|
||||||
|
sobel,
|
||||||
|
to_gray,
|
||||||
|
weighted_average,
|
||||||
|
median_filter,
|
||||||
|
high_boost,
|
||||||
|
ensure_uint8,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CH_BASIC = "Basic"
|
||||||
|
CH3 = "Image Enhancement in the Spatial Domain"
|
||||||
|
CH4 = "Image Enhancement in the Frequency Domain"
|
||||||
|
CH6 = "Color Image Processing"
|
||||||
|
|
||||||
|
|
||||||
|
def odd_param(default=3, max_value=35):
|
||||||
|
return {"type": "int", "default": default, "min": 3, "max": max_value, "step": 2, "odd": True}
|
||||||
|
|
||||||
|
|
||||||
|
def float_param(default, min_value, max_value, step=0.1):
|
||||||
|
return {"type": "float", "default": default, "min": min_value, "max": max_value, "step": step}
|
||||||
|
|
||||||
|
|
||||||
|
def int_param(default, min_value, max_value, step=1):
|
||||||
|
return {"type": "int", "default": default, "min": min_value, "max": max_value, "step": step}
|
||||||
|
|
||||||
|
|
||||||
|
def select_param(default, choices):
|
||||||
|
return {"type": "select", "default": default, "choices": choices}
|
||||||
|
|
||||||
|
|
||||||
|
def bool_param(default=False):
|
||||||
|
return {"type": "bool", "default": default}
|
||||||
|
|
||||||
|
|
||||||
|
def crop(image, params):
|
||||||
|
x = max(0, int(params.get("x", 0)))
|
||||||
|
y = max(0, int(params.get("y", 0)))
|
||||||
|
width = int(params.get("width", image.shape[1] - x))
|
||||||
|
height = int(params.get("height", image.shape[0] - y))
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ProcessingError("Crop width and height must be positive.")
|
||||||
|
x2 = min(image.shape[1], x + width)
|
||||||
|
y2 = min(image.shape[0], y + height)
|
||||||
|
if x >= x2 or y >= y2:
|
||||||
|
raise ProcessingError("Crop rectangle is outside the image.")
|
||||||
|
return image[y:y2, x:x2]
|
||||||
|
|
||||||
|
|
||||||
|
def identity(image, params):
|
||||||
|
return image.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def inverse_log(image, params):
|
||||||
|
c = float(params.get("c", 1.0))
|
||||||
|
normalized = image.astype(np.float32) / 255.0
|
||||||
|
transformed = np.expm1(normalized / max(c, 1e-8))
|
||||||
|
transformed /= max(float(np.max(transformed)), 1e-8)
|
||||||
|
return ensure_uint8(np.round(transformed * 255.0))
|
||||||
|
|
||||||
|
|
||||||
|
def threshold(image, params):
|
||||||
|
level = int(params.get("level", 128))
|
||||||
|
high = int(params.get("high", 255))
|
||||||
|
low = int(params.get("low", 0))
|
||||||
|
gray = to_gray(image)
|
||||||
|
return gray_to_rgb(np.where(gray >= level, high, low).astype(np.uint8))
|
||||||
|
|
||||||
|
|
||||||
|
def histeq(image, params):
|
||||||
|
mode = params.get("mode", "intensity")
|
||||||
|
if image.ndim == 2 or mode == "grayscale":
|
||||||
|
return histogram_equalization(image, params)
|
||||||
|
if mode == "rgb":
|
||||||
|
channels = [histogram_equalization(image[:, :, idx], params)[:, :, 0] for idx in range(3)]
|
||||||
|
return np.stack(channels, axis=2).astype(np.uint8)
|
||||||
|
hsi = rgb_to_hsi(image)
|
||||||
|
intensity = np.round(hsi[..., 2] * 255).astype(np.uint8)
|
||||||
|
hsi[..., 2] = histogram_equalization(intensity, params)[:, :, 0].astype(np.float32) / 255.0
|
||||||
|
return hsi_to_rgb(hsi)
|
||||||
|
|
||||||
|
|
||||||
|
def gaussian_noise(image, params):
|
||||||
|
mean = float(params.get("mean", 0))
|
||||||
|
variance = float(params.get("variance", 0.01))
|
||||||
|
sigma = math.sqrt(max(variance, 0.0)) * 255.0
|
||||||
|
noise = np.random.default_rng().normal(mean * 255.0, sigma, size=image.shape)
|
||||||
|
return ensure_uint8(np.round(image.astype(np.float32) + noise))
|
||||||
|
|
||||||
|
|
||||||
|
def salt_pepper_noise(image, params):
|
||||||
|
amount = float(params.get("amount", 0.03))
|
||||||
|
salt_ratio = float(params.get("salt_ratio", 0.5))
|
||||||
|
output = image.copy()
|
||||||
|
rng = np.random.default_rng()
|
||||||
|
mask = rng.random(image.shape[:2])
|
||||||
|
salt = mask < amount * salt_ratio
|
||||||
|
pepper = (mask >= amount * salt_ratio) & (mask < amount)
|
||||||
|
output[salt] = 255
|
||||||
|
output[pepper] = 0
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def speckle_noise(image, params):
|
||||||
|
variance = float(params.get("variance", 0.04))
|
||||||
|
noise = np.random.default_rng().normal(0, math.sqrt(max(variance, 0.0)), size=image.shape)
|
||||||
|
return ensure_uint8(np.round(image.astype(np.float32) + image.astype(np.float32) * noise))
|
||||||
|
|
||||||
|
|
||||||
|
def gaussian_filter(image, params):
|
||||||
|
size = int(params.get("size", 3))
|
||||||
|
variance = float(params.get("variance", 1.0))
|
||||||
|
if size < 3 or size % 2 == 0:
|
||||||
|
raise ProcessingError("size must be an odd integer >= 3.")
|
||||||
|
sigma = math.sqrt(max(variance, 1e-8))
|
||||||
|
return cv2.GaussianBlur(image, (size, size), sigmaX=sigma, sigmaY=sigma, borderType=cv2.BORDER_REFLECT)
|
||||||
|
|
||||||
|
|
||||||
|
def max_filter(image, params):
|
||||||
|
size = int(params.get("size", 3))
|
||||||
|
if size < 3 or size % 2 == 0:
|
||||||
|
raise ProcessingError("size must be an odd integer >= 3.")
|
||||||
|
return cv2.dilate(image, np.ones((size, size), np.uint8))
|
||||||
|
|
||||||
|
|
||||||
|
def min_filter(image, params):
|
||||||
|
size = int(params.get("size", 3))
|
||||||
|
if size < 3 or size % 2 == 0:
|
||||||
|
raise ProcessingError("size must be an odd integer >= 3.")
|
||||||
|
return cv2.erode(image, np.ones((size, size), np.uint8))
|
||||||
|
|
||||||
|
|
||||||
|
def laplacian_slide(image, params):
|
||||||
|
mask_name = params.get("mask", "cross")
|
||||||
|
kernels = {
|
||||||
|
"cross": np.array([[0, 1, 0], [1, -5, 1], [0, 1, 0]], dtype=np.float32),
|
||||||
|
"diagonal": np.array([[1, 1, 1], [1, -9, 1], [1, 1, 1]], dtype=np.float32),
|
||||||
|
"zero_sum_cross": np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32),
|
||||||
|
"zero_sum_diagonal": np.array([[1, 1, 1], [1, -8, 1], [1, 1, 1]], dtype=np.float32),
|
||||||
|
}
|
||||||
|
kernel = kernels.get(mask_name)
|
||||||
|
if kernel is None:
|
||||||
|
raise ProcessingError("Unknown Laplacian mask.")
|
||||||
|
channels = [cv2.filter2D(image[:, :, idx], cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT) for idx in range(image.shape[2])]
|
||||||
|
result = np.stack(channels, axis=2)
|
||||||
|
return normalize_to_uint8(result) if params.get("mode", "sharpen") == "detail" else ensure_uint8(result)
|
||||||
|
|
||||||
|
|
||||||
|
def gradient_abs_sum(image, params):
|
||||||
|
operator = params.get("operator", "sobel")
|
||||||
|
gray = to_gray(image).astype(np.float32)
|
||||||
|
if operator == "roberts":
|
||||||
|
gx = np.array([[-1, 0], [0, 1]], dtype=np.float32)
|
||||||
|
gy = np.array([[0, -1], [1, 0]], dtype=np.float32)
|
||||||
|
else:
|
||||||
|
gx = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32)
|
||||||
|
gy = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32)
|
||||||
|
fx = cv2.filter2D(gray, cv2.CV_32F, gx, borderType=cv2.BORDER_REFLECT)
|
||||||
|
fy = cv2.filter2D(gray, cv2.CV_32F, gy, borderType=cv2.BORDER_REFLECT)
|
||||||
|
return gray_to_rgb(normalize_to_uint8(np.abs(fx) + np.abs(fy)))
|
||||||
|
|
||||||
|
|
||||||
|
def rgb_channel(image, params):
|
||||||
|
channel = params.get("channel", "r")
|
||||||
|
index = {"r": 0, "g": 1, "b": 2}.get(channel, 0)
|
||||||
|
return gray_to_rgb(image[:, :, index])
|
||||||
|
|
||||||
|
|
||||||
|
def hsi_view(image, params):
|
||||||
|
component = params.get("component", "i")
|
||||||
|
hsi = rgb_to_hsi(image)
|
||||||
|
index = {"h": 0, "s": 1, "i": 2}.get(component, 2)
|
||||||
|
return gray_to_rgb(np.round(hsi[:, :, index] * 255.0).astype(np.uint8))
|
||||||
|
|
||||||
|
|
||||||
|
def fft_spectrum(image, params):
|
||||||
|
gray = to_gray(image).astype(np.float32)
|
||||||
|
spectrum = np.fft.fftshift(np.fft.fft2(gray))
|
||||||
|
mode = params.get("mode", "log_magnitude")
|
||||||
|
if mode == "phase":
|
||||||
|
return gray_to_rgb(normalize_to_uint8(np.angle(spectrum)))
|
||||||
|
magnitude = np.abs(spectrum)
|
||||||
|
if mode == "log_magnitude":
|
||||||
|
magnitude = np.log1p(magnitude)
|
||||||
|
return gray_to_rgb(normalize_to_uint8(magnitude))
|
||||||
|
|
||||||
|
|
||||||
|
def distance_grid(shape):
|
||||||
|
rows, cols = shape
|
||||||
|
u = np.arange(rows) - rows / 2
|
||||||
|
v = np.arange(cols) - cols / 2
|
||||||
|
vv, uu = np.meshgrid(v, u)
|
||||||
|
return np.sqrt(uu**2 + vv**2)
|
||||||
|
|
||||||
|
|
||||||
|
def frequency_filter(image, params):
|
||||||
|
gray = to_gray(image).astype(np.float32)
|
||||||
|
d0 = float(params.get("cutoff", 40))
|
||||||
|
order = int(params.get("order", 2))
|
||||||
|
family = params.get("family", "gaussian")
|
||||||
|
kind = params.get("kind", "lowpass")
|
||||||
|
d = distance_grid(gray.shape)
|
||||||
|
if family == "ideal":
|
||||||
|
mask = (d <= d0).astype(np.float32)
|
||||||
|
elif family == "butterworth":
|
||||||
|
mask = 1.0 / (1.0 + (d / max(d0, 1e-8)) ** (2 * max(order, 1)))
|
||||||
|
else:
|
||||||
|
mask = np.exp(-(d**2) / (2.0 * max(d0, 1e-8) ** 2))
|
||||||
|
if kind == "highpass":
|
||||||
|
mask = 1.0 - mask
|
||||||
|
if params.get("output", "image") == "mask":
|
||||||
|
return gray_to_rgb(normalize_to_uint8(mask))
|
||||||
|
f = np.fft.fftshift(np.fft.fft2(gray))
|
||||||
|
result = np.real(np.fft.ifft2(np.fft.ifftshift(f * mask)))
|
||||||
|
return gray_to_rgb(normalize_to_uint8(result))
|
||||||
|
|
||||||
|
|
||||||
|
def frequency_laplacian(image, params):
|
||||||
|
gray = to_gray(image).astype(np.float32)
|
||||||
|
rows, cols = gray.shape
|
||||||
|
u = np.arange(rows) - rows / 2
|
||||||
|
v = np.arange(cols) - cols / 2
|
||||||
|
vv, uu = np.meshgrid(v, u)
|
||||||
|
h = -4.0 * (np.pi**2) * (uu**2 + vv**2)
|
||||||
|
f = np.fft.fftshift(np.fft.fft2(gray))
|
||||||
|
result = np.real(np.fft.ifft2(np.fft.ifftshift(f * h)))
|
||||||
|
return gray_to_rgb(normalize_to_uint8(result))
|
||||||
|
|
||||||
|
|
||||||
|
def correlation(image, params):
|
||||||
|
kernel = np.array(params.get("kernel", [[1, 1, 1], [1, 1, 1], [1, 1, 1]]), dtype=np.float32)
|
||||||
|
kernel /= max(float(np.sum(np.abs(kernel))), 1e-8)
|
||||||
|
gray = to_gray(image).astype(np.float32)
|
||||||
|
return gray_to_rgb(normalize_to_uint8(cv2.filter2D(gray, cv2.CV_32F, kernel, borderType=cv2.BORDER_REFLECT)))
|
||||||
|
|
||||||
|
|
||||||
|
def convolution(image, params):
|
||||||
|
kernel = np.array(params.get("kernel", [[1, 1, 1], [1, 1, 1], [1, 1, 1]]), dtype=np.float32)
|
||||||
|
return correlation(image, {"kernel": np.flipud(np.fliplr(kernel)).tolist()})
|
||||||
|
|
||||||
|
|
||||||
|
def bone_scan_workflow(image, params):
|
||||||
|
gray_rgb = gray_to_rgb(to_gray(image))
|
||||||
|
lap_detail = laplacian_slide(gray_rgb, {"mask": "zero_sum_diagonal", "mode": "detail"})
|
||||||
|
sharpened = ensure_uint8(gray_rgb.astype(np.float32) + lap_detail.astype(np.float32))
|
||||||
|
sobel_img = gradient_abs_sum(gray_rgb, {"operator": "sobel"})
|
||||||
|
smooth_sobel = box_filter(sobel_img, {"size": 5})
|
||||||
|
mask = normalize_to_uint8((sharpened.astype(np.float32) * smooth_sobel.astype(np.float32)) / 255.0)
|
||||||
|
summed = ensure_uint8(gray_rgb.astype(np.float32) + mask.astype(np.float32))
|
||||||
|
return gamma(summed, {"gamma": float(params.get("gamma", 0.5)), "c": 1.0})
|
||||||
|
|
||||||
|
|
||||||
|
def operation(id, label, chapter, slide_group, func, params=None, supports="both"):
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"label": label,
|
||||||
|
"chapter": chapter,
|
||||||
|
"slide_group": slide_group,
|
||||||
|
"params": params or {},
|
||||||
|
"supports": supports,
|
||||||
|
"func": func,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
OPERATIONS = [
|
||||||
|
operation("crop", "Crop", CH_BASIC, "Workspace", crop, {"x": int_param(0, 0, 4000), "y": int_param(0, 0, 4000), "width": int_param(256, 1, 8000), "height": int_param(256, 1, 8000)}),
|
||||||
|
operation("identity", "Identity", CH3, "Point Processing", identity),
|
||||||
|
operation("negative", "Negative", CH3, "Point Processing", negative),
|
||||||
|
operation("log", "Log", CH3, "Point Processing", logarithmic, {"c": float_param(1.44, 0.1, 5, 0.05)}),
|
||||||
|
operation("inverse_log", "Inverse Log", CH3, "Point Processing", inverse_log, {"c": float_param(1.0, 0.1, 5, 0.05)}),
|
||||||
|
operation("gamma", "Power-Law / Gamma", CH3, "Point Processing", gamma, {"gamma": float_param(1.0, 0.1, 5, 0.05), "c": float_param(1.0, 0.1, 3, 0.05)}),
|
||||||
|
operation("threshold", "Thresholding", CH3, "Point Processing", threshold, {"level": int_param(128, 0, 255), "low": int_param(0, 0, 255), "high": int_param(255, 0, 255)}),
|
||||||
|
operation("contrast_stretch", "Contrast Stretching", CH3, "Piecewise Linear", contrast_stretch, {"low": int_param(30, 0, 254), "high": int_param(220, 1, 255)}),
|
||||||
|
operation("gray_slice", "Gray-Level Slicing", CH3, "Piecewise Linear", gray_slice, {"start": int_param(96, 0, 255), "end": int_param(160, 0, 255), "preserve_background": bool_param(True)}),
|
||||||
|
operation("bit_plane", "Bit-Plane Slicing", CH3, "Piecewise Linear", bit_plane, {"bit": int_param(7, 0, 7)}),
|
||||||
|
operation("histeq", "histeq()", CH3, "Histogram Processing", histeq, {"mode": select_param("intensity", ["intensity", "rgb", "grayscale"])}),
|
||||||
|
operation("hist_match", "Histogram Specification", CH3, "Histogram Processing", histogram_matching, {"target": select_param("uniform", ["uniform", "dark", "bright", "bimodal"])}),
|
||||||
|
operation("local_equalization", "Local Enhancement", CH3, "Histogram Processing", local_equalization, {"size": odd_param(7, 31)}),
|
||||||
|
operation("gaussian_noise", "Add Gaussian Noise", CH3, "Noise and Denoising", gaussian_noise, {"mean": float_param(0, -1, 1, 0.01), "variance": float_param(0.01, 0, 0.2, 0.005)}),
|
||||||
|
operation("salt_pepper_noise", "Add Salt & Pepper Noise", CH3, "Noise and Denoising", salt_pepper_noise, {"amount": float_param(0.03, 0, 0.5, 0.01), "salt_ratio": float_param(0.5, 0, 1, 0.05)}),
|
||||||
|
operation("speckle_noise", "Add Speckle Noise", CH3, "Noise and Denoising", speckle_noise, {"variance": float_param(0.04, 0, 0.3, 0.01)}),
|
||||||
|
operation("box_filter", "Box / Average Filter", CH3, "Smoothing Linear Filters", box_filter, {"size": odd_param(3, 35)}),
|
||||||
|
operation("weighted_average", "Weighted Average Filter", CH3, "Smoothing Linear Filters", weighted_average, {"size": odd_param(3, 35)}),
|
||||||
|
operation("gaussian_filter", "Gaussian fspecial Filter", CH3, "Smoothing Linear Filters", gaussian_filter, {"size": odd_param(3, 35), "variance": float_param(1.0, 0.01, 25, 0.1)}),
|
||||||
|
operation("median_filter", "Median Filter", CH3, "Order-Statistics Filters", median_filter, {"size": odd_param(3, 25)}),
|
||||||
|
operation("max_filter", "Max Filter", CH3, "Order-Statistics Filters", max_filter, {"size": odd_param(3, 25)}),
|
||||||
|
operation("min_filter", "Min Filter", CH3, "Order-Statistics Filters", min_filter, {"size": odd_param(3, 25)}),
|
||||||
|
operation("laplacian_slide", "Laplacian Masks", CH3, "Sharpening Spatial Filters", laplacian_slide, {"mask": select_param("cross", ["cross", "diagonal", "zero_sum_cross", "zero_sum_diagonal"]), "mode": select_param("sharpen", ["sharpen", "detail"])}),
|
||||||
|
operation("gradient_abs_sum", "Gradient abs(imfilter Gx)+abs(imfilter Gy)", CH3, "Gradient Operator", gradient_abs_sum, {"operator": select_param("sobel", ["sobel", "roberts"])}),
|
||||||
|
operation("sobel", "Sobel Magnitude", CH3, "Gradient Operator", sobel),
|
||||||
|
operation("roberts", "Roberts Magnitude", CH3, "Gradient Operator", roberts),
|
||||||
|
operation("high_boost", "High-Boost Filtering", CH3, "High-Boost Filtering", high_boost, {"amplification": float_param(1.5, 1, 6, 0.1), "size": odd_param(3, 35)}),
|
||||||
|
operation("bone_scan_workflow", "Bone Scan Workflow Preset", CH3, "Combining Spatial Enhancement Methods", bone_scan_workflow, {"gamma": float_param(0.5, 0.1, 2, 0.05)}),
|
||||||
|
operation("fft_spectrum", "FFT/DFT Spectrum View", CH4, "DFT and FFT", fft_spectrum, {"mode": select_param("log_magnitude", ["magnitude", "log_magnitude", "phase"])}),
|
||||||
|
operation("frequency_filter", "Ideal/Butterworth/Gaussian Frequency Filter", CH4, "Frequency Domain Filtering", frequency_filter, {"family": select_param("gaussian", ["ideal", "butterworth", "gaussian"]), "kind": select_param("lowpass", ["lowpass", "highpass"]), "cutoff": float_param(40, 1, 512, 1), "order": int_param(2, 1, 10), "output": select_param("image", ["image", "mask"])}),
|
||||||
|
operation("frequency_laplacian", "Laplacian in Frequency Domain", CH4, "Sharpening Highpass Filtering", frequency_laplacian),
|
||||||
|
operation("convolution", "Convolution Utility", CH4, "Convolution", convolution),
|
||||||
|
operation("correlation", "Correlation Utility", CH4, "Correlation", correlation),
|
||||||
|
operation("rgb_channel", "RGB Channel View", CH6, "RGB color model", rgb_channel, {"channel": select_param("r", ["r", "g", "b"])}),
|
||||||
|
operation("hsi_view", "HSI Component View", CH6, "HSI color model", hsi_view, {"component": select_param("i", ["h", "s", "i"])}),
|
||||||
|
operation("hsi_intensity_filter", "HSI Intensity Processing", CH6, "HSI color model", hsi_intensity_filter, {"method": select_param("smooth", ["smooth", "sharpen"]), "size": odd_param(3, 25)}),
|
||||||
|
operation("pseudo_color_slices", "Pseudocolor Intensity Slicing", CH6, "Pseudocolor Image Processing", pseudo_color_slices),
|
||||||
|
operation("gray_to_color_sinusoidal", "Gray-Level to Color Transform", CH6, "Gray level to color transformation", gray_to_color_sinusoidal, {"hue_frequency": float_param(1, 0.2, 4, 0.1), "saturation_frequency": float_param(0.5, 0.1, 4, 0.1), "intensity_frequency": float_param(0.25, 0.1, 4, 0.1)}),
|
||||||
|
]
|
||||||
|
|
||||||
|
OPERATION_MAP = {item["id"]: item for item in OPERATIONS}
|
||||||
|
|
||||||
|
|
||||||
|
def operation_metadata():
|
||||||
|
return [{key: value for key, value in item.items() if key != "func"} for item in OPERATIONS]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_registered_operation(image, operation_id, params=None):
|
||||||
|
item = OPERATION_MAP.get(operation_id)
|
||||||
|
if item is None:
|
||||||
|
raise ProcessingError(f"Unsupported operation '{operation_id}'.")
|
||||||
|
return ensure_uint8(item["func"](ensure_uint8(image), params or {}))
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from .models import ImageSession, ProcessingJob
|
from .models import ImageSession, ImageState, ProcessingJob
|
||||||
|
|
||||||
|
|
||||||
def image_session_get(*, session_id):
|
def image_session_get(*, session_id):
|
||||||
@@ -7,3 +7,11 @@ def image_session_get(*, session_id):
|
|||||||
|
|
||||||
def processing_job_get(*, job_id):
|
def processing_job_get(*, job_id):
|
||||||
return ProcessingJob.objects.filter(id=job_id).first()
|
return ProcessingJob.objects.filter(id=job_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def image_state_get(*, state_id):
|
||||||
|
return ImageState.objects.select_related("session", "parent").filter(id=state_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def image_states_list(*, session_id):
|
||||||
|
return ImageState.objects.select_related("parent").filter(session_id=session_id).order_by("sequence", "created_at")
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from .algorithms import ProcessingError, decode_image, histogram, process_image
|
from .algorithms import ProcessingError, average_images, decode_image, histogram, histogram_payload, normalize_to_uint8, process_image, verify_registration
|
||||||
from .models import ImageSession, ProcessingJob
|
from .models import ImageSession, ImageState, ProcessingJob
|
||||||
|
from .registry import apply_registered_operation
|
||||||
from .storage import load_image_array, payload_for_image, save_image_array
|
from .storage import load_image_array, payload_for_image, save_image_array
|
||||||
from .tasks import run_batch_job
|
from .tasks import run_batch_job
|
||||||
|
|
||||||
@@ -25,19 +27,145 @@ def image_session_create(*, uploaded_file=None, image_base64=None):
|
|||||||
original_histogram=hist,
|
original_histogram=hist,
|
||||||
expires_at=timezone.now() + timezone.timedelta(hours=settings.IMAGE_SESSION_TTL_HOURS),
|
expires_at=timezone.now() + timezone.timedelta(hours=settings.IMAGE_SESSION_TTL_HOURS),
|
||||||
)
|
)
|
||||||
|
state = image_state_create(
|
||||||
|
session=session,
|
||||||
|
parent=None,
|
||||||
|
image=image,
|
||||||
|
operation="upload",
|
||||||
|
params={},
|
||||||
|
label="S0 Upload",
|
||||||
|
prefix="state-upload",
|
||||||
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"session_id": str(session.id),
|
"session_id": str(session.id),
|
||||||
|
"active_state_id": str(state.id),
|
||||||
"width": session.width,
|
"width": session.width,
|
||||||
"height": session.height,
|
"height": session.height,
|
||||||
"channels": session.channels,
|
"channels": session.channels,
|
||||||
"color_mode": session.color_mode,
|
"color_mode": session.color_mode,
|
||||||
"original_histogram": hist,
|
"original_histogram": hist,
|
||||||
|
"histogram": state.histogram,
|
||||||
"expires_at": session.expires_at.isoformat(),
|
"expires_at": session.expires_at.isoformat(),
|
||||||
|
"states": [image_state_payload(state=state, include_image=True)],
|
||||||
}
|
}
|
||||||
payload.update(payload_for_image(image, relative_path))
|
payload.update(payload_for_image(image, relative_path))
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def image_state_create(*, session, parent, image, operation, params, label=None, prefix="state"):
|
||||||
|
relative_path = save_image_array(image, prefix)
|
||||||
|
sequence = session.states.count()
|
||||||
|
state = ImageState.objects.create(
|
||||||
|
session=session,
|
||||||
|
parent=parent,
|
||||||
|
sequence=sequence,
|
||||||
|
label=label or f"S{sequence} {operation}",
|
||||||
|
operation=operation,
|
||||||
|
params=params or {},
|
||||||
|
image=relative_path,
|
||||||
|
width=image.shape[1],
|
||||||
|
height=image.shape[0],
|
||||||
|
channels=image.shape[2] if image.ndim == 3 else 1,
|
||||||
|
color_mode="RGB" if image.ndim == 3 else "L",
|
||||||
|
histogram=histogram_payload(image),
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def image_state_payload(*, state, include_image=True):
|
||||||
|
payload = {
|
||||||
|
"state_id": str(state.id),
|
||||||
|
"session_id": str(state.session_id),
|
||||||
|
"parent_state_id": str(state.parent_id) if state.parent_id else None,
|
||||||
|
"sequence": state.sequence,
|
||||||
|
"label": state.label,
|
||||||
|
"operation": state.operation,
|
||||||
|
"params": state.params,
|
||||||
|
"width": state.width,
|
||||||
|
"height": state.height,
|
||||||
|
"channels": state.channels,
|
||||||
|
"color_mode": state.color_mode,
|
||||||
|
"histogram": state.histogram,
|
||||||
|
"created_at": state.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
if include_image:
|
||||||
|
image = load_image_array(state.image)
|
||||||
|
payload.update(payload_for_image(image, state.image))
|
||||||
|
else:
|
||||||
|
payload["image_path"] = state.image
|
||||||
|
payload["image_url"] = f"{settings.MEDIA_URL}{state.image}"
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def image_states_payload(*, states):
|
||||||
|
return [image_state_payload(state=state, include_image=True) for state in states]
|
||||||
|
|
||||||
|
|
||||||
|
def image_state_apply_operation(*, state, operation, params):
|
||||||
|
if state.session.expired:
|
||||||
|
raise ProcessingError("Image session has expired.")
|
||||||
|
source = load_image_array(state.image)
|
||||||
|
result = apply_registered_operation(source, operation, params or {})
|
||||||
|
new_state = image_state_create(
|
||||||
|
session=state.session,
|
||||||
|
parent=state,
|
||||||
|
image=result,
|
||||||
|
operation=operation,
|
||||||
|
params=params or {},
|
||||||
|
label=None,
|
||||||
|
prefix=f"state-{operation}",
|
||||||
|
)
|
||||||
|
return image_state_payload(state=new_state, include_image=True)
|
||||||
|
|
||||||
|
|
||||||
|
def combine_states(*, states, operation, params=None):
|
||||||
|
params = params or {}
|
||||||
|
if len(states) < 2:
|
||||||
|
raise ProcessingError("At least two states are required.")
|
||||||
|
session = states[0].session
|
||||||
|
if any(state.session_id != session.id for state in states):
|
||||||
|
raise ProcessingError("All states must belong to the same session.")
|
||||||
|
images = [load_image_array(state.image) for state in states]
|
||||||
|
verify_registration(images)
|
||||||
|
|
||||||
|
if operation == "average":
|
||||||
|
result = average_images(images)
|
||||||
|
elif operation == "add":
|
||||||
|
result = np_clip_sum(images)
|
||||||
|
elif operation == "subtract":
|
||||||
|
result = normalize_to_uint8(images[0].astype("float32") - images[1].astype("float32"))
|
||||||
|
elif operation == "dot_product":
|
||||||
|
result = normalize_to_uint8(np.prod([image.astype("float32") / 255.0 for image in images], axis=0))
|
||||||
|
elif operation == "and":
|
||||||
|
result = images[0].copy()
|
||||||
|
for image in images[1:]:
|
||||||
|
result = result & image
|
||||||
|
elif operation == "or":
|
||||||
|
result = images[0].copy()
|
||||||
|
for image in images[1:]:
|
||||||
|
result = result | image
|
||||||
|
else:
|
||||||
|
raise ProcessingError(f"Unsupported combine operation '{operation}'.")
|
||||||
|
|
||||||
|
new_state = image_state_create(
|
||||||
|
session=session,
|
||||||
|
parent=states[0],
|
||||||
|
image=result,
|
||||||
|
operation=f"combine_{operation}",
|
||||||
|
params={**params, "state_ids": [str(state.id) for state in states]},
|
||||||
|
label=None,
|
||||||
|
prefix=f"state-combine-{operation}",
|
||||||
|
)
|
||||||
|
return image_state_payload(state=new_state, include_image=True)
|
||||||
|
|
||||||
|
|
||||||
|
def np_clip_sum(images):
|
||||||
|
total = np.zeros_like(images[0], dtype="float32")
|
||||||
|
for image in images:
|
||||||
|
total += image.astype("float32")
|
||||||
|
return np.clip(total, 0, 255).astype("uint8")
|
||||||
|
|
||||||
|
|
||||||
def image_session_process(*, session, operation, params):
|
def image_session_process(*, session, operation, params):
|
||||||
if session.expired:
|
if session.expired:
|
||||||
raise ProcessingError("Image session has expired.")
|
raise ProcessingError("Image session has expired.")
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ def cleanup_expired_sessions():
|
|||||||
for session in expired:
|
for session in expired:
|
||||||
delete_relative_file(session.original_image)
|
delete_relative_file(session.original_image)
|
||||||
delete_relative_file(session.processed_image)
|
delete_relative_file(session.processed_image)
|
||||||
|
for state in session.states.all():
|
||||||
|
delete_relative_file(state.image)
|
||||||
expired.delete()
|
expired.delete()
|
||||||
|
|
||||||
old_jobs = ProcessingJob.objects.filter(created_at__lt=timezone.now() - timezone.timedelta(hours=24))
|
old_jobs = ProcessingJob.objects.filter(created_at__lt=timezone.now() - timezone.timedelta(hours=24))
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import BatchView, HealthView, ImageUploadView, JobDetailView, ProcessView
|
from .views import (
|
||||||
|
BatchView,
|
||||||
|
HealthView,
|
||||||
|
ImageUploadView,
|
||||||
|
JobDetailView,
|
||||||
|
OperationsView,
|
||||||
|
ProcessView,
|
||||||
|
SessionStatesView,
|
||||||
|
StateCombineView,
|
||||||
|
StateHistogramView,
|
||||||
|
StateOperationView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("health/", HealthView.as_view(), name="health"),
|
path("health/", HealthView.as_view(), name="health"),
|
||||||
path("images/", ImageUploadView.as_view(), name="image-upload"),
|
path("images/", ImageUploadView.as_view(), name="image-upload"),
|
||||||
|
path("operations/", OperationsView.as_view(), name="operations"),
|
||||||
|
path("sessions/<uuid:session_id>/states/", SessionStatesView.as_view(), name="session-states"),
|
||||||
|
path("states/<uuid:state_id>/operations/", StateOperationView.as_view(), name="state-operation"),
|
||||||
|
path("states/<uuid:state_id>/histogram/", StateHistogramView.as_view(), name="state-histogram"),
|
||||||
|
path("states/combine/", StateCombineView.as_view(), name="state-combine"),
|
||||||
path("process/", ProcessView.as_view(), name="process"),
|
path("process/", ProcessView.as_view(), name="process"),
|
||||||
path("batch/", BatchView.as_view(), name="batch"),
|
path("batch/", BatchView.as_view(), name="batch"),
|
||||||
path("jobs/<uuid:job_id>/", JobDetailView.as_view(), name="job-detail"),
|
path("jobs/<uuid:job_id>/", JobDetailView.as_view(), name="job-detail"),
|
||||||
|
|||||||
@@ -3,8 +3,18 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .algorithms import ProcessingError
|
from .algorithms import ProcessingError
|
||||||
from .selectors import image_session_get, processing_job_get
|
from .registry import operation_metadata
|
||||||
from .services import batch_job_create, image_session_create, image_session_process, processing_job_payload
|
from .selectors import image_session_get, image_state_get, image_states_list, processing_job_get
|
||||||
|
from .services import (
|
||||||
|
batch_job_create,
|
||||||
|
combine_states,
|
||||||
|
image_session_create,
|
||||||
|
image_session_process,
|
||||||
|
image_state_apply_operation,
|
||||||
|
image_state_payload,
|
||||||
|
image_states_payload,
|
||||||
|
processing_job_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def error_response(message, code=status.HTTP_400_BAD_REQUEST):
|
def error_response(message, code=status.HTTP_400_BAD_REQUEST):
|
||||||
@@ -59,6 +69,73 @@ class ProcessView(APIView):
|
|||||||
return error_response(str(exc), code)
|
return error_response(str(exc), code)
|
||||||
|
|
||||||
|
|
||||||
|
class OperationsView(APIView):
|
||||||
|
def get(self, request):
|
||||||
|
return Response({"operations": operation_metadata()})
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStatesView(APIView):
|
||||||
|
def get(self, request, session_id):
|
||||||
|
session = image_session_get(session_id=session_id)
|
||||||
|
if session is None:
|
||||||
|
return error_response("Image session does not exist.", status.HTTP_404_NOT_FOUND)
|
||||||
|
return Response({"session_id": str(session.id), "states": image_states_payload(states=image_states_list(session_id=session.id))})
|
||||||
|
|
||||||
|
|
||||||
|
class StateOperationView(APIView):
|
||||||
|
class InputSerializer(serializers.Serializer):
|
||||||
|
operation = serializers.CharField()
|
||||||
|
params = serializers.DictField(required=False, default=dict)
|
||||||
|
|
||||||
|
def post(self, request, state_id):
|
||||||
|
serializer = self.InputSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
state = image_state_get(state_id=state_id)
|
||||||
|
if state is None:
|
||||||
|
return error_response("Image state does not exist.", status.HTTP_404_NOT_FOUND)
|
||||||
|
try:
|
||||||
|
payload = image_state_apply_operation(
|
||||||
|
state=state,
|
||||||
|
operation=serializer.validated_data["operation"],
|
||||||
|
params=serializer.validated_data.get("params", {}),
|
||||||
|
)
|
||||||
|
return Response(payload, status=status.HTTP_201_CREATED)
|
||||||
|
except ProcessingError as exc:
|
||||||
|
code = status.HTTP_410_GONE if str(exc) == "Image session has expired." else status.HTTP_400_BAD_REQUEST
|
||||||
|
return error_response(str(exc), code)
|
||||||
|
|
||||||
|
|
||||||
|
class StateCombineView(APIView):
|
||||||
|
class InputSerializer(serializers.Serializer):
|
||||||
|
operation = serializers.ChoiceField(choices=["add", "subtract", "dot_product", "average", "and", "or"])
|
||||||
|
state_ids = serializers.ListField(child=serializers.UUIDField(), min_length=2)
|
||||||
|
params = serializers.DictField(required=False, default=dict)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
serializer = self.InputSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
states = [image_state_get(state_id=state_id) for state_id in serializer.validated_data["state_ids"]]
|
||||||
|
if any(state is None for state in states):
|
||||||
|
return error_response("One or more image states do not exist.", status.HTTP_404_NOT_FOUND)
|
||||||
|
try:
|
||||||
|
payload = combine_states(
|
||||||
|
states=states,
|
||||||
|
operation=serializer.validated_data["operation"],
|
||||||
|
params=serializer.validated_data.get("params", {}),
|
||||||
|
)
|
||||||
|
return Response(payload, status=status.HTTP_201_CREATED)
|
||||||
|
except ProcessingError as exc:
|
||||||
|
return error_response(str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
class StateHistogramView(APIView):
|
||||||
|
def get(self, request, state_id):
|
||||||
|
state = image_state_get(state_id=state_id)
|
||||||
|
if state is None:
|
||||||
|
return error_response("Image state does not exist.", status.HTTP_404_NOT_FOUND)
|
||||||
|
return Response({"state_id": str(state.id), "histogram": state.histogram, "state": image_state_payload(state=state, include_image=False)})
|
||||||
|
|
||||||
|
|
||||||
class BatchView(APIView):
|
class BatchView(APIView):
|
||||||
class InputSerializer(serializers.Serializer):
|
class InputSerializer(serializers.Serializer):
|
||||||
operation = serializers.ChoiceField(choices=["average", "subtract"])
|
operation = serializers.ChoiceField(choices=["average", "subtract"])
|
||||||
|
|||||||
@@ -1,22 +1,50 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import CanvasPane from "./components/CanvasPane.jsx";
|
import CanvasPane, { CanvasThumbnail } from "./components/CanvasPane.jsx";
|
||||||
import Controls from "./components/Controls.jsx";
|
import Controls from "./components/Controls.jsx";
|
||||||
import HistogramPanel from "./components/HistogramPanel.jsx";
|
import HistogramPanel from "./components/HistogramPanel.jsx";
|
||||||
import { createBatch, getJob, processImage, uploadImage } from "./lib/api.js";
|
import { applyStateOperation, combineStates, getOperations, listStates, uploadImage } from "./lib/api.js";
|
||||||
import { useDebouncedEffect } from "./lib/debounce.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);
|
||||||
const [processed, setProcessed] = useState(null);
|
const [states, setStates] = useState([]);
|
||||||
const [batchSessions, setBatchSessions] = useState([]);
|
const [activeState, setActiveState] = useState(null);
|
||||||
const [operation, setOperation] = useState("gamma");
|
const [operations, setOperations] = useState([]);
|
||||||
const [params, setParams] = useState({ gamma: 1 });
|
const [selectedOperation, setSelectedOperation] = useState("");
|
||||||
|
const [params, setParams] = useState({});
|
||||||
|
const [selectedStateIds, setSelectedStateIds] = useState([]);
|
||||||
const [status, setStatus] = useState("Upload an image to begin.");
|
const [status, setStatus] = useState("Upload an image to begin.");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
|
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
|
||||||
|
|
||||||
const originalHistogram = session?.original_histogram;
|
useEffect(() => {
|
||||||
const processedHistogram = processed?.processed_histogram || processed?.result_histogram;
|
getOperations()
|
||||||
|
.then((payload) => {
|
||||||
|
setOperations(payload.operations || []);
|
||||||
|
const first = payload.operations?.find((operation) => operation.id === "crop") || payload.operations?.[0];
|
||||||
|
if (first) {
|
||||||
|
setSelectedOperation(first.id);
|
||||||
|
setParams(Object.fromEntries(Object.entries(first.params || {}).map(([key, schema]) => [key, schema.default])));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => setStatus(error.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function refreshStates(sessionId, nextActiveId = null) {
|
||||||
|
const payload = await listStates(sessionId);
|
||||||
|
setStates(payload.states || []);
|
||||||
|
const nextActive = payload.states?.find((state) => state.state_id === nextActiveId) || payload.states?.at(-1) || null;
|
||||||
|
setActiveState(nextActive);
|
||||||
|
return nextActive;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUpload(file) {
|
async function handleUpload(file) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -25,10 +53,13 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const payload = await uploadImage(file);
|
const payload = await uploadImage(file);
|
||||||
setSession(payload);
|
setSession(payload);
|
||||||
setProcessed(null);
|
const initialStates = payload.states || [];
|
||||||
setBatchSessions([payload.session_id]);
|
setStates(initialStates);
|
||||||
|
setActiveState(initialStates[0] || null);
|
||||||
|
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.`);
|
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 {
|
||||||
@@ -36,14 +67,24 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleBatchUpload(file) {
|
function handleSelectOperation(operationId, nextParams) {
|
||||||
if (!file) return;
|
setSelectedOperation(operationId);
|
||||||
|
setParams(operationId === "crop" ? { ...nextParams, ...defaultCropParams(activeState) } : nextParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleParamChange(key, value) {
|
||||||
|
setParams((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
if (!activeState || !selectedOperation) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatus("Uploading batch image...");
|
setStatus(`Applying ${selectedOperation} to ${activeState.label}...`);
|
||||||
try {
|
try {
|
||||||
const payload = await uploadImage(file);
|
const state = await applyStateOperation(activeState.state_id, selectedOperation, params);
|
||||||
setBatchSessions((current) => [...current, payload.session_id]);
|
await refreshStates(state.session_id, state.state_id);
|
||||||
setStatus("Batch image added.");
|
setSelectedStateIds([state.state_id]);
|
||||||
|
setStatus(`${state.label} created.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error.message);
|
setStatus(error.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -51,14 +92,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runBatch(kind) {
|
async function handleCombine(kind) {
|
||||||
|
if (selectedStateIds.length < 2) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatus(`Starting ${kind} job...`);
|
setStatus(`Combining ${selectedStateIds.length} states using ${kind}...`);
|
||||||
try {
|
try {
|
||||||
const job = await createBatch(kind, batchSessions);
|
const state = await combineStates(kind, selectedStateIds);
|
||||||
const result = await pollJob(job.job_id);
|
await refreshStates(state.session_id, state.state_id);
|
||||||
setProcessed(result);
|
setSelectedStateIds([state.state_id]);
|
||||||
setStatus(`${kind} complete.`);
|
setStatus(`${state.label} created.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error.message);
|
setStatus(error.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -66,86 +108,53 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollJob(jobId) {
|
function toggleCombineState(stateId) {
|
||||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
setSelectedStateIds((current) => current.includes(stateId) ? current.filter((id) => id !== stateId) : [...current, stateId]);
|
||||||
const job = await getJob(jobId);
|
|
||||||
setStatus(`Job ${job.status}: ${job.progress}%`);
|
|
||||||
if (job.status === "complete") return job;
|
|
||||||
if (job.status === "failed") throw new Error(job.error || "Batch job failed");
|
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
throw new Error("Batch job timed out.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useDebouncedEffect(
|
const originalState = states[0] || null;
|
||||||
() => {
|
|
||||||
if (!session?.session_id || !operation) return;
|
|
||||||
let cancelled = false;
|
|
||||||
async function run() {
|
|
||||||
setBusy(true);
|
|
||||||
setStatus(`Processing ${operation}...`);
|
|
||||||
try {
|
|
||||||
const payload = await processImage(session.session_id, operation, params);
|
|
||||||
if (!cancelled) {
|
|
||||||
setProcessed(payload);
|
|
||||||
setStatus(`${operation} complete in ${payload.elapsed_ms} ms.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!cancelled) setStatus(error.message);
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[session?.session_id, operation, JSON.stringify(params)],
|
|
||||||
300
|
|
||||||
);
|
|
||||||
|
|
||||||
const processedImage = processed?.image_data || session?.image_data;
|
|
||||||
const originalImage = session?.image_data;
|
|
||||||
|
|
||||||
const viewportTitle = useMemo(() => {
|
const viewportTitle = useMemo(() => {
|
||||||
if (!session) return "No image";
|
if (!activeState) return "No active state";
|
||||||
return `${session.width} x ${session.height} ${session.color_mode}`;
|
return `${activeState.label} · ${activeState.width} x ${activeState.height} ${activeState.color_mode}`;
|
||||||
}, [session]);
|
}, [activeState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:flex-row">
|
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:flex-row">
|
||||||
<Controls
|
<Controls
|
||||||
selected={operation}
|
operations={operations}
|
||||||
|
selectedOperation={selectedOperation}
|
||||||
params={params}
|
params={params}
|
||||||
onOperationChange={(nextOperation, nextParams) => {
|
states={states}
|
||||||
setOperation(nextOperation);
|
activeState={activeState}
|
||||||
setParams(nextParams);
|
selectedStateIds={selectedStateIds}
|
||||||
}}
|
|
||||||
onParamChange={(key, value) => setParams((current) => ({ ...current, [key]: value }))}
|
|
||||||
onUpload={handleUpload}
|
|
||||||
onBatchUpload={handleBatchUpload}
|
|
||||||
batchCount={batchSessions.length}
|
|
||||||
onBatchRun={runBatch}
|
|
||||||
disabled={!session || busy}
|
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
onUpload={handleUpload}
|
||||||
|
onSelectOperation={handleSelectOperation}
|
||||||
|
onParamChange={handleParamChange}
|
||||||
|
onApply={handleApply}
|
||||||
|
onSelectState={(state) => {
|
||||||
|
setActiveState(state);
|
||||||
|
if (selectedOperation === "crop") setParams(defaultCropParams(state));
|
||||||
|
}}
|
||||||
|
onToggleCombineState={toggleCombineState}
|
||||||
|
onCombine={handleCombine}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="flex min-h-0 flex-1 flex-col">
|
<main className="flex min-h-0 flex-1 flex-col">
|
||||||
<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">Professional Dark Studio</p>
|
<p className="text-xs uppercase tracking-[0.18em] text-cyan-300">Professor Slide Workspace</p>
|
||||||
<h2 className="text-sm font-medium text-zinc-200">{viewportTitle}</h2>
|
<h2 className="text-sm font-medium text-zinc-200">{viewportTitle}</h2>
|
||||||
</div>
|
</div>
|
||||||
<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="grid min-h-0 flex-1 grid-cols-1 gap-px bg-zinc-800 lg:grid-cols-2">
|
<div className="relative min-h-0 flex-1 bg-zinc-800">
|
||||||
<CanvasPane title="Original" imageData={originalImage} histogram={originalHistogram} transform={transform} onTransform={setTransform} />
|
<CanvasThumbnail title="S0 Original" imageData={originalState?.image_data} />
|
||||||
<CanvasPane title="Processed" imageData={processedImage} histogram={processedHistogram} transform={transform} onTransform={setTransform} />
|
<CanvasPane title="Active State" imageData={activeState?.image_data} histogram={activeState?.histogram} transform={transform} onTransform={setTransform} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HistogramPanel original={originalHistogram} processed={processedHistogram} />
|
<HistogramPanel original={originalState?.histogram} processed={activeState?.histogram} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
|
|
||||||
|
vi.mock("./lib/api.js", () => ({
|
||||||
|
getOperations: () => Promise.resolve({ operations: [] }),
|
||||||
|
listStates: () => Promise.resolve({ states: [] }),
|
||||||
|
uploadImage: vi.fn(),
|
||||||
|
applyStateOperation: vi.fn(),
|
||||||
|
combineStates: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
describe("App real render", () => {
|
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("Spatial Image Enhancer Pro")).toBeInTheDocument();
|
expect(screen.getByText("Academic Image Processing Workspace")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,11 +5,19 @@ vi.mock("react-quick-pinch-zoom", () => ({
|
|||||||
default: ({ children }) => <div>{children}</div>
|
default: ({ children }) => <div>{children}</div>
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./lib/api.js", () => ({
|
||||||
|
getOperations: () => Promise.resolve({ operations: [] }),
|
||||||
|
listStates: () => Promise.resolve({ states: [] }),
|
||||||
|
uploadImage: vi.fn(),
|
||||||
|
applyStateOperation: vi.fn(),
|
||||||
|
combineStates: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
describe("App", () => {
|
describe("App", () => {
|
||||||
it("renders the processing studio immediately", () => {
|
it("renders the academic workspace immediately", () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
expect(screen.getByText("Spatial Image Enhancer Pro")).toBeInTheDocument();
|
expect(screen.getByText("Academic Image Processing Workspace")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("Original").length).toBeGreaterThan(0);
|
expect(screen.getByText("Image States")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("Processed").length).toBeGreaterThan(0);
|
expect(screen.getByText("Combine Selected States")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,3 +47,22 @@ export default function CanvasPane({ title, imageData, histogram, transform, onT
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CanvasThumbnail({ title, imageData }) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
drawToCanvas(canvasRef.current, imageData);
|
||||||
|
}, [imageData]);
|
||||||
|
|
||||||
|
if (!imageData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute left-4 top-4 z-10 w-40 border border-zinc-700 bg-zinc-950/95 shadow-2xl shadow-black/50 md:w-52">
|
||||||
|
<div className="border-b border-zinc-800 px-3 py-2 text-xs font-semibold text-zinc-100">{title}</div>
|
||||||
|
<div className="max-h-40 overflow-hidden bg-black md:max-h-52">
|
||||||
|
<canvas ref={canvasRef} className="block h-auto w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,93 +1,111 @@
|
|||||||
import { Activity, Aperture, Blend, Layers, SlidersHorizontal, Upload } from "lucide-react";
|
import { Combine, Crop, Layers, SlidersHorizontal, Upload } from "lucide-react";
|
||||||
|
|
||||||
const groups = [
|
function defaultParams(operation) {
|
||||||
{
|
return Object.fromEntries(
|
||||||
title: "Intensity",
|
Object.entries(operation?.params || {}).map(([key, schema]) => [key, schema.default])
|
||||||
icon: SlidersHorizontal,
|
);
|
||||||
operations: [
|
}
|
||||||
{ id: "negative", label: "Negative", params: [] },
|
|
||||||
{ id: "log", label: "Log", params: [{ key: "c", label: "c", min: 0.1, max: 3, step: 0.05, default: 1.44 }] },
|
function groupOperations(operations) {
|
||||||
{ id: "gamma", label: "Gamma", params: [{ key: "gamma", label: "Gamma", min: 0.1, max: 4, step: 0.05, default: 1 }] },
|
return operations.reduce((acc, operation) => {
|
||||||
{
|
acc[operation.chapter] ||= {};
|
||||||
id: "contrast_stretch",
|
acc[operation.chapter][operation.slide_group] ||= [];
|
||||||
label: "Contrast Stretch",
|
acc[operation.chapter][operation.slide_group].push(operation);
|
||||||
params: [
|
return acc;
|
||||||
{ key: "low", label: "Low", min: 0, max: 254, step: 1, default: 30 },
|
}, {});
|
||||||
{ key: "high", label: "High", min: 1, max: 255, step: 1, default: 220 }
|
}
|
||||||
]
|
|
||||||
},
|
function ParamControl({ name, schema, value, onChange }) {
|
||||||
{
|
if (schema.type === "select") {
|
||||||
id: "gray_slice",
|
return (
|
||||||
label: "Gray Slice",
|
<label className="mb-3 block text-xs text-zinc-300">
|
||||||
params: [
|
<span className="mb-1 block">{name}</span>
|
||||||
{ key: "start", label: "Start", min: 0, max: 255, step: 1, default: 96 },
|
<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)}>
|
||||||
{ key: "end", label: "End", min: 0, max: 255, step: 1, default: 160 }
|
{schema.choices.map((choice) => (
|
||||||
]
|
<option key={choice} value={choice}>{choice}</option>
|
||||||
},
|
))}
|
||||||
{ id: "bit_plane", label: "Bit Plane", params: [{ key: "bit", label: "Bit", min: 0, max: 7, step: 1, default: 7 }] }
|
</select>
|
||||||
]
|
</label>
|
||||||
},
|
);
|
||||||
{
|
|
||||||
title: "Histogram",
|
|
||||||
icon: Activity,
|
|
||||||
operations: [
|
|
||||||
{ id: "hist_equalization", label: "Global Equalization", params: [] },
|
|
||||||
{ id: "hist_match", label: "Match Uniform", params: [] },
|
|
||||||
{ id: "local_equalization", label: "Local Equalization", params: [{ key: "size", label: "Window", min: 3, max: 31, step: 2, default: 7 }] }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Spatial Filters",
|
|
||||||
icon: Aperture,
|
|
||||||
operations: [
|
|
||||||
{ id: "box_filter", label: "Box", params: [{ key: "size", label: "Size", min: 3, max: 35, step: 2, default: 3 }] },
|
|
||||||
{ id: "weighted_average", label: "Weighted Avg", params: [{ key: "size", label: "Size", min: 3, max: 35, step: 2, default: 3 }] },
|
|
||||||
{ id: "median_filter", label: "Median", params: [{ key: "size", label: "Size", min: 3, max: 15, step: 2, default: 3 }] },
|
|
||||||
{ id: "laplacian", label: "Laplacian", params: [] },
|
|
||||||
{
|
|
||||||
id: "high_boost",
|
|
||||||
label: "High Boost",
|
|
||||||
params: [
|
|
||||||
{ key: "amplification", label: "A", min: 1, max: 5, step: 0.1, default: 1.5 },
|
|
||||||
{ key: "size", label: "Size", min: 3, max: 35, step: 2, default: 3 }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{ id: "sobel", label: "Sobel", params: [] },
|
|
||||||
{ id: "roberts", label: "Roberts", params: [] }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Color",
|
|
||||||
icon: Blend,
|
|
||||||
operations: [
|
|
||||||
{ id: "pseudo_color_slices", label: "Intensity Slices", params: [] },
|
|
||||||
{ id: "gray_to_color_sinusoidal", label: "HSI Sinusoids", params: [{ key: "hue_frequency", label: "Hue Freq", min: 0.2, max: 4, step: 0.1, default: 1 }] },
|
|
||||||
{ id: "hsi_intensity_filter", label: "HSI Smooth I", params: [{ key: "size", label: "Size", min: 3, max: 15, step: 2, default: 3 }] }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
];
|
if (schema.type === "bool") {
|
||||||
|
return (
|
||||||
function initialParams(operation) {
|
<label className="mb-3 flex items-center gap-2 text-xs text-zinc-300">
|
||||||
return Object.fromEntries(operation.params.map((param) => [param.key, param.default]));
|
<input type="checkbox" checked={Boolean(value ?? schema.default)} onChange={(event) => onChange(name, event.target.checked)} />
|
||||||
|
{name}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<label className="mb-4 block">
|
||||||
|
<div className="mb-2 flex items-center justify-between text-xs text-zinc-300">
|
||||||
|
<span>{name}{schema.odd ? " (odd)" : ""}</span>
|
||||||
|
<input
|
||||||
|
className="w-20 border border-zinc-700 bg-zinc-950 px-2 py-1 text-right tabular-nums"
|
||||||
|
type="number"
|
||||||
|
min={schema.min}
|
||||||
|
max={schema.max}
|
||||||
|
step={schema.step}
|
||||||
|
value={value ?? schema.default}
|
||||||
|
onChange={(event) => onChange(name, schema.type === "int" ? parseInt(event.target.value || schema.default, 10) : Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={schema.min}
|
||||||
|
max={schema.max}
|
||||||
|
step={schema.step}
|
||||||
|
value={value ?? schema.default}
|
||||||
|
onChange={(event) => onChange(name, schema.type === "int" ? parseInt(event.target.value, 10) : Number(event.target.value))}
|
||||||
|
className="w-full accent-cyan-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Controls({
|
export default function Controls({
|
||||||
selected,
|
operations,
|
||||||
|
selectedOperation,
|
||||||
params,
|
params,
|
||||||
onOperationChange,
|
states,
|
||||||
onParamChange,
|
activeState,
|
||||||
|
selectedStateIds,
|
||||||
|
busy,
|
||||||
onUpload,
|
onUpload,
|
||||||
onBatchUpload,
|
onSelectOperation,
|
||||||
batchCount,
|
onParamChange,
|
||||||
onBatchRun,
|
onApply,
|
||||||
disabled,
|
onSelectState,
|
||||||
busy
|
onToggleCombineState,
|
||||||
|
onCombine,
|
||||||
}) {
|
}) {
|
||||||
|
const grouped = groupOperations(operations);
|
||||||
|
const operation = operations.find((item) => item.id === selectedOperation);
|
||||||
|
|
||||||
|
function renderParameterDrawer(item) {
|
||||||
|
if (selectedOperation !== item.id) return null;
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<SlidersHorizontal size={14} />
|
||||||
|
Parameters
|
||||||
|
</div>
|
||||||
|
{Object.entries(item.params || {}).map(([name, schema]) => (
|
||||||
|
<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}
|
||||||
|
<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">
|
||||||
|
Apply to Active State
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-full flex-col border-r border-zinc-800 bg-zinc-950 lg:w-[360px]">
|
<aside className="flex h-full w-full flex-col border-r border-zinc-800 bg-zinc-950 lg:w-[430px]">
|
||||||
<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 tracking-normal text-zinc-50">Spatial Image Enhancer Pro</h1>
|
<h1 className="text-lg font-semibold text-zinc-50">Academic Image Processing Workspace</h1>
|
||||||
<p className="mt-1 text-xs text-zinc-400">Vectorized spatial-domain processing studio</p>
|
<p className="mt-1 text-xs text-zinc-400">MATLAB-like states organized by lecture chapters</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 border-b border-zinc-800 p-4">
|
<div className="space-y-3 border-b border-zinc-800 p-4">
|
||||||
@@ -96,72 +114,71 @@ export default function Controls({
|
|||||||
Upload Image
|
Upload Image
|
||||||
<input type="file" accept="image/*" className="hidden" onChange={(event) => onUpload(event.target.files?.[0])} />
|
<input type="file" accept="image/*" className="hidden" onChange={(event) => onUpload(event.target.files?.[0])} />
|
||||||
</label>
|
</label>
|
||||||
<label className="flex cursor-pointer items-center justify-center gap-2 border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm font-medium text-zinc-100 hover:bg-zinc-800">
|
|
||||||
<Layers size={16} />
|
|
||||||
Add Batch Image ({batchCount})
|
|
||||||
<input type="file" accept="image/*" className="hidden" onChange={(event) => onBatchUpload(event.target.files?.[0])} />
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button disabled={batchCount < 2 || busy} onClick={() => onBatchRun("average")} className="border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-40">
|
|
||||||
Average
|
|
||||||
</button>
|
|
||||||
<button disabled={batchCount < 2 || busy} onClick={() => onBatchRun("subtract")} className="border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-40">
|
|
||||||
Subtract
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="border-b border-zinc-800 p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||||
|
<Layers size={16} className="text-cyan-300" />
|
||||||
|
Image States
|
||||||
|
</div>
|
||||||
|
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||||
|
{states.length === 0 ? <p className="text-sm text-zinc-500">No states yet.</p> : states.map((state) => (
|
||||||
|
<div key={state.state_id} className={`flex items-center gap-2 border p-2 ${activeState?.state_id === state.state_id ? "border-emerald-500 bg-emerald-950/30" : "border-zinc-800 bg-zinc-900"}`}>
|
||||||
|
<input type="checkbox" checked={selectedStateIds.includes(state.state_id)} onChange={() => onToggleCombineState(state.state_id)} />
|
||||||
|
<button className="min-w-0 flex-1 text-left" onClick={() => onSelectState(state)}>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-b border-zinc-800 p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||||
|
<Combine size={16} className="text-cyan-300" />
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
{groups.map((group) => {
|
{Object.entries(grouped).map(([chapter, groups]) => (
|
||||||
const Icon = group.icon;
|
<details key={chapter} open={chapter.includes("Chapter 3")} className="mb-3 border border-zinc-800 bg-zinc-900/60">
|
||||||
return (
|
<summary className="cursor-pointer px-3 py-3 text-sm font-semibold text-zinc-100">{chapter}</summary>
|
||||||
<details key={group.title} open className="mb-3 border border-zinc-800 bg-zinc-900/60">
|
<div className="space-y-3 border-t border-zinc-800 p-3">
|
||||||
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-3 text-sm font-semibold text-zinc-100">
|
{Object.entries(groups).map(([slideGroup, items]) => (
|
||||||
<Icon size={16} className="text-cyan-300" />
|
<details key={slideGroup} className="border border-zinc-800 bg-zinc-950/70">
|
||||||
{group.title}
|
<summary className="cursor-pointer px-3 py-2 text-xs font-semibold text-cyan-200">{slideGroup}</summary>
|
||||||
</summary>
|
<div className="grid grid-cols-1 gap-2 p-2">
|
||||||
<div className="space-y-2 border-t border-zinc-800 p-3">
|
{items.map((item) => (
|
||||||
{group.operations.map((operation) => (
|
<div key={item.id} className="space-y-2">
|
||||||
<button
|
<button
|
||||||
key={operation.id}
|
disabled={!activeState || busy}
|
||||||
disabled={disabled}
|
onClick={() => onSelectOperation(item.id, defaultParams(item))}
|
||||||
onClick={() => onOperationChange(operation.id, initialParams(operation))}
|
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 transition ${
|
>
|
||||||
selected === operation.id ? "border-emerald-500 bg-emerald-950/50 text-emerald-100" : "border-zinc-700 bg-zinc-950 text-zinc-200 hover:bg-zinc-800"
|
{item.id === "crop" ? <Crop size={14} className="mr-2 inline" /> : null}
|
||||||
} disabled:cursor-not-allowed disabled:opacity-40`}
|
{item.label}
|
||||||
>
|
</button>
|
||||||
{operation.label}
|
{renderParameterDrawer(item)}
|
||||||
</button>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
|
</details>
|
||||||
<div className="mt-4 border border-zinc-800 bg-zinc-900/60 p-3">
|
))}
|
||||||
<h2 className="mb-3 text-sm font-semibold text-zinc-100">Parameters</h2>
|
|
||||||
{groups
|
|
||||||
.flatMap((group) => group.operations)
|
|
||||||
.find((operation) => operation.id === selected)
|
|
||||||
?.params.map((param) => (
|
|
||||||
<label key={param.key} className="mb-4 block">
|
|
||||||
<div className="mb-2 flex items-center justify-between text-xs text-zinc-300">
|
|
||||||
<span>{param.label}</span>
|
|
||||||
<span className="tabular-nums text-zinc-400">{params[param.key] ?? param.default}</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={param.min}
|
|
||||||
max={param.max}
|
|
||||||
step={param.step}
|
|
||||||
value={params[param.key] ?? param.default}
|
|
||||||
onChange={(event) => onParamChange(param.key, Number(event.target.value))}
|
|
||||||
className="w-full accent-cyan-400"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)) || <p className="text-sm text-zinc-500">No tunable parameters for this operation.</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{operation ? null : <div className="border-t border-zinc-800 bg-zinc-950 p-4 text-sm text-zinc-500">Select an operation.</div>}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
|
||||||
|
function seriesFrom(histogram, fallbackKey = "intensity") {
|
||||||
|
if (Array.isArray(histogram)) return histogram;
|
||||||
|
return histogram?.[fallbackKey] || histogram?.intensity || [];
|
||||||
|
}
|
||||||
|
|
||||||
function toChartData(original, processed) {
|
function toChartData(original, processed) {
|
||||||
|
const originalSeries = seriesFrom(original);
|
||||||
|
const processedSeries = seriesFrom(processed);
|
||||||
|
const r = processed?.r || [];
|
||||||
|
const g = processed?.g || [];
|
||||||
|
const b = processed?.b || [];
|
||||||
return Array.from({ length: 256 }, (_, level) => ({
|
return Array.from({ length: 256 }, (_, level) => ({
|
||||||
level,
|
level,
|
||||||
original: original?.[level] ?? 0,
|
original: originalSeries?.[level] ?? 0,
|
||||||
processed: processed?.[level] ?? 0
|
processed: processedSeries?.[level] ?? 0,
|
||||||
|
r: r[level] ?? 0,
|
||||||
|
g: g[level] ?? 0,
|
||||||
|
b: b[level] ?? 0
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +29,7 @@ export default function HistogramPanel({ original, processed }) {
|
|||||||
<h2 className="text-sm font-semibold text-zinc-100">Histogram Analytics</h2>
|
<h2 className="text-sm font-semibold text-zinc-100">Histogram Analytics</h2>
|
||||||
<div className="flex gap-3 text-xs text-zinc-400">
|
<div className="flex gap-3 text-xs text-zinc-400">
|
||||||
<span className="text-cyan-300">Original</span>
|
<span className="text-cyan-300">Original</span>
|
||||||
<span className="text-emerald-300">Processed</span>
|
<span className="text-emerald-300">Active</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-40">
|
<div className="h-40">
|
||||||
@@ -28,6 +41,9 @@ export default function HistogramPanel({ original, processed }) {
|
|||||||
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", color: "#f4f4f5" }} />
|
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", color: "#f4f4f5" }} />
|
||||||
<Area type="monotone" dataKey="original" stroke="#67e8f9" fill="#0891b2" fillOpacity={0.22} dot={false} />
|
<Area type="monotone" dataKey="original" stroke="#67e8f9" fill="#0891b2" fillOpacity={0.22} dot={false} />
|
||||||
<Area type="monotone" dataKey="processed" stroke="#6ee7b7" fill="#059669" fillOpacity={0.24} dot={false} />
|
<Area type="monotone" dataKey="processed" stroke="#6ee7b7" fill="#059669" fillOpacity={0.24} dot={false} />
|
||||||
|
<Area type="monotone" dataKey="r" stroke="#f87171" fill="#ef4444" fillOpacity={0.08} dot={false} />
|
||||||
|
<Area type="monotone" dataKey="g" stroke="#4ade80" fill="#22c55e" fillOpacity={0.08} dot={false} />
|
||||||
|
<Area type="monotone" dataKey="b" stroke="#60a5fa" fill="#3b82f6" fillOpacity={0.08} dot={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,39 @@ export async function uploadImage(file) {
|
|||||||
return parseResponse(response);
|
return parseResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getOperations() {
|
||||||
|
const response = await fetch(`${API_BASE}/api/operations/`);
|
||||||
|
return parseResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listStates(sessionId) {
|
||||||
|
const response = await fetch(`${API_BASE}/api/sessions/${sessionId}/states/`);
|
||||||
|
return parseResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyStateOperation(stateId, operation, params = {}) {
|
||||||
|
const response = await fetch(`${API_BASE}/api/states/${stateId}/operations/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ operation, params })
|
||||||
|
});
|
||||||
|
return parseResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function combineStates(operation, stateIds, params = {}) {
|
||||||
|
const response = await fetch(`${API_BASE}/api/states/combine/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ operation, state_ids: stateIds, params })
|
||||||
|
});
|
||||||
|
return parseResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStateHistogram(stateId) {
|
||||||
|
const response = await fetch(`${API_BASE}/api/states/${stateId}/histogram/`);
|
||||||
|
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user