Skip to content

Commit 73b3039

Browse files
Baharishzanolistefsmeets
authored
Improve ASI detector support and reduce excessive logging (#111)
* use serval to interface with timepix3 cameras * Simplify HZanoli's changes, auto-determine `self.exposure_cooldown` from config * Stop the passive collection during single-frame acquisition instead of setting `self.grabber.frametime = 0` to avoid failing 0-second frames * In `CameraServal`, always request `tiff` (faster than pgm), removing need for 'asic' config Reading tiff is faster than pgm virtually always, especially if PixelDepth = 2**N (benchmark: https://chatgpt.com/canvas/shared/67b4942e54808191bf08685ce305112d): bit_depth pgm png tiff 1 117.41 ± 6.32 ms nan ± nan ms 0.17 ± 0.03 ms 4 117.24 ± 11.10 ms nan ± nan ms 0.17 ± 0.04 ms 6 151.23 ± 48.35 ms nan ± nan ms 0.16 ± 0.01 ms 8 0.08 ± 0.22 ms 1.41 ± 0.09 ms 0.20 ± 0.07 ms 10 153.95 ± 13.69 ms nan ± nan ms 0.63 ± 0.10 ms 12 153.15 ± 23.07 ms nan ± nan ms 0.46 ± 0.17 ms 14 150.50 ± 17.25 ms nan ± nan ms 0.68 ± 0.11 ms 16 1.84 ± 0.20 ms 2.10 ± 0.15 ms 0.42 ± 0.15 ms 20 nan ± nan ms nan ± nan ms 2.17 ± 0.22 ms 24 nan ± nan ms nan ± nan ms 2.21 ± 0.21 ms 32 nan ± nan ms nan ± nan ms 2.22 ± 0.19 ms * Serval: restrict exposure to 0.001-10 (common medi/timepix limit AFAIK) * Detector_config.asic is unnecessary if Serval always saves tiff * Fix `serval.yaml`: {PixelDepth: 24, BothCounters: True} are mutually incompatible * Filter out external DEBUG log records: reduces log size by 100MB/s * In `THANKS.md`, fix the link so that it points into instamatic contributions * In `about` frame, fix links and make other text copy-able * Auto-adapt `Copyable` width to prevent main widget stretching * Apply ruff suggestions to `main.py` * @hzanoli: in `get_movie`, exposure and trigger periods both have to match * With new `block`-based mechanism, these lines are no longer needed * Make tem_server serializer import absolute to allow running as script * Improve the comments in `serval.yaml` config file Co-authored-by: Henrique Zanoli <henrique.zanoli@amscins.com> * Handle 0 <= exposure < 10, fix `get_movie` (TODO test) * Prevent tk from trying to write into a closed `console_frame:Writer` * Synchronize `_get_image_single`, `get_movie` to prevent threading race issues * Improve typing, readability on `camera_serval`, follow ruff format * Log instamatic debug if -v, other debug if -vv, paths if -vvv * Fix typo, consistenly use `option.verbose` * Defer formatting of log messages until necessary Co-authored-by: Stef Smeets <stefsmeets@users.noreply.github.com> * Reorder image getters in `CameraServal.get_image` (todo fix manually) Co-authored-by: Stef Smeets <stefsmeets@users.noreply.github.com> * Update `videostream.py`: block passive preview when collecting movie * Remove `CameraServal.lock` as synchronisation is now done at `VideoStream` * `VideoStream`: Stop requesting media after it has been collected * `VideoStream`: callback with request to avoid confusion, add `test_get_movie` * Generalize `VideoStream.get_image` and `.get_movie` into `_get_media` * `CameraServal` tested, make `get_movie` return `np.ndarray`s not `Image`s * `CameraServal`: add unified `_get_images`, allow movies > MAX_EXPOSURE * `CameraServal`: single image is communicated via `n_triggers = Ignore` * Fix typos, consistently use param name `n_frames` rather than `n_triggers` * Try to squeeze frame definition in 2 lines Co-authored-by: Stef Smeets <stefsmeets@users.noreply.github.com> * Add missing `MovieRequest` docstring --------- Co-authored-by: Henrique Zanoli <henrique.zanoli@amscins.com> Co-authored-by: Stef Smeets <stefsmeets@users.noreply.github.com>
1 parent 20f8285 commit 73b3039

File tree

10 files changed

+279
-128
lines changed

10 files changed

+279
-128
lines changed

THANKS.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Code contributors
22
-----------------
33

4-
See [Github contributors list](https://github.com/nipy/nipype/graphs/contributors)
4+
See [Github contributors list](https://github.com/instamatic-dev/instamatic/graphs/contributors)
55

66
Special thanks
77
--------------

src/instamatic/camera/camera_serval.py

+97-51
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
import atexit
44
import logging
5-
from pathlib import Path
6-
from typing import Tuple
5+
import math
6+
from itertools import batched
7+
from typing import List, Optional, Sequence, Tuple, Union
78

89
import numpy as np
910
from serval_toolkit.camera import Camera as ServalCamera
1011

11-
from instamatic import config
1212
from instamatic.camera.camera_base import CameraBase
1313

1414
logger = logging.getLogger(__name__)
@@ -18,40 +18,100 @@
1818
# 2. `java -jar .\server\serv-2.1.3.jar`
1919
# 3. launch `instamatic`
2020

21+
Ignore = object() # sentinel object: informs `_get_images` to get a single image
22+
2123

2224
class CameraServal(CameraBase):
2325
"""Interfaces with Serval from ASI."""
2426

2527
streamable = True
28+
MIN_EXPOSURE = 0.000001
29+
MAX_EXPOSURE = 10.0
30+
BAD_EXPOSURE_MSG = 'Requested exposure exceeds native Serval support (>0-10s)'
2631

2732
def __init__(self, name='serval'):
2833
"""Initialize camera module."""
2934
super().__init__(name)
30-
3135
self.establish_connection()
32-
33-
msg = f'Camera {self.get_name()} initialized'
34-
logger.info(msg)
35-
36+
self.dead_time = (
37+
self.detector_config['TriggerPeriod'] - self.detector_config['ExposureTime']
38+
)
39+
logger.info(f'Camera {self.get_name()} initialized')
3640
atexit.register(self.release_connection)
3741

38-
def get_image(self, exposure=None, binsize=None, **kwargs) -> np.ndarray:
39-
"""Image acquisition routine. If the exposure and binsize are not
40-
given, the default values are read from the config file.
42+
def get_image(self, exposure: Optional[float] = None, **kwargs) -> np.ndarray:
43+
"""Image acquisition interface. If the exposure is not given, the
44+
default value is read from the config file. Binning is ignored.
4145
42-
exposure:
46+
exposure: `float` or `None`
4347
Exposure time in seconds.
44-
binsize:
45-
Which binning to use.
4648
"""
47-
if exposure is None:
48-
exposure = self.default_exposure
49-
if not binsize:
50-
binsize = self.default_binsize
49+
return self._get_images(n_frames=Ignore, exposure=exposure, **kwargs)
50+
51+
def get_movie(
52+
self, n_frames: int, exposure: Optional[float] = None, **kwargs
53+
) -> List[np.ndarray]:
54+
"""Movie acquisition interface. If the exposure is not given, the
55+
default value is read from the config file. Binning is ignored.
5156
57+
n_frames: `int`
58+
Number of frames to collect
59+
exposure: `float` or `None`
60+
Exposure time in seconds.
61+
"""
62+
return self._get_images(n_frames=n_frames, exposure=exposure, **kwargs)
63+
64+
def _get_images(
65+
self,
66+
n_frames: Union[int, Ignore],
67+
exposure: Optional[float] = None,
68+
**kwargs,
69+
) -> Union[np.ndarray, List[np.ndarray]]:
70+
"""General media acquisition dispatcher for other protected methods."""
71+
n: int = 1 if n_frames is Ignore else n_frames
72+
e: float = self.default_exposure if exposure is None else exposure
73+
74+
if n_frames == 0: # single image is communicated via n_frames = Ignore
75+
return []
76+
77+
elif e < self.MIN_EXPOSURE:
78+
logger.warning('%s: %d', self.BAD_EXPOSURE_MSG, e)
79+
if n_frames is Ignore:
80+
return self._get_image_null(exposure=e, **kwargs)
81+
return [self._get_image_null(exposure=e, **kwargs) for _ in range(n)]
82+
83+
elif e > self.MAX_EXPOSURE:
84+
logger.warning('%s: %d', self.BAD_EXPOSURE_MSG, e)
85+
n1 = math.ceil(e / self.MAX_EXPOSURE)
86+
e = (e + self.dead_time) / n1 - self.dead_time
87+
images = self._get_image_stack(n_frames=n * n1, exposure=e, **kwargs)
88+
if n_frames is Ignore:
89+
return self._spliced_sum(images, exposure=e)
90+
return [self._spliced_sum(i, exposure=e) for i in batched(images, n1)]
91+
92+
else: # if exposure is within limits
93+
if n_frames is Ignore:
94+
return self._get_image_single(exposure=e, **kwargs)
95+
return self._get_image_stack(n_frames=n, exposure=e, **kwargs)
96+
97+
def _spliced_sum(self, arrays: Sequence[np.ndarray], exposure: float) -> np.ndarray:
98+
"""Sum a series of arrays while applying a dead time correction."""
99+
array_sum = sum(arrays, np.zeros_like(arrays[0]))
100+
total_exposure = len(arrays) * exposure + (len(arrays) - 1) * self.dead_time
101+
live_fraction = len(arrays) * exposure / total_exposure
102+
return (array_sum / live_fraction).astype(arrays[0].dtype)
103+
104+
def _get_image_null(self, **_) -> np.ndarray:
105+
logger.debug('Creating a synthetic image with zero counts')
106+
return np.zeros(shape=self.get_image_dimensions(), dtype=np.int32)
107+
108+
def _get_image_single(self, exposure: float, **_) -> np.ndarray:
109+
"""Request a single frame in the mode in a trigger collection mode."""
110+
logger.debug(f'Collecting a single image with exposure {exposure} s')
52111
# Upload exposure settings (Note: will do nothing if no change in settings)
53112
self.conn.set_detector_config(
54-
ExposureTime=exposure, TriggerPeriod=exposure + 0.00050001
113+
ExposureTime=exposure,
114+
TriggerPeriod=exposure + self.dead_time,
55115
)
56116

57117
# Check if measurement is running. If not: start
@@ -67,31 +127,23 @@ def get_image(self, exposure=None, binsize=None, **kwargs) -> np.ndarray:
67127
arr = np.array(img)
68128
return arr
69129

70-
def get_movie(self, n_frames, exposure=None, binsize=None, **kwargs):
71-
"""Movie acquisition routine. If the exposure and binsize are not
72-
given, the default values are read from the config file.
73-
74-
n_frames:
75-
Number of frames to collect
76-
exposure:
77-
Exposure time in seconds.
78-
binsize:
79-
Which binning to use.
80-
"""
81-
if exposure is None:
82-
exposure = self.default_exposure
83-
if not binsize:
84-
binsize = self.default_binsize
85-
86-
self.conn.set_detector_config(TriggerMode='CONTINUOUS')
87-
88-
arr = self.conn.get_images(
89-
nTriggers=n_frames,
130+
def _get_image_stack(self, n_frames: int, exposure: float, **_) -> list[np.ndarray]:
131+
"""Get a series of images in a mode with minimal dead time."""
132+
logger.debug(f'Collecting {n_frames} images with exposure {exposure} s')
133+
mode = 'AUTOTRIGSTART_TIMERSTOP' if self.dead_time else 'CONTINUOUS'
134+
self.conn.measurement_stop()
135+
previous_config = self.conn.detector_config
136+
self.conn.set_detector_config(
137+
TriggerMode=mode,
90138
ExposureTime=exposure,
91-
TriggerPeriod=exposure,
139+
TriggerPeriod=exposure + self.dead_time,
140+
nTriggers=n_frames,
92141
)
93-
94-
return arr
142+
self.conn.measurement_start()
143+
images = self.conn.get_image_stream(nTriggers=n_frames, disable_tqdm=True)
144+
self.conn.measurement_stop()
145+
self.conn.set_detector_config(**previous_config)
146+
return [np.array(image) for image in images]
95147

96148
def get_image_dimensions(self) -> Tuple[int, int]:
97149
"""Get the binned dimensions reported by the camera."""
@@ -111,23 +163,17 @@ def establish_connection(self) -> None:
111163
bpc_file_path=self.bpc_file_path, dacs_file_path=self.dacs_file_path
112164
)
113165
self.conn.set_detector_config(**self.detector_config)
114-
# Check pixel depth. If 24 bit mode is used, the pgm format does not work
115-
# (has support up to 16 bits) so use tiff in that case. In other cases (1, 6, 12 bits)
116-
# use pgm since it is more efficient
117-
self.pixel_depth = self.conn.detector_config['PixelDepth']
118-
if self.pixel_depth == 24:
119-
file_format = 'tiff'
120-
else:
121-
file_format = 'pgm'
166+
122167
self.conn.destination = {
123168
'Image': [
124169
{
125170
# Where to place the preview files (HTTP end-point: GET localhost:8080/measurement/image)
126171
'Base': 'http://localhost',
127172
# What (image) format to provide the files in.
128-
'Format': file_format,
173+
'Format': 'tiff',
129174
# What data to build a frame from
130175
'Mode': 'count',
176+
# 'QueueSize': 2,
131177
}
132178
],
133179
}

src/instamatic/camera/videostream.py

+72-45
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,40 @@
22

33
import atexit
44
import threading
5+
from dataclasses import dataclass
6+
from typing import List, Optional, Union
7+
8+
import numpy as np
59

610
from instamatic.camera.camera_base import CameraBase
711

812
from .camera import Camera
913

1014

11-
class ImageGrabber:
15+
@dataclass(frozen=True)
16+
class MediaRequest:
17+
n_frames: Optional[int] = None
18+
exposure: Optional[float] = None
19+
binsize: Optional[int] = None
20+
21+
22+
class ImageRequest(MediaRequest):
23+
"""To be used when requesting a single image via `get_image`"""
24+
25+
26+
class MovieRequest(MediaRequest):
27+
"""To be used when requesting an image series via `get_movie`"""
28+
29+
30+
class MediaGrabber:
1231
"""Continuously read out the camera for continuous acquisition.
1332
1433
When the continousCollectionEvent is set, the camera will set the
1534
exposure to `frametime`, otherwise, the default camera exposure is
1635
used.
1736
18-
The callback function is used to send the frame back to the parent
19-
routine.
37+
The callback function is used to send the media, either image or movie,
38+
back to the parent routine.
2039
"""
2140

2241
def __init__(self, cam: CameraBase, callback, frametime: float = 0.05):
@@ -37,9 +56,8 @@ def __init__(self, cam: CameraBase, callback, frametime: float = 0.05):
3756
self.stash = None
3857

3958
self.frametime = frametime
40-
self.exposure = self.frametime
41-
self.binsize = self.cam.default_binsize
42-
59+
self.request: Optional[MediaRequest] = None
60+
self.requested_media = None
4361
self.lock = threading.Lock()
4462

4563
self.stopEvent = threading.Event()
@@ -50,14 +68,22 @@ def __init__(self, cam: CameraBase, callback, frametime: float = 0.05):
5068
def run(self):
5169
while not self.stopEvent.is_set():
5270
if self.acquireInitiateEvent.is_set():
71+
r = self.request
5372
self.acquireInitiateEvent.clear()
54-
55-
frame = self.cam.get_image(exposure=self.exposure, binsize=self.binsize)
56-
self.callback(frame, acquire=True)
73+
e = r.exposure if r.exposure else self.default_exposure
74+
b = r.binsize if r.binsize else self.default_binsize
75+
if isinstance(r, ImageRequest):
76+
media = self.cam.get_image(exposure=e, binsize=b)
77+
else: # isinstance(r, MovieRequest):
78+
n = r.n_frames if r.n_frames else 1
79+
media = self.cam.get_movie(n_frames=n, exposure=e, binsize=b)
80+
self.callback(media, request=r)
5781

5882
elif not self.continuousCollectionEvent.is_set():
59-
frame = self.cam.get_image(exposure=self.frametime, binsize=self.binsize)
60-
self.callback(frame)
83+
frame = self.cam.get_image(
84+
exposure=self.frametime, binsize=self.default_binsize
85+
)
86+
self.callback(frame, request=None)
6187

6288
def start_loop(self):
6389
self.thread = threading.Thread(target=self.run, args=(), daemon=True)
@@ -71,14 +97,10 @@ def stop(self):
7197
class VideoStream(threading.Thread):
7298
"""Handle the continuous stream of incoming data from the ImageGrabber."""
7399

74-
def __init__(self, cam='simulate'):
100+
def __init__(self, cam: Union[CameraBase, str] = 'simulate'):
75101
threading.Thread.__init__(self)
76102

77-
if isinstance(cam, str):
78-
self.cam = Camera(name=cam)
79-
else:
80-
self.cam = cam
81-
103+
self.cam: CameraBase = Camera(name=cam) if isinstance(cam, str) else cam
82104
self.lock = threading.Lock()
83105

84106
self.default_exposure = self.cam.default_exposure
@@ -109,43 +131,48 @@ def __getattr__(self, attrname):
109131
def start(self):
110132
self.grabber.start_loop()
111133

112-
def send_frame(self, frame, acquire=False):
113-
if acquire:
114-
self.grabber.lock.acquire(True)
115-
self.acquired_frame = self.frame = frame
116-
self.grabber.lock.release()
117-
self.grabber.acquireCompleteEvent.set()
118-
else:
119-
self.grabber.lock.acquire(True)
120-
self.frame = frame
121-
self.grabber.lock.release()
122-
123-
def setup_grabber(self) -> ImageGrabber:
124-
grabber = ImageGrabber(self.cam, callback=self.send_frame, frametime=self.frametime)
134+
def send_media(
135+
self,
136+
media: Union[np.ndarray, List[np.ndarray]],
137+
request: Optional[MediaRequest] = None,
138+
) -> None:
139+
"""Callback function of `self.grabber` that handles grabbed media."""
140+
with self.grabber.lock:
141+
if request is None:
142+
self.frame = media
143+
elif isinstance(request, ImageRequest):
144+
self.requested_media = self.frame = media
145+
self.grabber.acquireCompleteEvent.set()
146+
else: # isinstance(request, MovieRequest):
147+
self.requested_media = media
148+
self.frame = media[-1]
149+
self.grabber.acquireCompleteEvent.set()
150+
151+
def setup_grabber(self) -> MediaGrabber:
152+
grabber = MediaGrabber(self.cam, callback=self.send_media, frametime=self.frametime)
125153
atexit.register(grabber.stop)
126154
return grabber
127155

128156
def get_image(self, exposure=None, binsize=None):
129-
current_frametime = self.grabber.frametime
157+
request = ImageRequest(exposure=exposure, binsize=binsize)
158+
return self._get_media(request)
130159

131-
# set to 0 to prevent it lagging data acquisition
132-
self.grabber.frametime = 0
133-
if exposure:
134-
self.grabber.exposure = exposure
135-
if binsize:
136-
self.grabber.binsize = binsize
160+
def get_movie(self, n_frames: int, exposure=None, binsize=None):
161+
request = MovieRequest(n_frames=n_frames, exposure=exposure, binsize=binsize)
162+
return self._get_media(request)
137163

164+
def _get_media(self, request: MediaRequest) -> Union[np.ndarray, List[np.ndarray]]:
165+
self.block() # Stop the passive collection during request acquisition
166+
self.grabber.request = request
138167
self.grabber.acquireInitiateEvent.set()
139-
140168
self.grabber.acquireCompleteEvent.wait()
141-
142-
self.grabber.lock.acquire(True)
143-
frame = self.acquired_frame
144-
self.grabber.lock.release()
145-
169+
with self.grabber.lock:
170+
media = self.requested_media
171+
self.requested_media = None
172+
self.grabber.request = None
146173
self.grabber.acquireCompleteEvent.clear()
147-
self.grabber.frametime = current_frametime
148-
return frame
174+
self.unblock() # Resume the passive collection
175+
return media
149176

150177
def update_frametime(self, frametime):
151178
self.frametime = frametime

0 commit comments

Comments
 (0)