Compare commits

..

4 Commits

8 changed files with 155 additions and 34 deletions

View File

@@ -86,7 +86,7 @@ The app is organized as a small MATLAB-like image workspace. Each operation crea
### Chapter 4: Frequency Domain
- **FFT/DFT spectrum view** shows magnitude, log magnitude, or phase of the image in the frequency domain. Formula: `F(u,v) = DFT{f(x,y)}`. Apply it to a periodic-noisy state with `log_magnitude` to see the noise peaks.
- **FFT/DFT spectrum view** shows the log magnitude of the image in the frequency domain. Formula: `log(1 + |fftshift(fft2(f))|)`. Apply it to a periodic-noisy state to see the noise peaks.
- **Inverse FFT reconstruction** reconstructs from a previously saved FFT state. Formula: `f = real(ifft2(ifftshift(F)))`.
### Chapter 6: RGB Color Processing

View File

@@ -327,6 +327,7 @@ def gradient_abs_sum(image, params):
Use it to emphasize prominent edges before combining them with a sharpened image.
"""
grayscale_input = image.ndim == 2
operator = params.get("operator", "sobel")
gray = to_gray(image).astype(np.float32)
if operator == "roberts":
@@ -337,7 +338,8 @@ def gradient_abs_sum(image, params):
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)))
gradient = normalize_to_uint8(np.abs(fx) + np.abs(fy))
return gradient if grayscale_input else gray_to_rgb(gradient)
def rgb_channel(image, params):
@@ -354,19 +356,14 @@ def rgb_channel(image, params):
def fft_spectrum(image, params):
"""Display the DFT magnitude, log magnitude, or phase spectrum of an image.
"""Display the DFT log-magnitude spectrum of an image.
Use it to understand whether image information is concentrated in low or high frequencies.
"""
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)
magnitude = np.log1p(np.abs(spectrum))
return gray_to_rgb(normalize_to_uint8(magnitude))
@@ -436,7 +433,7 @@ OPERATIONS = [
kernel_pair_preview("Sobel", [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]),
], formula="Gradient image = abs(imfilter(f,Gx)) + abs(imfilter(f,Gy))."),
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("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("fft_spectrum", "FFT/DFT Spectrum View", CH4, "DFT and FFT", fft_spectrum, formula="Display log(1 + |fftshift(fft2(f))|).", repeatable=False),
operation("inverse_fft_reconstruction", "Inverse FFT Reconstruction", CH4, "DFT and FFT", inverse_fft_reconstruction, formula="f = real(ifft2(ifftshift(F))). Apply this to an FFT/DFT Spectrum View state.", repeatable=False),
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("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),

View File

@@ -165,6 +165,8 @@ def image_state_apply_operation(*, state, operation, params):
return image_state_fft_spectrum_create(state=state, params=params or {})
if operation == "inverse_fft_reconstruction":
return image_state_inverse_fft_create(state=state, params=params or {})
if operation == "average_noisy_copies":
return image_state_average_noisy_copies_create(state=state, params=params or {})
source = load_image_array(state.image)
result = apply_registered_operation(source, operation, params or {})
@@ -180,6 +182,40 @@ def image_state_apply_operation(*, state, operation, params):
return image_state_payload(state=new_state, include_image=True)
def image_state_average_noisy_copies_create(*, state, params):
"""Create averaged noisy copies from the clean source behind a Gaussian-noise state.
If the active state is already a Gaussian-noisy image, the averaging must regenerate independent
noisy copies from its parent image, not from the already-noisy pixels.
"""
source_state = state
if (
state.operation == "noise_filter"
and isinstance(state.params, dict)
and state.params.get("kind", "gaussian") == "gaussian"
and state.parent is not None
):
source_state = state.parent
operation_params = dict(params or {})
if source_state.id != state.id:
operation_params["source_state_id"] = str(source_state.id)
source = load_image_array(source_state.image)
result = apply_registered_operation(source, "average_noisy_copies", operation_params)
new_state = image_state_create(
session=state.session,
parent=state,
image=result,
operation="average_noisy_copies",
params=operation_params,
label=None,
prefix="state-average_noisy_copies",
)
return image_state_payload(state=new_state, include_image=True)
def image_state_fft_spectrum_create(*, state, params):
"""Create an FFT visualization state and persist the actual complex spectrum.
@@ -187,15 +223,12 @@ def image_state_fft_spectrum_create(*, state, params):
"""
source = load_image_array(state.image)
mode = params.get("mode", "log_magnitude")
if mode not in {"magnitude", "log_magnitude", "phase"}:
raise ProcessingError("FFT mode must be magnitude, log_magnitude, or phase.")
spectrum = image_fft(source)
preview = fft_preview_image(spectrum, mode)
preview = fft_preview_image(spectrum)
fft_data_path = save_fft_array(spectrum, "state-fft-data")
operation_params = {
**params,
"mode": mode,
**(params or {}),
"mode": "log_magnitude",
"fft_data_path": fft_data_path,
"source_state_id": str(state.id),
"source_color_mode": "RGB" if source.ndim == 3 else "L",
@@ -240,15 +273,10 @@ def image_fft(image):
return np.stack(channels, axis=2)
def fft_preview_image(spectrum, mode):
"""Convert complex FFT data to a display-only uint8 preview image."""
def fft_preview_image(spectrum):
"""Convert complex FFT data to a display-only log-magnitude uint8 preview image."""
if mode == "phase":
preview = np.angle(spectrum)
else:
preview = np.abs(spectrum)
if mode == "log_magnitude":
preview = np.log1p(preview)
preview = np.log1p(np.abs(spectrum))
return normalize_to_uint8(preview)

View File

@@ -83,6 +83,7 @@ class ApiTests(TestCase):
self.assertEqual(operations["periodic_noise"]["params"]["T"]["default"], 100)
self.assertEqual(operations["inverse_fft_reconstruction"]["label"], "Inverse FFT Reconstruction")
self.assertFalse(operations["inverse_fft_reconstruction"]["repeatable"])
self.assertEqual(operations["fft_spectrum"]["params"], {})
self.assertEqual(operations["box_filter"]["label"], "Average / Box Filter")
self.assertEqual(operations["gaussian_filter"]["label"], "Gaussian Filter")
self.assertFalse(operations["negative"]["repeatable"])
@@ -233,6 +234,29 @@ class ApiTests(TestCase):
self.assertEqual(result.ndim, 2)
self.assertEqual(int(result[0, 0]), 96)
def test_average_noisy_copies_uses_clean_parent_after_gaussian_noise_state(self):
upload = self.client.post("/api/images/", {"image": grayscale_png_upload(value=96)}, format="multipart")
s0_id = upload.data["states"][0]["state_id"]
noisy = self.client.post(
f"/api/states/{s0_id}/operations/",
{"operation": "noise_filter", "params": {"kind": "gaussian", "mean": 0.2, "variance": 0}},
format="json",
)
noisy_image = load_image_array(ImageState.objects.get(id=noisy.data["state_id"]).image)
self.assertGreater(int(noisy_image[0, 0]), 96)
averaged = self.client.post(
f"/api/states/{noisy.data['state_id']}/operations/",
{"operation": "average_noisy_copies", "params": {"N": 10, "kind": "gaussian", "mean": 0, "variance": 0}},
format="json",
)
self.assertEqual(averaged.status_code, 201)
self.assertEqual(averaged.data["parent_state_id"], noisy.data["state_id"])
self.assertEqual(averaged.data["params"]["source_state_id"], s0_id)
result = load_image_array(ImageState.objects.get(id=averaged.data["state_id"]).image)
self.assertEqual(int(result[0, 0]), 96)
def test_periodic_noise_preserves_grayscale_and_zero_amplitude(self):
upload = self.client.post("/api/images/", {"image": grayscale_png_upload(value=96)}, format="multipart")
s0_id = upload.data["states"][0]["state_id"]
@@ -289,7 +313,7 @@ class ApiTests(TestCase):
s0_id = upload.data["states"][0]["state_id"]
spectrum = self.client.post(
f"/api/states/{s0_id}/operations/",
{"operation": "fft_spectrum", "params": {"mode": "log_magnitude"}},
{"operation": "fft_spectrum", "params": {}},
format="json",
)
@@ -304,7 +328,7 @@ class ApiTests(TestCase):
s0_id = upload.data["states"][0]["state_id"]
spectrum = self.client.post(
f"/api/states/{s0_id}/operations/",
{"operation": "fft_spectrum", "params": {"mode": "log_magnitude"}},
{"operation": "fft_spectrum", "params": {}},
format="json",
)
response = self.client.post(
@@ -324,7 +348,7 @@ class ApiTests(TestCase):
s0_id = upload.data["states"][0]["state_id"]
spectrum = self.client.post(
f"/api/states/{s0_id}/operations/",
{"operation": "fft_spectrum", "params": {"mode": "log_magnitude"}},
{"operation": "fft_spectrum", "params": {}},
format="json",
)
response = self.client.post(
@@ -361,6 +385,40 @@ class ApiTests(TestCase):
self.assertEqual(noisy.data["channels"], 1)
self.assertEqual(noisy.data["color_mode"], "L")
def test_gradient_mask_from_grayscale_can_combine_with_grayscale_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",
)
gradient = self.client.post(
f"/api/states/{gray.data['state_id']}/operations/",
{"operation": "gradient_abs_sum", "params": {"operator": "sobel"}},
format="json",
)
self.assertEqual(gradient.status_code, 201)
self.assertEqual(gradient.data["channels"], 1)
self.assertEqual(gradient.data["color_mode"], "L")
added = self.client.post(
"/api/states/combine/",
{"operation": "add", "state_ids": [gray.data["state_id"], gradient.data["state_id"]]},
format="json",
)
subtracted = self.client.post(
"/api/states/combine/",
{"operation": "subtract", "state_ids": [gray.data["state_id"], gradient.data["state_id"]]},
format="json",
)
self.assertEqual(added.status_code, 201)
self.assertEqual(added.data["channels"], 1)
self.assertEqual(subtracted.status_code, 201)
self.assertEqual(subtracted.data["channels"], 1)
@patch("processing.services.run_batch_job.delay")
def test_batch_returns_job_id(self, delay):
first = self.client.post("/api/images/", {"image": png_upload(name="a.png")}, format="multipart")

View File

@@ -15,6 +15,7 @@
"react-dom": "latest",
"react-quick-pinch-zoom": "latest",
"recharts": "latest",
"sonner": "^2.0.7",
"vite": "latest"
},
"devDependencies": {
@@ -3027,6 +3028,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -17,7 +17,8 @@
"react-quick-pinch-zoom": "latest",
"recharts": "latest",
"lucide-react": "latest",
"prop-types": "latest"
"prop-types": "latest",
"sonner": "^2.0.7"
},
"devDependencies": {
"tailwindcss": "3.4.17",

View File

@@ -1,9 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { Toaster, toast } from "sonner";
import CanvasPane, { CanvasThumbnail } from "./components/CanvasPane.jsx";
import Controls from "./components/Controls.jsx";
import HistogramPanel from "./components/HistogramPanel.jsx";
import { applyStateOperation, combineStates, deleteState, getOperations, listStates, uploadImage } from "./lib/api.js";
function showErrorToast(error, fallback = "Something went wrong.") {
const message = error?.message || fallback;
toast.error(message, {
description: "Please check the selected image, operation, and parameters."
});
return message;
}
export default function App() {
const [session, setSession] = useState(null);
const [states, setStates] = useState([]);
@@ -26,7 +35,7 @@ export default function App() {
setParams({ _repeat: 1, ...Object.fromEntries(Object.entries(first.params || {}).map(([key, schema]) => [key, schema.default])) });
}
})
.catch((error) => setStatus(error.message));
.catch((error) => setStatus(showErrorToast(error, "Unable to load operations.")));
}, []);
async function refreshStates(sessionId, nextActiveId = null) {
@@ -51,7 +60,7 @@ export default function App() {
setTransform({ x: 0, y: 0, scale: 1 });
setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded as S0.`);
} catch (error) {
setStatus(error.message);
setStatus(showErrorToast(error, "Unable to upload image."));
} finally {
setBusy(false);
}
@@ -76,7 +85,7 @@ export default function App() {
setSelectedStateIds([state.state_id]);
setStatus(`${state.label} created.`);
} catch (error) {
setStatus(error.message);
setStatus(showErrorToast(error, "Unable to apply operation."));
} finally {
setBusy(false);
}
@@ -92,7 +101,7 @@ export default function App() {
setSelectedStateIds([state.state_id]);
setStatus(`${state.label} created.`);
} catch (error) {
setStatus(error.message);
setStatus(showErrorToast(error, "Unable to combine states."));
} finally {
setBusy(false);
}
@@ -117,7 +126,7 @@ export default function App() {
setSelectedStateIds((current) => current.filter((id) => id !== state.state_id));
setStatus(`${state.label} deleted.`);
} catch (error) {
setStatus(error.message);
setStatus(showErrorToast(error, "Unable to delete state."));
} finally {
setBusy(false);
}
@@ -131,6 +140,7 @@ export default function App() {
return (
<div className="flex h-screen overflow-hidden bg-zinc-950 text-zinc-100">
<Toaster richColors theme="dark" position="top-right" closeButton />
<Controls
operations={operations}
selectedOperation={selectedOperation}

View File

@@ -1,9 +1,25 @@
const API_BASE = import.meta.env.VITE_API_BASE || "";
function humanizeErrorDetail(detail) {
if (!detail) return "Request failed";
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) return detail.map(humanizeErrorDetail).join(" ");
if (typeof detail === "object") {
return Object.entries(detail)
.map(([key, value]) => {
const message = humanizeErrorDetail(value);
return key === "non_field_errors" || key === "detail" ? message : `${key}: ${message}`;
})
.filter(Boolean)
.join(" ");
}
return String(detail);
}
async function parseResponse(response) {
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.detail || "Request failed");
throw new Error(humanizeErrorDetail(payload.detail || payload));
}
return payload;
}