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 16 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
38 changes: 19 additions & 19 deletions src/instamatic/camera/camera_serval.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@

import atexit
import logging
from pathlib import Path
from typing import Tuple

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 @@ -27,14 +25,22 @@ class CameraServal(CameraBase):
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.exposure_cooldown = (
self.detector_config['TriggerPeriod'] - self.detector_config['ExposureTime']
)
logger.info(f'Camera {self.get_name()} initialized')
atexit.register(self.release_connection)

def _validate_exposure(self, exposure: float) -> float:
if exposure < 0.001:
logger.warning(f'Exposure {exposure} too low. Adjusting to 0.001s')
exposure = 0.001
elif exposure > 10:
logger.warning(f'Exposure {exposure} too high. Adjusting to 10s')
exposure = 10
return exposure

def get_image(self, exposure=None, binsize=None, **kwargs) -> np.ndarray:
"""Image acquisition routine. If the exposure and binsize are not
given, the default values are read from the config file.
Expand All @@ -46,12 +52,12 @@ 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
exposure = self._validate_exposure(exposure)
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if this call is not going to be too spammy. I'd say it's also fair to raise a ValueError here.

Suggested change
exposure = self._validate_exposure(exposure)
if not (0.001 < exposure < 10):
raise ValueError('Exposure must be between 0.001 and 10 s')

Copy link
Contributor Author

Choose a reason for hiding this comment

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

While this would be a more elegant approach, unfortunately this error is something I am specifically trying to avoid. Serval toolkit already does raise a RuntimeError and specifically describes in the error message that exposure must be in this range; However, the moment any error is raised here (and this can be done completely by accident e.g. when changing exposure in preview), the GUI dies. I discovered the 0-second exposure because trying to get any images other than preview caused this. If you don't like this approach, I can try tomorrow to make a precise try-except statement here instead.

Copy link
Member

Choose a reason for hiding this comment

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

I'd say let Serval handle the data validation, but then the error must be caught at a higher level. No need to do data validation twice. You want to avoid the function invisibly updating the exposure, that's why it is better to raise.

If it's related to the gui, perhaps the gui call to this function can be behind a try/except. I think it is mainly the lower limit that causes issues in practice. Perhaps the number input in the gui could have a lower limit (exposure must be > 0). I think this makes sense in general.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@stefsmeets The upper limit is also causing exceptions, and in places other than GUI. The other day I was testing flatfield algorithm and it requested 370-second exposure. I came to conclusion that instead of piling up try/except statements, I could actually implement exposures of = 0 seconds or > 10 seconds by summing images from get_movie. This will be accompanied with appropriate warning but IMO will be more general than arbitrary cut-off. I will convert this PR to draft while I am working on it.


# 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.exposure_cooldown,
)

# Check if measurement is running. If not: start
Expand Down Expand Up @@ -82,6 +88,7 @@ def get_movie(self, n_frames, exposure=None, binsize=None, **kwargs):
exposure = self.default_exposure
if not binsize:
binsize = self.default_binsize
exposure = self._validate_exposure(exposure)
Copy link
Member

Choose a reason for hiding this comment

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

See comment above about raising a ValueError instead.

Suggested change
exposure = self._validate_exposure(exposure)
if not (0.001 < exposure < 10):
raise ValueError('Exposure must be between 0.001 and 10 s')


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

Expand Down Expand Up @@ -111,21 +118,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
19 changes: 17 additions & 2 deletions src/instamatic/config/camera/serval.yaml
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
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
ExposureTime: 1.0
TriggerPeriod: 1.001
TriggerPeriod: 1.002 # Try 1.0005 for Medipix3
Copy link

Choose a reason for hiding this comment

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

I think it is a nice idea to derive the cooldown/deadtime to avoid having an asic field. But since that might not be very transparent to the user, I think that it deserves a small comment here, something like:

# TriggerPeriod and ExposureTime are used to derive the deadtime/cooldown of the detector.
# Please verify in the manual since different TriggerMode and other detector configurations
# require different deadtimes. 

Copy link
Contributor Author

@Baharis Baharis Feb 25, 2025

Choose a reason for hiding this comment

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

Currently SOFTWARESTART_TIMERSTOP is the only trigger mode that I managed to get working with Instamatic. I briefly tested AUTOTRIGSTART_TIMERSTOP which massively improves the fps because it does not wait for a /measurement/start, but it is not handled by Instamatic yet (sent data can not be opened by Pillow/tifffile) so I will need to take a closer look at it in the future. For the purpose of collecting "movies" (hypothetical/untested), Instamatic sets trigger mode to CONTINUOUS, but also disregards "cooldown" and uses the same value for ExposureTime and TriggerPeriod so it will comply with Serval requirements.

I definitely like the idea of placing additional comment in the yaml file so that a user can make a conscious choice about setting ExposureTime and TriggerPeriod. I will base and possibly expand it on your suggestion. Thank you!

nTriggers: 1000000000
GainMode: HGM
# Disregard 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()
28 changes: 23 additions & 5 deletions src/instamatic/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ def main():
help='Show info about the current instamatic installation.',
)

parser.add_argument(
'-v',
'--verbose',
action='store_true',
dest='verbose',
help="Show imported packages' DEBUG records in log",
)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

One more thing that I considered suggesting was disabling by default instamatic debug statement, allowing instamatic debug statement if -v, and allowing all statement if -vv. To the best of my understanding, debug statement are not something that a log should be collecting in normal conditions. I do not want to cause a revolution though.

Copy link
Member

Choose a reason for hiding this comment

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

There is some performance penalty to logging. This is why I tried to limit logging as much as possible in the performance critical loops (data collection), to avoid unnecessary locks or interruptions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, I think your opinion aligns with my suggestion. If Instamatic debug statements are logged only if -v is specified (and external debug statements only if -vv) then it becomes fine to put debug statements even in "critical loops". Which in my opinion is the correct way to go, because with this change the "critical loops" remain fast if instamatic is run as normal, but can be easily investigated if -v is specified.

For reference, according to simple search instamatic currently does not have particularly much logging, but there seems to be a healthy split between debug that I suggest to omit by default and info that I suggest to always show:

  • 74 debug statements (including some in Merlin/Serval data collection loop, auto-cred, serialED, img conversion),
  • 100 info statements (same files, plus more in microscope interfaces)
  • 22 warn(ing) statements (joel microscope, camera merlin, comera serval, mrc format, simulation...)
  • 2 error statements.

I will have that implemented today and ask for a re-review afterwards.


parser.set_defaults(
script=None,
acquire_at_items=False,
Expand Down Expand Up @@ -155,11 +163,21 @@ def main():
date = datetime.datetime.now().strftime('%Y-%m-%d')
logfile = config.locations['logs'] / f'instamatic_{date}.log'

logging.basicConfig(
format='%(asctime)s | %(module)s:%(lineno)s | %(levelname)s | %(message)s',
filename=logfile,
level=logging.DEBUG,
)
def filter_out_imported_package_debug_records(r: logging.LogRecord) -> bool:
return (
r.name.startswith('instamatic')
or r.name == '__main__'
or r.levelno > logging.DEBUG
or options.verbose
)

log_main = logging.getLogger()
log_main.setLevel(logging.DEBUG)
log_format = '%(asctime)s | %(module)s:%(lineno)s | %(levelname)s | %(message)s'
log_handler = logging.FileHandler(logfile)
log_handler.setFormatter(logging.Formatter(log_format))
log_handler.addFilter(filter_out_imported_package_debug_records)
log_main.addHandler(log_handler)

logging.captureWarnings(True)
log = logging.getLogger(__name__)
Expand Down
5 changes: 1 addition & 4 deletions src/instamatic/server/tem_server.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
from __future__ import annotations

import datetime
import json
import logging
import pickle
import queue
import socket
import threading
import traceback

from instamatic import config
from instamatic.microscope import get_microscope

from .serializer import dumper, loader
from instamatic.server.serializer import dumper, loader

condition = threading.Condition()
box = []
Expand Down