fix(v5): reconstruct inverse fft from saved spectrum

This commit is contained in:
2026-07-09 15:01:47 +03:30
parent ca447c71cc
commit 30e1d250ef
5 changed files with 157 additions and 18 deletions

View File

@@ -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),
]

View File

@@ -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.

View File

@@ -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

View File

@@ -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",
)