Compare commits

...

6 Commits

10 changed files with 327 additions and 42 deletions

View File

@@ -86,8 +86,8 @@ 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.
- **Inverse FFT reconstruction** applies FFT then inverse FFT without filtering to demonstrate reconstruction. Formula: `f = real(ifft2(ifftshift(fftshift(fft2(image)))))`.
- **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,38 +356,24 @@ 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))
def inverse_fft_reconstruction(image, params):
"""Reconstruct an image with real(ifft2(ifftshift(fftshift(fft2(image))))).
"""Placeholder for inverse FFT reconstruction from a saved FFT state.
Use it to demonstrate that FFT followed by inverse FFT recovers the image when no filter is applied.
The service layer handles this operation because it needs the complex FFT data saved by the FFT action.
"""
def reconstruct_channel(channel):
source = channel.astype(np.float32)
spectrum = np.fft.fftshift(np.fft.fft2(source))
reconstructed = np.real(np.fft.ifft2(np.fft.ifftshift(spectrum)))
return np.round(np.clip(reconstructed, 0, 255)).astype(np.uint8)
if image.ndim == 2:
return reconstruct_channel(image)
channels = [reconstruct_channel(image[:, :, idx]) for idx in range(image.shape[2])]
return np.stack(channels, axis=2)
raise ProcessingError("Inverse FFT Reconstruction must be applied to an FFT/DFT Spectrum View state.")
def operation(id, label, chapter, slide_group, func, params=None, supports="both", matrices=None, formula="", repeatable=True):
@@ -445,8 +433,8 @@ 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("inverse_fft_reconstruction", "Inverse FFT Reconstruction", CH4, "DFT and FFT", inverse_fft_reconstruction, formula="f = real(ifft2(ifftshift(fftshift(fft2(image))))).", 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

@@ -9,7 +9,7 @@ from django.utils import timezone
from .algorithms import ProcessingError, average_images, decode_image, histogram, histogram_payload, normalize_to_uint8, process_image, verify_registration
from .models import ImageSession, ImageState, ProcessingJob
from .registry import apply_registered_operation
from .storage import delete_relative_file, load_image_array, payload_for_image, save_image_array
from .storage import delete_relative_file, load_fft_array, load_image_array, payload_for_image, save_fft_array, save_image_array
from .tasks import run_batch_job
@@ -145,9 +145,11 @@ def image_state_delete(*, state):
if state.sequence == 0 or state.operation == "upload":
raise ProcessingError("The original S0 upload state cannot be deleted.")
image_path = state.image
fft_data_path = state.params.get("fft_data_path") if isinstance(state.params, dict) else None
state.children.update(parent=None)
state.delete()
delete_relative_file(image_path)
delete_relative_file(fft_data_path)
def image_state_apply_operation(*, state, operation, params):
@@ -158,6 +160,14 @@ def image_state_apply_operation(*, state, operation, params):
if state.session.expired:
raise ProcessingError("Image session has expired.")
if operation == "fft_spectrum":
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 {})
new_state = image_state_create(
@@ -172,6 +182,117 @@ 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.
The displayed image is only a spectrum preview; the saved FFT data is what the inverse step uses.
"""
source = load_image_array(state.image)
spectrum = image_fft(source)
preview = fft_preview_image(spectrum)
fft_data_path = save_fft_array(spectrum, "state-fft-data")
operation_params = {
**(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",
}
new_state = image_state_create(
session=state.session,
parent=state,
image=preview,
operation="fft_spectrum",
params=operation_params,
label=None,
prefix="state-fft_spectrum",
)
return image_state_payload(state=new_state, include_image=True)
def image_state_inverse_fft_create(*, state, params):
"""Create an image state by applying inverse FFT to a previously saved FFT state."""
if state.operation != "fft_spectrum" or not isinstance(state.params, dict) or not state.params.get("fft_data_path"):
raise ProcessingError("Inverse FFT Reconstruction must be applied to an FFT/DFT Spectrum View state.")
spectrum = load_fft_array(state.params["fft_data_path"])
result = inverse_fft_image(spectrum)
new_state = image_state_create(
session=state.session,
parent=state,
image=result,
operation="inverse_fft_reconstruction",
params=params or {},
label=None,
prefix="state-inverse_fft_reconstruction",
)
return image_state_payload(state=new_state, include_image=True)
def image_fft(image):
"""Return centered DFT data for grayscale or per-channel RGB images."""
if image.ndim == 2:
return np.fft.fftshift(np.fft.fft2(image.astype(np.float32)))
channels = [np.fft.fftshift(np.fft.fft2(image[:, :, idx].astype(np.float32))) for idx in range(image.shape[2])]
return np.stack(channels, axis=2)
def fft_preview_image(spectrum):
"""Convert complex FFT data to a display-only log-magnitude uint8 preview image."""
preview = np.log1p(np.abs(spectrum))
return normalize_to_uint8(preview)
def inverse_fft_image(spectrum):
"""Apply ifft2(ifftshift(F)) to stored complex FFT data."""
def reconstruct_channel(channel):
reconstructed = np.real(np.fft.ifft2(np.fft.ifftshift(channel)))
return np.round(np.clip(reconstructed, 0, 255)).astype(np.uint8)
if spectrum.ndim == 2:
return reconstruct_channel(spectrum)
channels = [reconstruct_channel(spectrum[:, :, idx]) for idx in range(spectrum.shape[2])]
return np.stack(channels, axis=2)
def combine_states(*, states, operation, params=None):
"""Combine registered states using add, subtract, dot product, average, and/or.

View File

@@ -30,6 +30,14 @@ def save_image_array(image, prefix="image"):
return filename
def save_fft_array(spectrum, prefix="fft"):
filename = f"sessions/{prefix}-{uuid4().hex}.npz"
path = Path(settings.MEDIA_ROOT) / filename
path.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(path, spectrum=spectrum)
return filename
def load_image_array(relative_path):
path = Path(settings.MEDIA_ROOT) / relative_path
if not path.exists():
@@ -45,6 +53,17 @@ def load_image_array(relative_path):
return cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.uint8)
def load_fft_array(relative_path):
path = Path(settings.MEDIA_ROOT) / relative_path
if not path.exists():
raise ProcessingError("FFT data file is missing or unreadable.")
try:
with np.load(path) as payload:
return payload["spectrum"]
except (OSError, KeyError, ValueError) as exc:
raise ProcessingError("FFT data file is missing or unreadable.") from exc
def delete_relative_file(relative_path):
if not relative_path:
return

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"]
@@ -273,7 +297,7 @@ class ApiTests(TestCase):
)
self.assertEqual(response.status_code, 400)
def test_inverse_fft_reconstruction_matches_input_shape_and_values(self):
def test_inverse_fft_reconstruction_rejects_non_fft_state(self):
upload = self.client.post("/api/images/", {"image": png_upload(color=(32, 64, 128), size=(4, 4))}, format="multipart")
s0_id = upload.data["states"][0]["state_id"]
response = self.client.post(
@@ -282,8 +306,39 @@ class ApiTests(TestCase):
format="json",
)
self.assertEqual(response.status_code, 400)
def test_fft_spectrum_state_stores_complex_data(self):
upload = self.client.post("/api/images/", {"image": png_upload(color=(32, 64, 128), size=(4, 4))}, format="multipart")
s0_id = upload.data["states"][0]["state_id"]
spectrum = self.client.post(
f"/api/states/{s0_id}/operations/",
{"operation": "fft_spectrum", "params": {}},
format="json",
)
self.assertEqual(spectrum.status_code, 201)
state = ImageState.objects.get(id=spectrum.data["state_id"])
self.assertEqual(state.operation, "fft_spectrum")
self.assertIn("fft_data_path", state.params)
self.assertTrue((Path(self.tmp.name) / state.params["fft_data_path"]).exists())
def test_inverse_fft_reconstruction_undoes_saved_fft_state(self):
upload = self.client.post("/api/images/", {"image": png_upload(color=(32, 64, 128), size=(4, 4))}, format="multipart")
s0_id = upload.data["states"][0]["state_id"]
spectrum = self.client.post(
f"/api/states/{s0_id}/operations/",
{"operation": "fft_spectrum", "params": {}},
format="json",
)
response = self.client.post(
f"/api/states/{spectrum.data['state_id']}/operations/",
{"operation": "inverse_fft_reconstruction", "params": {}},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["channels"], 3)
self.assertEqual(response.data["parent_state_id"], spectrum.data["state_id"])
result = load_image_array(ImageState.objects.get(id=response.data["state_id"]).image)
expected = load_image_array(ImageState.objects.get(id=s0_id).image)
np.testing.assert_allclose(result, expected, atol=1)
@@ -291,8 +346,13 @@ class ApiTests(TestCase):
def test_inverse_fft_reconstruction_preserves_grayscale(self):
upload = self.client.post("/api/images/", {"image": grayscale_png_upload(value=96)}, format="multipart")
s0_id = upload.data["states"][0]["state_id"]
response = self.client.post(
spectrum = self.client.post(
f"/api/states/{s0_id}/operations/",
{"operation": "fft_spectrum", "params": {}},
format="json",
)
response = self.client.post(
f"/api/states/{spectrum.data['state_id']}/operations/",
{"operation": "inverse_fft_reconstruction", "params": {}},
format="json",
)
@@ -325,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

@@ -15,12 +15,37 @@ function toChartData(original, processed) {
level,
original: originalSeries?.[level] ?? 0,
processed: processedSeries?.[level] ?? 0,
r: r[level] ?? 0,
g: g[level] ?? 0,
b: b[level] ?? 0
red: r[level] ?? 0,
green: g[level] ?? 0,
blue: b[level] ?? 0
}));
}
function HistogramTooltip({ active, payload, label }) {
if (!active || !payload?.length) return null;
const values = Object.fromEntries(payload.map((item) => [item.dataKey, item.value]));
const rows = [
["Original", values.original, "text-cyan-300"],
["Active", values.processed, "text-emerald-300"],
["R", values.red, "text-red-300"],
["G", values.green, "text-green-300"],
["B", values.blue, "text-blue-300"]
];
return (
<div className="border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs shadow-xl">
<div className="mb-1 font-semibold text-zinc-100">Level {label}</div>
<div className="space-y-1">
{rows.map(([name, value, className]) => (
<div key={name} className="flex min-w-32 justify-between gap-4">
<span className={className}>{name}</span>
<span className="tabular-nums text-zinc-100">{Number(value ?? 0).toFixed(8)}</span>
</div>
))}
</div>
</div>
);
}
export default function HistogramPanel({ original, processed }) {
const data = toChartData(original, processed);
return (
@@ -38,12 +63,12 @@ export default function HistogramPanel({ original, processed }) {
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
<XAxis dataKey="level" stroke="#71717a" tick={{ fontSize: 10 }} interval={63} />
<YAxis stroke="#71717a" tick={{ fontSize: 10 }} width={44} />
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", color: "#f4f4f5" }} />
<Tooltip content={<HistogramTooltip />} />
<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="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} />
<Area type="monotone" dataKey="red" stroke="#f87171" fill="#ef4444" fillOpacity={0.08} dot={false} />
<Area type="monotone" dataKey="green" stroke="#4ade80" fill="#22c55e" fillOpacity={0.08} dot={false} />
<Area type="monotone" dataKey="blue" stroke="#60a5fa" fill="#3b82f6" fillOpacity={0.08} dot={false} />
</AreaChart>
</ResponsiveContainer>
</div>

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;
}