Compare commits
6 Commits
d9f2cd4d9d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 47d6af6abc | |||
| 38faff0ed0 | |||
| 428b9a3e7f | |||
| da390b8bd9 | |||
| 30e1d250ef | |||
| ca447c71cc |
@@ -86,8 +86,8 @@ 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** applies FFT then inverse FFT without filtering to demonstrate reconstruction. Formula: `f = real(ifft2(ifftshift(fftshift(fft2(image)))))`.
|
- **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
|
||||||
|
|
||||||
|
|||||||
@@ -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,38 +356,24 @@ 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))
|
||||||
|
|
||||||
|
|
||||||
def inverse_fft_reconstruction(image, params):
|
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):
|
raise ProcessingError("Inverse FFT Reconstruction must be applied to an FFT/DFT Spectrum View state.")
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def operation(id, label, chapter, slide_group, func, params=None, supports="both", matrices=None, formula="", repeatable=True):
|
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]]),
|
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(fftshift(fft2(image))))).", 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),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 .algorithms import ProcessingError, average_images, decode_image, histogram, histogram_payload, normalize_to_uint8, process_image, verify_registration
|
||||||
from .models import ImageSession, ImageState, ProcessingJob
|
from .models import ImageSession, ImageState, ProcessingJob
|
||||||
from .registry import apply_registered_operation
|
from .registry import apply_registered_operation
|
||||||
from .storage import 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
|
from .tasks import run_batch_job
|
||||||
|
|
||||||
|
|
||||||
@@ -145,9 +145,11 @@ def image_state_delete(*, state):
|
|||||||
if state.sequence == 0 or state.operation == "upload":
|
if state.sequence == 0 or state.operation == "upload":
|
||||||
raise ProcessingError("The original S0 upload state cannot be deleted.")
|
raise ProcessingError("The original S0 upload state cannot be deleted.")
|
||||||
image_path = state.image
|
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.children.update(parent=None)
|
||||||
state.delete()
|
state.delete()
|
||||||
delete_relative_file(image_path)
|
delete_relative_file(image_path)
|
||||||
|
delete_relative_file(fft_data_path)
|
||||||
|
|
||||||
|
|
||||||
def image_state_apply_operation(*, state, operation, params):
|
def image_state_apply_operation(*, state, operation, params):
|
||||||
@@ -158,6 +160,14 @@ def image_state_apply_operation(*, state, operation, params):
|
|||||||
|
|
||||||
if state.session.expired:
|
if state.session.expired:
|
||||||
raise ProcessingError("Image session has expired.")
|
raise ProcessingError("Image session has expired.")
|
||||||
|
|
||||||
|
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)
|
source = load_image_array(state.image)
|
||||||
result = apply_registered_operation(source, operation, params or {})
|
result = apply_registered_operation(source, operation, params or {})
|
||||||
new_state = image_state_create(
|
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)
|
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):
|
def combine_states(*, states, operation, params=None):
|
||||||
"""Combine registered states using add, subtract, dot product, average, and/or.
|
"""Combine registered states using add, subtract, dot product, average, and/or.
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ def save_image_array(image, prefix="image"):
|
|||||||
return filename
|
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):
|
def load_image_array(relative_path):
|
||||||
path = Path(settings.MEDIA_ROOT) / relative_path
|
path = Path(settings.MEDIA_ROOT) / relative_path
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -45,6 +53,17 @@ def load_image_array(relative_path):
|
|||||||
return cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.uint8)
|
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):
|
def delete_relative_file(relative_path):
|
||||||
if not relative_path:
|
if not relative_path:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -273,7 +297,7 @@ class ApiTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 400)
|
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")
|
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"]
|
s0_id = upload.data["states"][0]["state_id"]
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -282,8 +306,39 @@ class ApiTests(TestCase):
|
|||||||
format="json",
|
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.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)
|
result = load_image_array(ImageState.objects.get(id=response.data["state_id"]).image)
|
||||||
expected = load_image_array(ImageState.objects.get(id=s0_id).image)
|
expected = load_image_array(ImageState.objects.get(id=s0_id).image)
|
||||||
np.testing.assert_allclose(result, expected, atol=1)
|
np.testing.assert_allclose(result, expected, atol=1)
|
||||||
@@ -291,8 +346,13 @@ class ApiTests(TestCase):
|
|||||||
def test_inverse_fft_reconstruction_preserves_grayscale(self):
|
def test_inverse_fft_reconstruction_preserves_grayscale(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"]
|
||||||
response = self.client.post(
|
spectrum = self.client.post(
|
||||||
f"/api/states/{s0_id}/operations/",
|
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": {}},
|
{"operation": "inverse_fft_reconstruction", "params": {}},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
@@ -325,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")
|
||||||
|
|||||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -15,12 +15,37 @@ function toChartData(original, processed) {
|
|||||||
level,
|
level,
|
||||||
original: originalSeries?.[level] ?? 0,
|
original: originalSeries?.[level] ?? 0,
|
||||||
processed: processedSeries?.[level] ?? 0,
|
processed: processedSeries?.[level] ?? 0,
|
||||||
r: r[level] ?? 0,
|
red: r[level] ?? 0,
|
||||||
g: g[level] ?? 0,
|
green: g[level] ?? 0,
|
||||||
b: b[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 }) {
|
export default function HistogramPanel({ original, processed }) {
|
||||||
const data = toChartData(original, processed);
|
const data = toChartData(original, processed);
|
||||||
return (
|
return (
|
||||||
@@ -38,12 +63,12 @@ export default function HistogramPanel({ original, processed }) {
|
|||||||
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="level" stroke="#71717a" tick={{ fontSize: 10 }} interval={63} />
|
<XAxis dataKey="level" stroke="#71717a" tick={{ fontSize: 10 }} interval={63} />
|
||||||
<YAxis stroke="#71717a" tick={{ fontSize: 10 }} width={44} />
|
<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="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="red" 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="green" 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="blue" stroke="#60a5fa" fill="#3b82f6" fillOpacity={0.08} dot={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user