feat(v5): add periodic noise and fft reconstruction
This commit is contained in:
@@ -73,6 +73,7 @@ The app is organized as a small MATLAB-like image workspace. Each operation crea
|
||||
- **Bit-plane slicing** displays one binary bit of each gray value. Formula: `bit_k(r)`.
|
||||
- **Noise filter** adds test noise. Gaussian noise uses `g = f + n`; salt-and-pepper noise randomly sets pixels to `0` or `255`.
|
||||
- **Average N noisy copies** generates `N` independent Gaussian-noisy copies of the current image and averages them into one result. Formula: `result = (1/N) * sum_i(f + n_i)`.
|
||||
- **Periodic noise** adds a repeating sinusoidal row and column pattern. Formula: `g(x,y) = f(x,y) + A sin(2*pi*y/T) + A sin(2*pi*x/T)`.
|
||||
- **Average / box filter** smooths an image with a uniform mask. Formula: `g = imfilter(f, ones(K,K) / K^2)`.
|
||||
- **Weighted average filter** smooths with the slide mask `1/16 * [[1,2,1],[2,4,2],[1,2,1]]`.
|
||||
- **Gaussian filter** smooths using a Gaussian mask controlled by size `K` and variance `Q`. Formula: `G(x,y) = exp(-(x^2+y^2)/(2Q))`.
|
||||
@@ -85,7 +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)}`.
|
||||
- **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)))))`.
|
||||
|
||||
### Chapter 6: RGB Color Processing
|
||||
|
||||
|
||||
@@ -192,6 +192,29 @@ def average_noisy_copies(image, params):
|
||||
return ensure_uint8(np.round(total / count))
|
||||
|
||||
|
||||
def periodic_noise(image, params):
|
||||
"""Add sinusoidal periodic noise controlled by amplitude A and period T.
|
||||
|
||||
Use it to reproduce lecture examples where repeating row/column patterns create visible frequency spikes.
|
||||
"""
|
||||
|
||||
amplitude = float(params.get("A", 0.2))
|
||||
period = float(params.get("T", 100))
|
||||
if not np.isfinite(amplitude) or not np.isfinite(period) or period <= 0:
|
||||
raise ProcessingError("A must be finite and T must be a finite positive number.")
|
||||
|
||||
height, width = image.shape[:2]
|
||||
y = np.arange(height, dtype=np.float32)[:, None]
|
||||
x = np.arange(width, dtype=np.float32)[None, :]
|
||||
mask = amplitude * np.sin(2.0 * math.pi * y / period) + amplitude * np.sin(2.0 * math.pi * x / period)
|
||||
|
||||
source = image.astype(np.float32) / 255.0
|
||||
if image.ndim == 3:
|
||||
mask = mask[:, :, None]
|
||||
noisy = np.clip(source + mask, 0.0, 1.0)
|
||||
return ensure_uint8(np.round(noisy * 255.0))
|
||||
|
||||
|
||||
def gaussian_filter(image, params):
|
||||
"""Apply a Gaussian low-pass filter controlled by mask size K and variance Q.
|
||||
|
||||
@@ -347,6 +370,24 @@ def fft_spectrum(image, params):
|
||||
return gray_to_rgb(normalize_to_uint8(magnitude))
|
||||
|
||||
|
||||
def inverse_fft_reconstruction(image, params):
|
||||
"""Reconstruct an image with real(ifft2(ifftshift(fftshift(fft2(image))))).
|
||||
|
||||
Use it to demonstrate that FFT followed by inverse FFT recovers the image when no filter is applied.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def operation(id, label, chapter, slide_group, func, params=None, supports="both", matrices=None, formula="", repeatable=True):
|
||||
"""Create one operation registry entry consumed by the API and frontend."""
|
||||
|
||||
@@ -385,6 +426,10 @@ OPERATIONS = [
|
||||
"mean": float_param(0, -1, 1, 0.01, description="Gaussian mean in normalized intensity units."),
|
||||
"variance": float_param(0.01, 0, 0.2, 0.005, description="Gaussian variance; lower values add weaker noise."),
|
||||
}, formula="result = (1/N) sum_i (f + n_i), with gaussian n_i.", repeatable=False),
|
||||
operation("periodic_noise", "Periodic Noise", CH3, "Noise and Denoising", periodic_noise, {
|
||||
"A": float_param(0.2, 0, 1, 0.01, description="Amplitude A of the sinusoidal noise in normalized intensity units."),
|
||||
"T": float_param(100, 1, 512, 1, description="Period T of the horizontal and vertical sinusoidal noise in pixels."),
|
||||
}, formula="g(x,y) = f(x,y) + A sin(2*pi*y/T) + A sin(2*pi*x/T)."),
|
||||
operation("box_filter", "Average / Box Filter", CH3, "Linear Low-Pass Filters", box_denoise, {"K": odd_param(3, 35, description="Odd mask dimension K for the K x K average mask.")}, matrices=[kernel_preview("1 / K^2 box mask", [["1", "1", "1"], ["1", "1", "1"], ["1", "1", "1"]], "1 / K^2")], formula="g = imfilter(f, ones(K,K)/K^2)"),
|
||||
operation("weighted_average", "Weighted Average Filter", CH3, "Linear Low-Pass Filters", weighted_denoise, matrices=[kernel_preview("Weighted average mask", [[1, 2, 1], [2, 4, 2], [1, 2, 1]], "1 / 16")], formula="g = imfilter(f, weighted mask)"),
|
||||
operation("gaussian_filter", "Gaussian Filter", CH3, "Linear Low-Pass Filters", gaussian_denoise, {"K": odd_param(3, 35, description="Odd Gaussian mask dimension K."), "Q": float_param(1.0, 0.01, 25, 0.1, description="Variance Q of the Gaussian mask.")}, formula="Gaussian mask controlled by K and variance Q."),
|
||||
@@ -401,6 +446,7 @@ OPERATIONS = [
|
||||
], 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("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),
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@ from io import BytesIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from PIL import Image
|
||||
@@ -69,12 +70,19 @@ class ApiTests(TestCase):
|
||||
self.assertIn("median_filter", operation_ids)
|
||||
self.assertIn("noise_filter", operation_ids)
|
||||
self.assertIn("average_noisy_copies", operation_ids)
|
||||
self.assertIn("periodic_noise", operation_ids)
|
||||
self.assertIn("inverse_fft_reconstruction", operation_ids)
|
||||
self.assertIn("rgb_to_gray", operation_ids)
|
||||
self.assertEqual(operations["histeq"]["params"], {})
|
||||
self.assertEqual(operations["noise_filter"]["label"], "Noise Filter")
|
||||
self.assertEqual(operations["average_noisy_copies"]["label"], "Average N Noisy Copies")
|
||||
self.assertEqual(operations["average_noisy_copies"]["params"]["N"]["default"], 100)
|
||||
self.assertFalse(operations["average_noisy_copies"]["repeatable"])
|
||||
self.assertEqual(operations["periodic_noise"]["label"], "Periodic Noise")
|
||||
self.assertEqual(operations["periodic_noise"]["params"]["A"]["default"], 0.2)
|
||||
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["box_filter"]["label"], "Average / Box Filter")
|
||||
self.assertEqual(operations["gaussian_filter"]["label"], "Gaussian Filter")
|
||||
self.assertFalse(operations["negative"]["repeatable"])
|
||||
@@ -225,6 +233,76 @@ class ApiTests(TestCase):
|
||||
self.assertEqual(result.ndim, 2)
|
||||
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"]
|
||||
response = self.client.post(
|
||||
f"/api/states/{s0_id}/operations/",
|
||||
{"operation": "periodic_noise", "params": {"A": 0, "T": 100}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["channels"], 1)
|
||||
self.assertEqual(response.data["color_mode"], "L")
|
||||
result = load_image_array(ImageState.objects.get(id=response.data["state_id"]).image)
|
||||
self.assertEqual(result.ndim, 2)
|
||||
self.assertTrue(np.all(result == 96))
|
||||
|
||||
def test_periodic_noise_rgb_uses_shared_channel_mask(self):
|
||||
upload = self.client.post("/api/images/", {"image": png_upload(color=(80, 80, 80), size=(8, 8))}, format="multipart")
|
||||
s0_id = upload.data["states"][0]["state_id"]
|
||||
response = self.client.post(
|
||||
f"/api/states/{s0_id}/operations/",
|
||||
{"operation": "periodic_noise", "params": {"A": 0.1, "T": 4}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
result = load_image_array(ImageState.objects.get(id=response.data["state_id"]).image)
|
||||
np.testing.assert_array_equal(result[:, :, 0], result[:, :, 1])
|
||||
np.testing.assert_array_equal(result[:, :, 1], result[:, :, 2])
|
||||
|
||||
def test_periodic_noise_rejects_invalid_period(self):
|
||||
upload = self.client.post("/api/images/", {"image": grayscale_png_upload()}, format="multipart")
|
||||
s0_id = upload.data["states"][0]["state_id"]
|
||||
response = self.client.post(
|
||||
f"/api/states/{s0_id}/operations/",
|
||||
{"operation": "periodic_noise", "params": {"A": 0.2, "T": 0}},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_inverse_fft_reconstruction_matches_input_shape_and_values(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(
|
||||
f"/api/states/{s0_id}/operations/",
|
||||
{"operation": "inverse_fft_reconstruction", "params": {}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["channels"], 3)
|
||||
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)
|
||||
|
||||
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(
|
||||
f"/api/states/{s0_id}/operations/",
|
||||
{"operation": "inverse_fft_reconstruction", "params": {}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["channels"], 1)
|
||||
result = load_image_array(ImageState.objects.get(id=response.data["state_id"]).image)
|
||||
self.assertEqual(result.ndim, 2)
|
||||
self.assertTrue(np.allclose(result, 96, atol=1))
|
||||
|
||||
def test_grayscale_operation_creates_state(self):
|
||||
upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart")
|
||||
s0_id = upload.data["states"][0]["state_id"]
|
||||
|
||||
Reference in New Issue
Block a user