Skip to content

Commit 6701c34

Browse files
authored
Add PixelResponseNonUniformity effect for fixed per-pixel gain variation (PRNU) (#886)
2 parents 957ece0 + be4928b commit 6701c34

3 files changed

Lines changed: 154 additions & 1 deletion

File tree

scopesim/effects/electronic/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- PoorMansHxRGReadoutNoise - simple readout noise for HAWAII detectors
1010
- BasicReadoutNoise - readout noise
1111
- ShotNoise - realisation of Poissonian photon noise
12+
- PixelResponseNonUniformity - per-pixel gain variation (PRNU)
1213
- DarkCurrent - add dark current
1314
- LinearityCurve - apply detector (non-)linearity and saturation
1415
- ReferencePixelBorder
@@ -23,7 +24,7 @@
2324

2425
from .electrons import LinearityCurve, ADConversion, InterPixelCapacitance
2526
from .noise import (Bias, PoorMansHxRGReadoutNoise, BasicReadoutNoise,
26-
ShotNoise, DarkCurrent)
27+
ShotNoise, DarkCurrent, PixelResponseNonUniformity)
2728
from .exposure import AutoExposure, ExposureIntegration, ExposureOutput
2829
from .pixels import ReferencePixelBorder, BinnedImage, UnequalBinnedImage
2930
from .dmps import DetectorModePropertiesSetter

scopesim/effects/electronic/noise.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,85 @@ def plot_hist(self, det, **kwargs):
134134
ax.hist(dtcr.data.flatten())
135135

136136

137+
class PixelResponseNonUniformity(Effect):
138+
"""Pixel Response Non-Uniformity (PRNU).
139+
140+
Models the fixed pattern of per-pixel gain variations across the detector
141+
arising from manufacturing differences in quantum efficiency. Each pixel is
142+
multiplied by a gain factor drawn from N(1, ``prnu_std``) keyed by detector ID.
143+
The gain map is generated once per detector on first use and reused identically
144+
across all subsequent exposures.
145+
146+
Parameters
147+
----------
148+
prnu_std : float or dict
149+
Standard deviation of the per-pixel gain distribution.
150+
151+
prnu_seed : int, fixed
152+
153+
include: "!DET.include_prnu"
154+
155+
Example
156+
--------
157+
158+
- name: prnu
159+
description: Pixel response non-uniformity
160+
class: PixelResponseNonUniformity
161+
kwargs:
162+
prnu_std: 0.001
163+
prnu_seed: 42
164+
include: "!DET.include_prnu"
165+
166+
"""
167+
168+
required_keys: ClassVar[set] = set()
169+
z_order: ClassVar[tuple[int, ...]] = (805,)
170+
171+
def __init__(self, **kwargs):
172+
super().__init__(**kwargs)
173+
self.meta.update(kwargs)
174+
self._gain_maps = {} # keyed by dtcr_id
175+
176+
def apply_to(self, obj, **kwargs):
177+
if not isinstance(obj, Detector):
178+
return obj
179+
180+
random_seed = from_currsys(self.meta.get("prnu_seed"), self.cmds)
181+
id_key = real_colname("id", obj.meta)
182+
dtcr_id = obj.meta[id_key] if id_key is not None else None
183+
184+
prnu_std_meta = from_currsys(self.meta["prnu_std"], self.cmds)
185+
if isinstance(prnu_std_meta, dict):
186+
prnu_std = float(from_currsys(prnu_std_meta[dtcr_id], self.cmds))
187+
elif isinstance(prnu_std_meta, (int, float)):
188+
prnu_std = float(prnu_std_meta)
189+
else:
190+
raise TypeError(
191+
"<PixelResponseNonUniformity>.meta['prnu_std'] must be a float "
192+
f"or a dict keyed by detector ID, got {type(prnu_std_meta)}"
193+
)
194+
195+
shape = obj._hdu.data.shape
196+
if dtcr_id not in self._gain_maps or self._gain_maps[dtcr_id].shape != shape:
197+
self._gain_maps[dtcr_id] = np.random.default_rng(random_seed).normal(
198+
loc=1.0, scale=prnu_std, size=shape)
199+
200+
obj._hdu.data = obj._hdu.data * self._gain_maps[dtcr_id]
201+
return obj
202+
203+
def plot(self, det_id=None):
204+
if not self._gain_maps:
205+
raise RuntimeError("No gain map yet — run a simulation first.")
206+
key = det_id if det_id in self._gain_maps else next(iter(self._gain_maps))
207+
gain_map = self._gain_maps[key]
208+
dev = np.max(np.abs(gain_map - 1.0))
209+
fig, ax = figure_factory()
210+
im = ax.imshow(gain_map, origin="lower", aspect="auto",
211+
vmin=1 - dev, vmax=1 + dev)
212+
fig.colorbar(im, ax=ax, label="per-pixel gain")
213+
return fig
214+
215+
137216
class ShotNoise(Effect):
138217
z_order: ClassVar[tuple[int, ...]] = (820,)
139218

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from matplotlib import pyplot as plt
2+
import numpy as np
3+
import pytest
4+
5+
from scopesim.detector import Detector
6+
from scopesim.effects.electronic import PixelResponseNonUniformity
7+
from scopesim.optics.image_plane_utils import header_from_list_of_xy
8+
9+
PLOTS = False
10+
11+
def make_detector(value=1000, size=10):
12+
hdr = header_from_list_of_xy([-size/2, size/2], [-size/2, size/2], 1, "D")
13+
dtcr = Detector(hdr)
14+
dtcr._hdu.data[:] = value
15+
return dtcr
16+
17+
18+
class TestApplyTo:
19+
def test_output_std_matches_prnu_std(self):
20+
"""Relative std of output should be close to prnu_std."""
21+
prnu_std = 0.05
22+
dtcr = make_detector(value=1000, size=100)
23+
PixelResponseNonUniformity(prnu_std=prnu_std, prnu_seed=42).apply_to(dtcr)
24+
rel_std = dtcr._hdu.data.std() / dtcr._hdu.data.mean()
25+
assert abs(rel_std - prnu_std) < 0.01
26+
27+
def test_gain_map_is_reused(self):
28+
"""Same map should be applied on repeated calls (fixed pattern)."""
29+
dtcr1 = make_detector()
30+
dtcr2 = make_detector()
31+
prnu = PixelResponseNonUniformity(prnu_std=0.01, prnu_seed=42)
32+
prnu.apply_to(dtcr1)
33+
prnu.apply_to(dtcr2)
34+
np.testing.assert_array_equal(dtcr1._hdu.data, dtcr2._hdu.data)
35+
36+
def test_dict_prnu_std(self):
37+
"""Dict mode: different amplitude per detector ID."""
38+
hdr = header_from_list_of_xy([-5, 5], [-5, 5], 1, "D")
39+
dtcr = Detector(hdr)
40+
dtcr.meta["id"] = "H2RG"
41+
dtcr._hdu.data[:] = 1000
42+
prnu = PixelResponseNonUniformity(
43+
prnu_std={"H2RG": 0.005, "GeoSnap": 0.020}, prnu_seed=42)
44+
prnu.apply_to(dtcr)
45+
assert dtcr._hdu.data.std() > 0
46+
47+
def test_multiplicative_zero_signal(self):
48+
"""Zero signal should remain zero — confirms multiplicative (not additive)."""
49+
dtcr = make_detector(value=0)
50+
PixelResponseNonUniformity(prnu_std=0.01, prnu_seed=42).apply_to(dtcr)
51+
assert dtcr._hdu.data.sum() == 0
52+
53+
def test_non_detector_passthrough(self):
54+
"""Non-Detector objects should be returned unchanged."""
55+
prnu = PixelResponseNonUniformity(prnu_std=0.01)
56+
result = prnu.apply_to("not a detector")
57+
assert result == "not a detector"
58+
59+
def test_invalid_prnu_std_raises(self):
60+
prnu = PixelResponseNonUniformity(prnu_std="not a number nor a dict")
61+
with pytest.raises(TypeError):
62+
prnu.apply_to(make_detector())
63+
64+
def test_plot_raises_before_simulation(self):
65+
prnu = PixelResponseNonUniformity(prnu_std=0.01)
66+
with pytest.raises(RuntimeError):
67+
prnu.plot()
68+
69+
def test_plot_returns_figure(self):
70+
prnu = PixelResponseNonUniformity(prnu_std=0.01, prnu_seed=42)
71+
prnu.apply_to(make_detector())
72+
if PLOTS:
73+
assert isinstance(prnu.plot(), plt.Figure)

0 commit comments

Comments
 (0)