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 ### 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)))`. - **Inverse FFT reconstruction** reconstructs from a previously saved FFT state. Formula: `f = real(ifft2(ifftshift(F)))`.
### Chapter 6: RGB Color Processing ### 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. Use it to emphasize prominent edges before combining them with a sharpened image.
""" """
grayscale_input = image.ndim == 2
operator = params.get("operator", "sobel") operator = params.get("operator", "sobel")
gray = to_gray(image).astype(np.float32) gray = to_gray(image).astype(np.float32)
if operator == "roberts": 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) 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) fx = cv2.filter2D(gray, cv2.CV_32F, gx, borderType=cv2.BORDER_REFLECT)
fy = cv2.filter2D(gray, cv2.CV_32F, gy, 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): def rgb_channel(image, params):
@@ -354,19 +356,14 @@ def rgb_channel(image, params):
def fft_spectrum(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. Use it to understand whether image information is concentrated in low or high frequencies.
""" """
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))
mode = params.get("mode", "log_magnitude") magnitude = np.log1p(np.abs(spectrum))
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)) 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]]), 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))."), ], 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("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("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_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), 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 {}) return image_state_fft_spectrum_create(state=state, params=params or {})
if operation == "inverse_fft_reconstruction": if operation == "inverse_fft_reconstruction":
return image_state_inverse_fft_create(state=state, params=params or {}) 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) source = load_image_array(state.image)
result = apply_registered_operation(source, operation, params or {}) 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) 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): def image_state_fft_spectrum_create(*, state, params):
"""Create an FFT visualization state and persist the actual complex spectrum. """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) 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) 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") fft_data_path = save_fft_array(spectrum, "state-fft-data")
operation_params = { operation_params = {
**params, **(params or {}),
"mode": mode, "mode": "log_magnitude",
"fft_data_path": fft_data_path, "fft_data_path": fft_data_path,
"source_state_id": str(state.id), "source_state_id": str(state.id),
"source_color_mode": "RGB" if source.ndim == 3 else "L", "source_color_mode": "RGB" if source.ndim == 3 else "L",
@@ -240,15 +273,10 @@ def image_fft(image):
return np.stack(channels, axis=2) return np.stack(channels, axis=2)
def fft_preview_image(spectrum, mode): def fft_preview_image(spectrum):
"""Convert complex FFT data to a display-only uint8 preview image.""" """Convert complex FFT data to a display-only log-magnitude uint8 preview image."""
if mode == "phase": preview = np.log1p(np.abs(spectrum))
preview = np.angle(spectrum)
else:
preview = np.abs(spectrum)
if mode == "log_magnitude":
preview = np.log1p(preview)
return normalize_to_uint8(preview) 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["periodic_noise"]["params"]["T"]["default"], 100)
self.assertEqual(operations["inverse_fft_reconstruction"]["label"], "Inverse FFT Reconstruction") self.assertEqual(operations["inverse_fft_reconstruction"]["label"], "Inverse FFT Reconstruction")
self.assertFalse(operations["inverse_fft_reconstruction"]["repeatable"]) 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["box_filter"]["label"], "Average / Box Filter")
self.assertEqual(operations["gaussian_filter"]["label"], "Gaussian Filter") self.assertEqual(operations["gaussian_filter"]["label"], "Gaussian Filter")
self.assertFalse(operations["negative"]["repeatable"]) self.assertFalse(operations["negative"]["repeatable"])
@@ -233,6 +234,29 @@ class ApiTests(TestCase):
self.assertEqual(result.ndim, 2) self.assertEqual(result.ndim, 2)
self.assertEqual(int(result[0, 0]), 96) 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): def test_periodic_noise_preserves_grayscale_and_zero_amplitude(self):
upload = self.client.post("/api/images/", {"image": grayscale_png_upload(value=96)}, format="multipart") upload = self.client.post("/api/images/", {"image": grayscale_png_upload(value=96)}, format="multipart")
s0_id = upload.data["states"][0]["state_id"] s0_id = upload.data["states"][0]["state_id"]
@@ -289,7 +313,7 @@ class ApiTests(TestCase):
s0_id = upload.data["states"][0]["state_id"] s0_id = upload.data["states"][0]["state_id"]
spectrum = self.client.post( spectrum = self.client.post(
f"/api/states/{s0_id}/operations/", f"/api/states/{s0_id}/operations/",
{"operation": "fft_spectrum", "params": {"mode": "log_magnitude"}}, {"operation": "fft_spectrum", "params": {}},
format="json", format="json",
) )
@@ -304,7 +328,7 @@ class ApiTests(TestCase):
s0_id = upload.data["states"][0]["state_id"] s0_id = upload.data["states"][0]["state_id"]
spectrum = self.client.post( spectrum = self.client.post(
f"/api/states/{s0_id}/operations/", f"/api/states/{s0_id}/operations/",
{"operation": "fft_spectrum", "params": {"mode": "log_magnitude"}}, {"operation": "fft_spectrum", "params": {}},
format="json", format="json",
) )
response = self.client.post( response = self.client.post(
@@ -324,7 +348,7 @@ class ApiTests(TestCase):
s0_id = upload.data["states"][0]["state_id"] s0_id = upload.data["states"][0]["state_id"]
spectrum = self.client.post( spectrum = self.client.post(
f"/api/states/{s0_id}/operations/", f"/api/states/{s0_id}/operations/",
{"operation": "fft_spectrum", "params": {"mode": "log_magnitude"}}, {"operation": "fft_spectrum", "params": {}},
format="json", format="json",
) )
response = self.client.post( response = self.client.post(
@@ -361,6 +385,40 @@ class ApiTests(TestCase):
self.assertEqual(noisy.data["channels"], 1) self.assertEqual(noisy.data["channels"], 1)
self.assertEqual(noisy.data["color_mode"], "L") 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") @patch("processing.services.run_batch_job.delay")
def test_batch_returns_job_id(self, delay): def test_batch_returns_job_id(self, delay):
first = self.client.post("/api/images/", {"image": png_upload(name="a.png")}, format="multipart") first = self.client.post("/api/images/", {"image": png_upload(name="a.png")}, format="multipart")

View File

@@ -15,6 +15,7 @@
"react-dom": "latest", "react-dom": "latest",
"react-quick-pinch-zoom": "latest", "react-quick-pinch-zoom": "latest",
"recharts": "latest", "recharts": "latest",
"sonner": "^2.0.7",
"vite": "latest" "vite": "latest"
}, },
"devDependencies": { "devDependencies": {
@@ -3027,6 +3028,16 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/source-map-js/-/source-map-js-1.2.1.tgz", "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", "react-quick-pinch-zoom": "latest",
"recharts": "latest", "recharts": "latest",
"lucide-react": "latest", "lucide-react": "latest",
"prop-types": "latest" "prop-types": "latest",
"sonner": "^2.0.7"
}, },
"devDependencies": { "devDependencies": {
"tailwindcss": "3.4.17", "tailwindcss": "3.4.17",

View File

@@ -1,9 +1,18 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Toaster, toast } from "sonner";
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, deleteState, getOperations, listStates, uploadImage } from "./lib/api.js"; 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() { export default function App() {
const [session, setSession] = useState(null); const [session, setSession] = useState(null);
const [states, setStates] = useState([]); 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])) }); 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) { async function refreshStates(sessionId, nextActiveId = null) {
@@ -51,7 +60,7 @@ export default function App() {
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.`);
} catch (error) { } catch (error) {
setStatus(error.message); setStatus(showErrorToast(error, "Unable to upload image."));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -76,7 +85,7 @@ export default function App() {
setSelectedStateIds([state.state_id]); setSelectedStateIds([state.state_id]);
setStatus(`${state.label} created.`); setStatus(`${state.label} created.`);
} catch (error) { } catch (error) {
setStatus(error.message); setStatus(showErrorToast(error, "Unable to apply operation."));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -92,7 +101,7 @@ export default function App() {
setSelectedStateIds([state.state_id]); setSelectedStateIds([state.state_id]);
setStatus(`${state.label} created.`); setStatus(`${state.label} created.`);
} catch (error) { } catch (error) {
setStatus(error.message); setStatus(showErrorToast(error, "Unable to combine states."));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -117,7 +126,7 @@ export default function App() {
setSelectedStateIds((current) => current.filter((id) => id !== state.state_id)); setSelectedStateIds((current) => current.filter((id) => id !== state.state_id));
setStatus(`${state.label} deleted.`); setStatus(`${state.label} deleted.`);
} catch (error) { } catch (error) {
setStatus(error.message); setStatus(showErrorToast(error, "Unable to delete state."));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -131,6 +140,7 @@ export default function App() {
return ( return (
<div className="flex h-screen overflow-hidden bg-zinc-950 text-zinc-100"> <div className="flex h-screen overflow-hidden bg-zinc-950 text-zinc-100">
<Toaster richColors theme="dark" position="top-right" closeButton />
<Controls <Controls
operations={operations} operations={operations}
selectedOperation={selectedOperation} selectedOperation={selectedOperation}

View File

@@ -1,9 +1,25 @@
const API_BASE = import.meta.env.VITE_API_BASE || ""; 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) { async function parseResponse(response) {
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
throw new Error(payload.detail || "Request failed"); throw new Error(humanizeErrorDetail(payload.detail || payload));
} }
return payload; return payload;
} }