Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve ASI detector support and reduce excessive logging #111

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f202a34
use serval to interface with timepix3 cameras
hzanoli Dec 20, 2024
8ccdef1
Simplify HZanoli's changes, auto-determine `self.exposure_cooldown` f…
Baharis Feb 17, 2025
8f0deed
Stop the passive collection during single-frame acquisition instead o…
Baharis Feb 17, 2025
1ef5a36
In `CameraServal`, always request `tiff` (faster than pgm), removing …
Baharis Feb 18, 2025
6e18b2c
Serval: restrict exposure to 0.001-10 (common medi/timepix limit AFAIK)
Baharis Feb 18, 2025
d8cbb8e
Merge branch 'main' into serval_guards
Baharis Feb 18, 2025
ea83287
Detector_config.asic is unnecessary if Serval always saves tiff
Baharis Feb 18, 2025
81a6ba6
Fix `serval.yaml`: {PixelDepth: 24, BothCounters: True} are mutually …
Baharis Feb 18, 2025
5dab929
Filter out external DEBUG log records: reduces log size by 100MB/s
Baharis Feb 19, 2025
1c5febb
In `THANKS.md`, fix the link so that it points into instamatic contri…
Baharis Feb 20, 2025
a6aa242
In `about` frame, fix links and make other text copy-able
Baharis Feb 20, 2025
63afa09
Auto-adapt `Copyable` width to prevent main widget stretching
Baharis Feb 20, 2025
31eca62
Apply ruff suggestions to `main.py`
Baharis Feb 20, 2025
0d10902
@hzanoli: in `get_movie`, exposure and trigger periods both have to m…
Baharis Feb 20, 2025
dcea143
With new `block`-based mechanism, these lines are no longer needed
Baharis Feb 20, 2025
5d96d30
Make tem_server serializer import absolute to allow running as script
Baharis Feb 21, 2025
d0bc19b
Improve the comments in `serval.yaml` config file
Baharis Feb 26, 2025
3a811bf
Handle 0 <= exposure < 10, fix `get_movie` (TODO test)
Baharis Feb 26, 2025
089f905
Prevent tk from trying to write into a closed `console_frame:Writer`
Baharis Feb 27, 2025
d9db13e
Synchronize `_get_image_single`, `get_movie` to prevent threading rac…
Baharis Feb 27, 2025
0e784ba
Improve typing, readability on `camera_serval`, follow ruff format
Baharis Feb 28, 2025
d3a67fe
Log instamatic debug if -v, other debug if -vv, paths if -vvv
Baharis Mar 4, 2025
2df07b5
Fix typo, consistenly use `option.verbose`
Baharis Mar 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion THANKS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Code contributors
-----------------

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

Special thanks
--------------
Expand Down
100 changes: 69 additions & 31 deletions src/instamatic/camera/camera_serval.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

import atexit
import logging
from pathlib import Path
from typing import Tuple
import math
import threading
from functools import wraps
from typing import Any, Callable, Tuple, TypeVar

import numpy as np
from serval_toolkit.camera import Camera as ServalCamera

from instamatic import config
from instamatic.camera.camera_base import CameraBase

logger = logging.getLogger(__name__)
Expand All @@ -19,20 +20,40 @@
# 3. launch `instamatic`


Decorated = TypeVar('Decorated', bound=Callable[..., Any])


def synchronized(lock: threading.Lock) -> Callable:
"""Decorator: only one function decorated with given lock can run at time"""

def decorator(func: Decorated) -> Decorated:
@wraps(func)
def wrapper(*args, **kwargs) -> Any:
with lock:
return func(*args, **kwargs)

return wrapper

return decorator


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

lock = threading.Lock()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting the lock at the class level initiates it when the code is loaded at runtime. Move this to __init__().

Actually, I don't understand why this lock is needed at all. The camera does not know if it is running in a thread, so it should not be the one locking. I think the code can be simpler if the lock is moved to get_image, if absolutely necessary.

streamable = True
MIN_EXPOSURE = 0.000001
MAX_EXPOSURE = 10.0
BAD_EXPOSURE_MSG = 'Requested exposure exceeds native Serval support (>0–10s)'

def __init__(self, name='serval'):
"""Initialize camera module."""
super().__init__(name)

self.establish_connection()

msg = f'Camera {self.get_name()} initialized'
logger.info(msg)

self.dead_time = (
self.detector_config['TriggerPeriod'] - self.detector_config['ExposureTime']
)
logger.info(f'Camera {self.get_name()} initialized')
atexit.register(self.release_connection)

def get_image(self, exposure=None, binsize=None, **kwargs) -> np.ndarray:
Expand All @@ -46,12 +67,32 @@ def get_image(self, exposure=None, binsize=None, **kwargs) -> np.ndarray:
"""
if exposure is None:
exposure = self.default_exposure
if not binsize:
binsize = self.default_binsize

if exposure < self.MIN_EXPOSURE:
logger.warning(f'{self.BAD_EXPOSURE_MSG}: {exposure}')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider deferring the formatting until necessary: https://docs.python.org/3/howto/logging.html#optimization

Suggested change
logger.warning(f'{self.BAD_EXPOSURE_MSG}: {exposure}')
logger.warning('%s: %d', self.BAD_EXPOSURE_MSG, exposure)

return self._get_image_null()
elif self.MIN_EXPOSURE <= exposure <= self.MAX_EXPOSURE:
return self._get_image_single(exposure, binsize, **kwargs)
else: # if exposure > self.MAX_EXPOSURE
Comment on lines +73 to +75
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imo, logically _get_image_single should be the default. This also simplifies the logic a bit.

Suggested change
elif self.MIN_EXPOSURE <= exposure <= self.MAX_EXPOSURE:
return self._get_image_single(exposure, binsize, **kwargs)
else: # if exposure > self.MAX_EXPOSURE
elif exposure > self.MAX_EXPOSURE:
...
else:
return self._get_image_single(exposure, binsize, **kwargs)

logger.warning(f'{self.BAD_EXPOSURE_MSG}: {exposure}')
n_triggers = math.ceil(exposure / self.MAX_EXPOSURE)
exposure1 = (exposure + self.dead_time) / n_triggers - self.dead_time
arrays = self.get_movie(n_triggers, exposure1, binsize, **kwargs)
array_sum = sum(arrays, np.zeros_like(arrays[0]))
scaling_factor = exposure / exposure1 * n_triggers # account for dead time
return (array_sum * scaling_factor).astype(array_sum.dtype)
Comment on lines +76 to +82
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider moving this bit to its own function to be in line with the other 2 options:

Suggested change
logger.warning(f'{self.BAD_EXPOSURE_MSG}: {exposure}')
n_triggers = math.ceil(exposure / self.MAX_EXPOSURE)
exposure1 = (exposure + self.dead_time) / n_triggers - self.dead_time
arrays = self.get_movie(n_triggers, exposure1, binsize, **kwargs)
array_sum = sum(arrays, np.zeros_like(arrays[0]))
scaling_factor = exposure / exposure1 * n_triggers # account for dead time
return (array_sum * scaling_factor).astype(array_sum.dtype)
logger.warning(f'{self.BAD_EXPOSURE_MSG}: {exposure}')
return _stacked_image(exposure, binsize, **kwargs)


@synchronized(lock)
def _get_image_null(self, exposure=None, binsize=None, **kwargs) -> np.ndarray:
logger.debug('Creating a synthetic image with zero counts')
return np.zeros(shape=self.get_image_dimensions(), dtype=np.int32)

@synchronized(lock)
def _get_image_single(self, exposure=None, binsize=None, **kwargs) -> np.ndarray:
logger.debug(f'Collecting a single image with exposure {exposure} s')
# Upload exposure settings (Note: will do nothing if no change in settings)
self.conn.set_detector_config(
ExposureTime=exposure, TriggerPeriod=exposure + 0.00050001
ExposureTime=exposure,
TriggerPeriod=exposure + self.dead_time,
)

# Check if measurement is running. If not: start
Expand All @@ -67,6 +108,7 @@ def get_image(self, exposure=None, binsize=None, **kwargs) -> np.ndarray:
arr = np.array(img)
return arr

@synchronized(lock)
def get_movie(self, n_frames, exposure=None, binsize=None, **kwargs):
"""Movie acquisition routine. If the exposure and binsize are not
given, the default values are read from the config file.
Expand All @@ -80,18 +122,21 @@ def get_movie(self, n_frames, exposure=None, binsize=None, **kwargs):
"""
if exposure is None:
exposure = self.default_exposure
if not binsize:
binsize = self.default_binsize

self.conn.set_detector_config(TriggerMode='CONTINUOUS')

arr = self.conn.get_images(
nTriggers=n_frames,
logger.debug(f'Collecting {n_frames} images with exposure {exposure} s')
mode = 'AUTOTRIGSTART_TIMERSTOP' if self.dead_time else 'CONTINUOUS'
self.conn.measurement_stop()
previous_config = self.conn.detector_config
self.conn.set_detector_config(
TriggerMode=mode,
ExposureTime=exposure,
TriggerPeriod=exposure,
TriggerPeriod=exposure + self.dead_time,
nTriggers=n_frames,
)

return arr
self.conn.measurement_start()
images = self.conn.get_image_stream(nTriggers=n_frames, disable_tqdm=True)
self.conn.measurement_stop()
self.conn.set_detector_config(**previous_config)
return images

def get_image_dimensions(self) -> Tuple[int, int]:
"""Get the binned dimensions reported by the camera."""
Expand All @@ -111,21 +156,14 @@ def establish_connection(self) -> None:
bpc_file_path=self.bpc_file_path, dacs_file_path=self.dacs_file_path
)
self.conn.set_detector_config(**self.detector_config)
# Check pixel depth. If 24 bit mode is used, the pgm format does not work
# (has support up to 16 bits) so use tiff in that case. In other cases (1, 6, 12 bits)
# use pgm since it is more efficient
self.pixel_depth = self.conn.detector_config['PixelDepth']
if self.pixel_depth == 24:
file_format = 'tiff'
else:
file_format = 'pgm'

self.conn.destination = {
'Image': [
{
# Where to place the preview files (HTTP end-point: GET localhost:8080/measurement/image)
'Base': 'http://localhost',
# What (image) format to provide the files in.
'Format': file_format,
'Format': 'tiff',
# What data to build a frame from
'Mode': 'count',
}
Expand Down
7 changes: 2 additions & 5 deletions src/instamatic/camera/videostream.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,7 @@ def setup_grabber(self) -> ImageGrabber:
return grabber

def get_image(self, exposure=None, binsize=None):
current_frametime = self.grabber.frametime

# set to 0 to prevent it lagging data acquisition
self.grabber.frametime = 0
self.block() # Stop the passive collection during single-frame acquisition
if exposure:
self.grabber.exposure = exposure
if binsize:
Expand All @@ -144,7 +141,7 @@ def get_image(self, exposure=None, binsize=None):
self.grabber.lock.release()

self.grabber.acquireCompleteEvent.clear()
self.grabber.frametime = current_frametime
self.unblock() # Resume the passive collection
return frame

def update_frametime(self, frametime):
Expand Down
24 changes: 21 additions & 3 deletions src/instamatic/config/camera/serval.yaml
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
camera_rotation_vs_stage_xy: 0.0
default_binsize: 1
default_exposure: 0.02

# Double-check that your dimensions match the ones from your detector.
dimensions: [512, 512]

dynamic_range: 11800
interface: serval

physical_pixelsize: 0.055
possible_binsizes: [1]

stretch_amplitude: 0.0
stretch_azimuth: 0.0

# This configuration can be tuned based on your needs.
# Please refer to the Serval manual.
# For Serval 3.3.x, it is in section 4.5.7 - Detector Config JSON structure.
detector_config:
BiasVoltage: 100
BiasEnabled: True
TriggerMode: SOFTWARESTART_TIMERSTOP
TriggerMode: SOFTWARESTART_TIMERSTOP # Currently only this mode is supported
ExposureTime: 1.0
TriggerPeriod: 1.001
TriggerPeriod: 1.002
nTriggers: 1000000000
GainMode: HGM
# TriggerPeriod and ExposureTime are used to derive the dead/cooldown
# time of the detector. Consult Serval user manual to determine
# the optimal cooldown time for requested TriggerMode on your detector.
# Remove the following lines if you are using CheeTah T3 variants (timepix3),
# as they do not support these settings.
GainMode: HGM # Only for Medipix3
PixelDepth: 24 # Set to 12 for 12-bit (normal) mode - only Medipix3
BothCounters: False # Set to True for 12-bit mode - only Medipix3

# Change this to the location of your actual factory settings
bpc_file_path: '/home/asi/Desktop/Factory_settings/SPM-HGM/config.bpc'
dacs_file_path: '/home/asi/Desktop/Factory_settings/SPM-HGM/config.dacs'
url: 'http://localhost:8080'
3 changes: 2 additions & 1 deletion src/instamatic/experiments/cred/experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ def __init__(
self.unblank_beam = unblank_beam
self.logger = log
self.mode = mode
if ctrl.cam.name == 'simulate':
# we need to be able to simulate using the serval camera as well - add "simulate" to part of the name.
if 'simulate' in ctrl.cam.name:
self.mode = 'simulate'
self.stopEvent = stop_event
self.flatfield = flatfield
Expand Down
45 changes: 27 additions & 18 deletions src/instamatic/gui/about_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from tkinter import Label as tkLabel
from tkinter.font import Font, nametofont
from tkinter.ttk import *
from typing import Callable, Optional

import instamatic

Expand All @@ -29,6 +30,16 @@ def get_background_of_widget(widget):
return background


class Copyable(Text):
def __init__(self, master=None, text: str = '', **kwargs) -> None:
kwargs['bg'] = kwargs.get('bg', get_background_of_widget(master))
kwargs['relief'] = kwargs.get('relief', 'flat')
kwargs['width'] = kwargs.get('width', len(text))
super().__init__(master, **kwargs)
self.insert(1.0, text)
self.configure(state='disabled', font=nametofont('TkDefaultFont'))


class Link_Button(tkLabel):
"""http://code.activestate.com/recipes/580774-tkinter-link-or-hyperlink-
button/"""
Expand Down Expand Up @@ -110,45 +121,43 @@ def __init__(self, parent):

Label(frame, text='').grid(row=0, column=0, sticky='W')
Label(frame, text='Contact:').grid(row=1, column=0, sticky='W', padx=10)
Label(frame, text=f'{instamatic.__author__} ({instamatic.__author_email__}').grid(
row=1, column=1, sticky='W'
)
auth = f'{instamatic.__author__}, {instamatic.__author_email__}'
Copyable(frame, text=auth, height=1).grid(row=1, column=1, sticky='W')
Label(frame, text='').grid(row=5, column=0, sticky='W')

Label(frame, text='Source code:').grid(row=10, column=0, sticky='W', padx=10)
link = Link_Button(frame, text=instamatic.__url__, action=self.link_github)
action = self.link_action_factory(url=instamatic.__url__)
link = Link_Button(frame, text=instamatic.__url__, action=action)
link.grid(row=10, column=1, sticky='W')
Label(frame, text='').grid(row=12, column=0, sticky='W')

Label(frame, text='Docs:').grid(row=20, column=0, sticky='W', padx=10)
link = Link_Button(frame, text=instamatic.__docs__, action=self.link_github)
action = self.link_action_factory(url=instamatic.__docs__)
link = Link_Button(frame, text=instamatic.__docs__, action=action)
link.grid(row=20, column=1, sticky='W')
Label(frame, text='').grid(row=22, column=0, sticky='W')

Label(frame, text='Bugs:').grid(row=30, column=0, sticky='W', padx=10)
link = Link_Button(frame, text=instamatic.__issues__, action=self.link_github)
action = self.link_action_factory(url=instamatic.__issues__)
link = Link_Button(frame, text=instamatic.__issues__, action=action)
link.grid(row=30, column=1, sticky='W')
Label(frame, text='').grid(row=32, column=0, sticky='W')

Label(frame, text='If you found this software useful, please cite:').grid(
row=40, column=0, sticky='W', columnspan=2, padx=10
)
txt = Message(frame, text=instamatic.__citation__, width=320, justify=LEFT)
txt.grid(row=41, column=1, sticky='W')

Label(frame, text='').grid(row=41, column=0, sticky='W', padx=10)
txt = Copyable(frame, text=instamatic.__citation__, height=1)
txt.grid(row=41, column=0, columnspan=2, sticky='W', padx=10)

frame.pack(side='top', fill='x')

def link_github(self, event=None):
import webbrowser

webbrowser.open_new(instamatic.__url__)
def link_action_factory(self, url: str) -> Callable:
def link_action(event=None) -> None:
import webbrowser

def link_manual(self, event=None):
import webbrowser
webbrowser.open_new(url)

webbrowser.open_new(instamatic.__url__)
return link_action


module = BaseModule(name='about', display_name='about', tk_frame=AboutFrame, location='bottom')
Expand All @@ -157,5 +166,5 @@ def link_manual(self, event=None):

if __name__ == '__main__':
root = Tk()
About(root).pack(side='top', fill='both', expand=True)
AboutFrame(root).pack(side='top', fill='both', expand=True)
root.mainloop()
3 changes: 3 additions & 0 deletions src/instamatic/gui/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ def __init__(self, root, cam, modules: list = []):
self.root.bind('<Escape>', self.close)

def close(self):
# Prevent tk from trying to write into a closed `console_frame:Writer`
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
try:
self.stream_frame.close()
except AttributeError:
Expand Down
Loading