From d9f2cd4d9ddba72a19d36410de2a991ae330736e Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 9 Jul 2026 14:41:02 +0330 Subject: [PATCH] feat(v5): add periodic noise and fft reconstruction --- README.md | 4 +- backend/processing/registry.py | 46 ++++++++++++++++ backend/processing/tests/test_api.py | 78 ++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 869c32e..3579fd3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/processing/registry.py b/backend/processing/registry.py index 6293a05..a4e92bf 100644 --- a/backend/processing/registry.py +++ b/backend/processing/registry.py @@ -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), ] diff --git a/backend/processing/tests/test_api.py b/backend/processing/tests/test_api.py index c8ed6df..01b22d0 100644 --- a/backend/processing/tests/test_api.py +++ b/backend/processing/tests/test_api.py @@ -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"]