Source code for pyfli.sp_analysis.simulator.reconstructor

"""
Reconstruct images from simulated single-pixel measurements using linear, Fourier, and
TV methods.

This module belongs to :mod:`pyfli.sp_analysis.simulator` and is part of PyFLI single-
pixel camera basis generation, acquisition simulation, and reconstruction solvers.
Public API includes classes :class:`Reconstructor`.
"""

from typing import Any

# sp_analysis/simulator/reconstructor.py
import numpy as np
from scipy.fftpack import idct
from scipy.optimize import minimize

from pyfli import logging


[docs] class Reconstructor: """ Reconstruct images from simulated single-pixel measurements. Methods include total- variation optimization, linear reconstruction, Fourier-domain reconstruction, and output normalization. Parameters ---------- resolution : tuple[int, ...] Spatial resolution of generated patterns or reconstructed images. """ def __init__(self, resolution: tuple[int, ...] = (128, 128)) -> None: self.res_h, self.res_w = resolution self.n_pixels = self.res_h * self.res_w def _tv_norm(self, x_flat: np.ndarray) -> Any: # Calculates the Total Variation of the image """ Run the TV norm routine. Parameters ---------- x_flat : np.ndarray Flattened image or parameter vector. Returns ------- Any Object produced by TV norm. """ x = x_flat.reshape((self.res_h, self.res_w)) grad_x = np.diff(x, axis=1) grad_y = np.diff(x, axis=0) return np.sum(np.abs(grad_x)) + np.sum(np.abs(grad_y)) def _objective_and_grad( self, x_flat: np.ndarray, A: np.ndarray, y: np.ndarray, alpha: float ) -> tuple[Any, ...]: """ Calculates both the objective value and the gradient. Providing the gradient (jac) makes the solver 10,000x faster. """ x = x_flat.reshape((self.res_h, self.res_w)) # Data fidelity: 0.5 * ||Ax - y||^2 Ax_minus_y = np.dot(A, x_flat) - y fidelity = 0.5 * np.sum(Ax_minus_y**2) grad_fidelity = np.dot(A.T, Ax_minus_y) # Isotropic TV with Neumann (zero-flux) boundary conditions eps = 1e-8 # Forward differences, zero-padded at boundaries (Neumann BC) dx = np.zeros_like(x) dy = np.zeros_like(x) dx[:, :-1] = np.diff(x, axis=1) dy[:-1, :] = np.diff(x, axis=0) norm = np.sqrt(dx**2 + dy**2 + eps) tv = np.sum(norm) # Divergence with Neumann BC (correct adjoint of forward-diff gradient) px = dx / norm py = dy / norm grad_tv = np.zeros_like(x) grad_tv[:, :-1] -= px[:, :-1] grad_tv[:, 1:] += px[:, :-1] grad_tv[:-1, :] -= py[:-1, :] grad_tv[1:, :] += py[:-1, :] total_obj = fidelity + alpha * tv total_grad = grad_fidelity + alpha * grad_tv.flatten() return total_obj, total_grad
[docs] def solve_tv( self, measurements: np.ndarray, basis_matrix: np.ndarray, alpha: float = 1.0, maxiter: int = 500, ) -> Any: """ Run the solve TV routine. Parameters ---------- measurements : np.ndarray Single-pixel measurement vector or matrix. basis_matrix : np.ndarray Sensing basis matrix used for reconstruction. alpha : float Regularization strength, fraction value, or significance threshold used by the routine. maxiter : int Maximum number of optimization iterations. Returns ------- Any Object produced by solve TV. """ A = basis_matrix.astype(np.float64) y = measurements.astype(np.float64).flatten() M, N = A.shape # Scaled initial guess: pinv-like using A^T normalization x0 = np.dot(A.T, y) / M logging.info(f"Starting TV Optimization (Alpha={alpha}, maxiter={maxiter})...") res = minimize( self._objective_and_grad, x0, args=(A, y, alpha), method="L-BFGS-B", jac=True, options={"maxiter": maxiter, "ftol": 1e-10, "gtol": 1e-7, "disp": True}, ) return res.x.reshape((self.res_h, self.res_w), order="C")
[docs] def reconstruct_linear( self, measurements: np.ndarray, basis_matrix: np.ndarray ) -> Any: # Standard linear back-projection (Ghost Imaging) """ Reconstruct linear. Parameters ---------- measurements : np.ndarray Single-pixel measurement vector or matrix. basis_matrix : np.ndarray Sensing basis matrix used for reconstruction. Returns ------- Any Object produced by reconstruct linear. """ y = measurements.flatten() M = len(y) img_flat = np.dot(basis_matrix.T, y) img_flat /= M return img_flat.reshape((self.res_h, self.res_w))
[docs] def reconstruct_fourier_domain( self, measurements: np.ndarray, sampling_indices: Any ) -> np.ndarray: # Fast reconstruction for Fourier SPI. # Directly fills the 2D DCT spectrum and performs IDCT. """ Reconstruct fourier domain. Parameters ---------- measurements : np.ndarray Single-pixel measurement vector or matrix. sampling_indices : Any Frequency-domain sample indices used for reconstruction. Returns ------- np.ndarray Fourier-domain reconstruction on the requested sampling indices. """ freq_map_flat = np.zeros(self.n_pixels) # Place measurements back into their frequency locations freq_map_flat[sampling_indices[: len(measurements)]] = measurements freq_map = freq_map_flat.reshape((self.res_h, self.res_w)) # 2D Inverse Discrete Cosine Transform,'norm=ortho' is crucial to match the generation img = idct(idct(freq_map, axis=0, norm="ortho"), axis=1, norm="ortho") return img
[docs] @staticmethod def normalize_image(image: np.ndarray) -> Any: """Scales the reconstructed image to 0-1 range for viewing.""" img_min = image.min() img_max = image.max() if img_max - img_min == 0: return image return (image - img_min) / (img_max - img_min)