feat(v5): add periodic noise and fft reconstruction

This commit is contained in:
2026-07-09 14:41:02 +03:30
parent ece63caa22
commit d9f2cd4d9d
3 changed files with 127 additions and 1 deletions

View File

@@ -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)`. - **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`. - **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)`. - **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)`. - **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]]`. - **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))`. - **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 ### 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 ### Chapter 6: RGB Color Processing

View File

@@ -192,6 +192,29 @@ def average_noisy_copies(image, params):
return ensure_uint8(np.round(total / count)) 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): def gaussian_filter(image, params):
"""Apply a Gaussian low-pass filter controlled by mask size K and variance Q. """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)) 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): 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.""" """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."), "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."), "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), }, 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("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("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."), 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))."), ], 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, {"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_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

@@ -3,6 +3,7 @@ from io import BytesIO
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import numpy as np
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from PIL import Image from PIL import Image
@@ -69,12 +70,19 @@ class ApiTests(TestCase):
self.assertIn("median_filter", operation_ids) self.assertIn("median_filter", operation_ids)
self.assertIn("noise_filter", operation_ids) self.assertIn("noise_filter", operation_ids)
self.assertIn("average_noisy_copies", 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.assertIn("rgb_to_gray", operation_ids)
self.assertEqual(operations["histeq"]["params"], {}) self.assertEqual(operations["histeq"]["params"], {})
self.assertEqual(operations["noise_filter"]["label"], "Noise Filter") 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"]["label"], "Average N Noisy Copies")
self.assertEqual(operations["average_noisy_copies"]["params"]["N"]["default"], 100) self.assertEqual(operations["average_noisy_copies"]["params"]["N"]["default"], 100)
self.assertFalse(operations["average_noisy_copies"]["repeatable"]) 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["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"])
@@ -225,6 +233,76 @@ 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_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): def test_grayscale_operation_creates_state(self):
upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart") upload = self.client.post("/api/images/", {"image": png_upload()}, format="multipart")
s0_id = upload.data["states"][0]["state_id"] s0_id = upload.data["states"][0]["state_id"]