Source code for pyfli.sp_analysis.simulator.measurement_sim
# sp_analysis/simulator/measurement_sim.py
"""
Simulate patterned measurements with optional Gaussian and shot noise.
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:`MeasurementSimulator`.
"""
from typing import Any
import numpy as np
[docs]
class MeasurementSimulator:
"""
Simulate patterned single-pixel measurements from a scene and sensing basis. It can
add Gaussian noise, shot noise, differential measurements, and simple SNR estimates.
Parameters
----------
noise_level : float
Additive noise level used by the measurement simulator.
shot_noise : bool
Whether to add Poisson shot noise to simulated measurements.
"""
def __init__(self, noise_level: float = 0.0, shot_noise: bool = False) -> None:
self.noise_level = noise_level
self.shot_noise = shot_noise
[docs]
def capture(self, scene: np.ndarray, patterns: np.ndarray) -> np.ndarray:
"""
Simulates the physical projection: y = A * x
"""
x = scene.flatten(order="C").astype(float)
# Linear light integration
measurements = np.dot(patterns, x)
# Add Shot Noise (Poisson-like simulation)
if self.shot_noise:
# Scaled to keep it realistic; noise increases with intensity
noise_scale = np.sqrt(np.abs(measurements)) * self.noise_level
measurements += np.random.normal(0, 1, measurements.shape) * noise_scale
# Add Electronic/Thermal Noise (Gaussian)
elif self.noise_level > 0:
noise = np.random.normal(0, self.noise_level, measurements.shape)
measurements += noise
return measurements
[docs]
def process_differential(self, measurements: np.ndarray) -> Any:
"""
Implements y_diff = y_pos - y_neg.
Matches the stacked output of BasisPatterns.generate_hadamard(differential=True).
"""
# Ensure we have an even number of measurements for pairing
n_pairs = len(measurements) // 2
y_pos = measurements[:n_pairs]
y_neg = measurements[n_pairs:]
return y_pos - y_neg
[docs]
def simulate_fourier_acquisition(
self, scene: np.ndarray, fourier_patterns: np.ndarray
) -> Any:
"""
For grayscale fringes, we simulate the 'DC-centered' signal.
In the lab, you often measure the average brightness of the room
first and subtract it.
"""
measurements = self.capture(scene, fourier_patterns)
# Subtracting mean removes the global offset caused by the [0, 1]
# normalization in pattern_gen.
return measurements - np.mean(measurements)
[docs]
@staticmethod
def get_snr(clean_signal: np.ndarray, noisy_signal: np.ndarray) -> Any:
"""
Calculates SNR in decibels.
Higher is better.
"""
# Use clean signal for power calculation to avoid bias
signal_power = np.mean(clean_signal**2)
noise_power = np.mean((clean_signal - noisy_signal) ** 2)
if noise_power == 0:
return float("inf")
return 10 * np.log10(signal_power / noise_power)