From 30e1d250ef686a4753fc08eb4cf8617f4f7ca7bc Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 9 Jul 2026 15:01:47 +0330 Subject: [PATCH] fix(v5): reconstruct inverse fft from saved spectrum --- README.md | 2 +- backend/processing/registry.py | 17 ++--- backend/processing/services.py | 95 +++++++++++++++++++++++++++- backend/processing/storage.py | 19 ++++++ backend/processing/tests/test_api.py | 42 +++++++++++- 5 files changed, 157 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 3579fd3..cc9c5df 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ 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)))))`. +- **Inverse FFT reconstruction** reconstructs from a previously saved FFT state. Formula: `f = real(ifft2(ifftshift(F)))`. ### Chapter 6: RGB Color Processing diff --git a/backend/processing/registry.py b/backend/processing/registry.py index a4e92bf..eff90b2 100644 --- a/backend/processing/registry.py +++ b/backend/processing/registry.py @@ -371,21 +371,12 @@ def fft_spectrum(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): - 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): @@ -446,7 +437,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("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), ] diff --git a/backend/processing/services.py b/backend/processing/services.py index 437d873..da90ccc 100644 --- a/backend/processing/services.py +++ b/backend/processing/services.py @@ -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,12 @@ 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 {}) + source = load_image_array(state.image) result = apply_registered_operation(source, operation, params or {}) new_state = image_state_create( @@ -172,6 +180,91 @@ def image_state_apply_operation(*, state, operation, params): 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) + mode = params.get("mode", "log_magnitude") + if mode not in {"magnitude", "log_magnitude", "phase"}: + raise ProcessingError("FFT mode must be magnitude, log_magnitude, or phase.") + spectrum = image_fft(source) + preview = fft_preview_image(spectrum, mode) + fft_data_path = save_fft_array(spectrum, "state-fft-data") + operation_params = { + **params, + "mode": mode, + "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, mode): + """Convert complex FFT data to a display-only uint8 preview image.""" + + if mode == "phase": + preview = np.angle(spectrum) + else: + preview = np.abs(spectrum) + if mode == "log_magnitude": + preview = np.log1p(preview) + 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. diff --git a/backend/processing/storage.py b/backend/processing/storage.py index 9d868c9..ffbddc6 100644 --- a/backend/processing/storage.py +++ b/backend/processing/storage.py @@ -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 diff --git a/backend/processing/tests/test_api.py b/backend/processing/tests/test_api.py index 01b22d0..cf67d1c 100644 --- a/backend/processing/tests/test_api.py +++ b/backend/processing/tests/test_api.py @@ -273,7 +273,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 +282,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": {"mode": "log_magnitude"}}, + 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": {"mode": "log_magnitude"}}, + 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 +322,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": {"mode": "log_magnitude"}}, + format="json", + ) + response = self.client.post( + f"/api/states/{spectrum.data['state_id']}/operations/", {"operation": "inverse_fft_reconstruction", "params": {}}, format="json", )