Skip to content

Commit cb519dc

Browse files
committed
[feat] added peak simulation
1 parent 5e61817 commit cb519dc

4 files changed

Lines changed: 233 additions & 7 deletions

File tree

src/odemis/driver/simcam.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,11 @@
3434
from odemis import dataio, model, util
3535
from odemis.model import oneway
3636
from odemis.util.synthetic import ParabolicMirrorRayTracer
37+
from odemis.util.synthetic import simulate_peak
3738

3839
ERROR_STATE_FILE = "simcam-hw.error"
39-
40+
GOFFSET_TO_PIXEL = 0.25 # Conversion factor for grating offset to image pixels.
41+
PEAK_WIDTH = 2.5 # Width of the simulated spectrograph peak in pixels (before binning).
4042

4143
class Camera(model.DigitalCamera):
4244
'''
@@ -179,6 +181,22 @@ def clip_max_res(img_res):
179181
self._img_simulator = None
180182
self._mirror = None
181183

184+
try:
185+
spectrograph = dependencies["spectrograph"]
186+
if not (
187+
isinstance(spectrograph, model.ComponentBase)
188+
and hasattr(spectrograph, "axes")
189+
and isinstance(spectrograph.axes, dict)
190+
and "goffset" in spectrograph.axes):
191+
raise ValueError("spectrograph %s must have a 'goffset' attribute" % spectrograph)
192+
193+
self._spectrograph = spectrograph
194+
logging.debug("Will simulate spectral peaks using spectrograph %s", spectrograph.name)
195+
196+
except (TypeError, KeyError):
197+
logging.info("Will not simulate spectrograph peaks")
198+
self._spectrograph = None
199+
182200
# Simple implementation of the flow: we keep generating images and if
183201
# there are subscribers, they'll receive it.
184202
self.data = SimpleDataFlow(self)
@@ -374,6 +392,11 @@ def _simulate(self):
374392
center = self._img_res[0] / 2, self._img_res[1] / 2
375393
pixel_size = self._metadata.get(model.MD_PIXEL_SIZE, self.pixelSize.value)
376394

395+
# Extra translation to simulate stage movement
396+
pos = self._metadata.get(model.MD_POS, (0, 0))
397+
pxs = [p / b for p, b in zip(pixel_size, self.binning.value)]
398+
stage_shift = pos[0] / pxs[0], -pos[1] / pxs[1] # Y goes opposite
399+
377400
if self._mirror:
378401
eff_pixel_size = [p * b for p, b in zip(pixel_size, binning)]
379402
self._img_simulator.resolution.value = res
@@ -382,11 +405,6 @@ def _simulate(self):
382405
self._img = model.DataArray(sim_img, self._img.metadata)
383406
sim_img = self._img.copy()
384407
else:
385-
# Extra translation to simulate stage movement
386-
pos = self._metadata.get(model.MD_POS, (0, 0))
387-
pxs = [p / b for p, b in zip(pixel_size, self.binning.value)]
388-
stage_shift = pos[0] / pxs[0], -pos[1] / pxs[1] # Y goes opposite
389-
390408
# First and last index (eg, 0 -> 255)
391409
ltrb = [center[0] + trans[0] + stage_shift[0] - (res[0] / 2) * binning[0],
392410
center[1] + trans[1] + stage_shift[1] - (res[1] / 2) * binning[1],
@@ -413,6 +431,31 @@ def _simulate(self):
413431
[int(round(ltrb[1] + i * binning[1])) for i in range(res[1])])
414432
sim_img = self._img[numpy.ix_(coord[1], coord[0])] # copy
415433

434+
# spectrograph peak simulation
435+
if self._spectrograph:
436+
current_offset = self._spectrograph.position.value["goffset"]
437+
438+
ccd_center_x = self._img_res[0]/2.0 # find the x-coordinate of the center of the ccd
439+
x0_px = ccd_center_x + current_offset * GOFFSET_TO_PIXEL
440+
roi_left = center[0] + trans[0] + stage_shift[0] - (res[0] / 2) * binning[0]
441+
442+
bin_x = binning[0] # binning factor along x-axis
443+
peak_center_binned = (x0_px - roi_left) / bin_x # express the peak position in the ROI's coordinate system
444+
445+
logging.info("DEBUG: x0_px=%s, ltrb0=%s, result=%s",
446+
x0_px, roi_left, peak_center_binned
447+
)
448+
449+
width_binned = PEAK_WIDTH / bin_x
450+
451+
peak = simulate_peak(amplitude=20000, x0=peak_center_binned, width=width_binned,
452+
shape=sim_img.shape, dtype=sim_img.dtype)
453+
454+
# set all values in sim_img to the minimal sim_img value
455+
min_val = sim_img.min()
456+
sim_img[...] = min_val
457+
sim_img += peak
458+
416459
# Add some noise
417460
mx = self._img.max()
418461
sim_img += numpy.random.randint(0, max(mx // 100, 10), sim_img.shape, dtype=sim_img.dtype)

src/odemis/driver/test/simcam_test.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,5 +457,79 @@ def test_acquire_ar_pol(self):
457457
testing.assert_array_not_equal(im_horizontal, im_vertical)
458458

459459

460+
class TestSimCamSpectrograph(unittest.TestCase):
461+
462+
@classmethod
463+
def setUpClass(cls):
464+
class MockSpectrograph(model.Component):
465+
def __init__(self, name="mock_spectrograph"):
466+
super().__init__(name)
467+
self.axes = {"goffset": None}
468+
self.position = type('obj', (object,), {'value': {"goffset": 0.0}})()
469+
470+
cls.focus = simulated.Stage(**KWARGS_FOCUS)
471+
cls.mock_spectrograph = MockSpectrograph()
472+
473+
cls.camera = CLASS(dependencies={"focus": cls.focus, "spectrograph": cls.mock_spectrograph},
474+
**KWARGS_MOVE)
475+
476+
@classmethod
477+
def tearDownClass(cls):
478+
cls.camera.terminate()
479+
480+
def setUp(self):
481+
self.camera.binning.value = (1, 1)
482+
self.camera.translation.value = (0, 0)
483+
self.camera.resolution.value = self.camera.resolution.range[1]
484+
self.camera.exposureTime.value = 0.05
485+
self.mock_spectrograph.position.value["goffset"] = 0.0
486+
time.sleep(0.1)
487+
488+
# helper function to find the peak
489+
def _find_peak(self, image):
490+
data = image.astype(float)
491+
profile = data.mean(axis=0)
492+
493+
p_min, p_max = profile.min(), profile.max()
494+
threshold = p_min + (p_max - p_min) * 0.8
495+
496+
mask = profile > threshold
497+
indices = numpy.arange(len(profile))
498+
499+
if numpy.sum(mask) == 0:
500+
return float(profile.argmax(()), p_max, profile.mean())
501+
502+
# find the peak
503+
weighted_average = numpy.sum(indices[mask] * profile[mask]) / numpy.sum(profile[mask])
504+
505+
return weighted_average, p_max, profile.mean()
506+
507+
def test_spectrograph_with_goffset(self):
508+
move = 300
509+
expected_ratio = 0.25
510+
511+
self.mock_spectrograph.position.value["goffset"] = 0.0
512+
image_0 = self.camera.data.get()
513+
date_0 = image_0.metadata[model.MD_ACQ_DATE]
514+
515+
# change offset
516+
self.mock_spectrograph.position.value["goffset"] = move
517+
518+
image_new = None
519+
for _ in range(10):
520+
image_new = self.camera.data.get()
521+
if image_new.metadata[model.MD_ACQ_DATE] > date_0:
522+
break
523+
time.sleep(0.05)
524+
525+
x0, _, _ = self._find_peak(image_0)
526+
x1, _, _ = self._find_peak(image_new)
527+
528+
shift = x1 - x0
529+
print(f"DEBUG: Moved {move}, Peak went from {x0} to {x1}. Shift: {shift}")
530+
531+
expected_shift = move * expected_ratio
532+
self.assertAlmostEqual(shift, expected_shift, delta=0.5)
533+
460534
if __name__ == '__main__':
461535
unittest.main()

src/odemis/util/synthetic.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,37 @@ def psf_gaussian(
128128
numpy.rint(UINT16_MAX * out, out=out)
129129
return out.astype(numpy.uint16)
130130

131+
def simulate_peak(amplitude, x0, width, shape, dtype=numpy.uint16) -> numpy.ndarray:
132+
133+
"""
134+
Simulate a 1D Gaussian peak across the x-dimension of an image, with optional 2D image input for direct use when y > 1.
135+
:param amplitude: the maximum intensity of the peak
136+
:param x0: the center position of the peak along the x-axis
137+
:param width: the standard deviation (width) of the Gaussian peak
138+
:param shape: the shape of the output image (can be an int for 1D or a tuple for 2D)
139+
:param dtype: the data type of the output array (default is numpy.uint16)
140+
:return: a numpy array containing the simulated peak, either as a 1D Gaussian or directly from the provided image
141+
"""
142+
143+
# Standardize shape to a tuple, so we can index it
144+
if isinstance(shape, (int, numpy.integer)):
145+
shape = (shape,)
146+
147+
# Generate the 1D Gaussian peak across the x-dimension
148+
x = numpy.arange(shape[-1])
149+
intensity = amplitude*(numpy.exp(-0.5 * ((x - x0) / width)**2))
150+
151+
# Clip based on the actual dtype provided
152+
info = numpy.iinfo(dtype)
153+
intensity_clipped = numpy.clip(intensity, info.min, info.max)
154+
peak_1d = intensity_clipped.astype(dtype)
155+
156+
if len(shape) == 1:
157+
return peak_1d
158+
else:
159+
# Duplicated along the y-dimension (shape[0])
160+
return numpy.tile(peak_1d, (shape[0], 1))
161+
131162

132163
class ParabolicMirrorRayTracer:
133164
"""

src/odemis/util/test/synthetic_test.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
import numpy
2525

26-
from odemis.util.synthetic import ParabolicMirrorRayTracer
26+
from odemis.util.synthetic import ParabolicMirrorRayTracer, simulate_peak
2727

2828

2929
class TestParabolicMirrorRayTracer(unittest.TestCase):
@@ -125,6 +125,84 @@ def test_constructor_raises_value_error_on_missing_keys(self):
125125
with self.assertRaises(ValueError):
126126
ParabolicMirrorRayTracer(good_pos=bad_pos_missing_all)
127127

128+
class TestPeakSimulation(unittest.TestCase):
129+
130+
def test_simulate_peak_1d(self):
131+
"""
132+
Verify simulate_peak generates a correct 1D Gaussian peak.
133+
"""
134+
135+
amplitude = 1000
136+
x0 = 50
137+
width = 5
138+
shape = 100
139+
140+
peak = simulate_peak(amplitude, x0, width, shape)
141+
142+
self.assertEqual(peak.shape, (shape,))
143+
self.assertEqual(peak.dtype, numpy.uint16)
144+
145+
# peak maximum should occur near x0
146+
peak_idx = numpy.argmax(peak)
147+
self.assertAlmostEqual(peak_idx, x0, delta=1)
148+
149+
# peak value should be close to amplitude (clipped if necessary)
150+
self.assertAlmostEqual(peak[peak_idx], amplitude, delta=5)
151+
152+
def test_simulate_peak_2d(self):
153+
"""
154+
Verify simulate_peak correctly expands the 1D peak to 2D.
155+
"""
156+
157+
amplitude = 500
158+
x0 = 40
159+
width = 4
160+
shape = (20, 100)
161+
162+
peak = simulate_peak(amplitude, x0, width, shape)
163+
164+
self.assertEqual(peak.shape, shape)
165+
166+
# every row should be identical
167+
for row in peak:
168+
self.assertTrue(numpy.array_equal(row, peak[0]))
169+
170+
# verify peak location
171+
peak_idx = numpy.argmax(peak[0])
172+
self.assertAlmostEqual(peak_idx, x0, delta=1)
173+
174+
def test_simulate_peak_dtype_clipping(self):
175+
"""
176+
Verify values are clipped to dtype limits.
177+
"""
178+
179+
amplitude = 100000 # larger than uint16 max
180+
x0 = 30
181+
width = 3
182+
shape = 80
183+
184+
peak = simulate_peak(amplitude, x0, width, shape)
185+
186+
dtype_max = numpy.iinfo(numpy.uint16).max
187+
188+
self.assertEqual(peak.max(), dtype_max)
189+
190+
def test_simulate_peak_dtype(self):
191+
"""
192+
Verify simulate_peak respects the dtype argument.
193+
"""
194+
195+
amplitude = 200
196+
x0 = 25
197+
width = 3
198+
shape = 60
199+
200+
peak = simulate_peak(amplitude, x0, width, shape, dtype=numpy.uint8)
201+
202+
self.assertEqual(peak.dtype, numpy.uint8)
203+
204+
dtype_max = numpy.iinfo(numpy.uint8).max
205+
self.assertLessEqual(peak.max(), dtype_max)
128206

129207
if __name__ == "__main__":
130208
unittest.main()

0 commit comments

Comments
 (0)