Skip to content

Commit 69945bc

Browse files
Baharisstefsmeets
andauthored
Video stream modifications via a new VideoStreamProcessor (#135)
* Add `ClickDispatcher` architecture and setup one in `VideoStreamFrame` * Add `VideoStreamService` architecture and setup one in `VideoStreamFrame` * Rename `VideoStreamEnhancers` to `Overlays`, make `ImageDrawOverlay` editable * `ClickListener`: make `callback` optional and allow activating via `with` context * `MediaGrabber`: fix bug: `get_media` cleared `continuousCollectionEvent` * `calibrate_beamshift.py`: make imports absolute, allow float beamshift * Instamatic GUI: temporarily print coords of clicked point, revert later * Move `instamatic.typing` to `typing_` to avoid import conflicts * `stage`: Add `get_rotation_speed`, improve typing * Add `instamatic.calibrate_stage_rotation` for greater rotation control * Add `instamatic.calibrate_stage_rotation` for greater rotation control 2 * Add first ideas for RATS-like generalized data collection frame * `ImageDrawOverlay`: Don't print `self.operations` on every redraw * GUI: FIX couldn't initialize with non-steamable camera * `VideoStream`: Generalize real & fake streams under common base class * GUI: Allow VideoStreamFrame for non-streamable cameras via the fake * Allow customizing `NoOverwriteDict`'s `KeyOverwriteError` message * Add `ClickDispatcher` architecture and setup one in `VideoStreamFrame` * `ClickListener`: make `callback` optional and allow activating via `with` context * Add `typing_extensions` as a dependency * Move potential `typing_extensions` types to `instamatic._typing` * Feature usefulness of new `instamatic._typing.Self` * Move potential `typing_extensions` types to `instamatic._typing` * Feature usefulness of new `instamatic._typing.Self` * Exclude changes irrelevant to the `click_service` branch * Add a custom `ClickEvent.__repr__` * Add a custom `ClickEvent.__repr__` * Move `instamatic.collections` to `instamatic._collections` * Move `instamatic.collections` to `instamatic._collections` * `ClickListener`: call `callback` only after putting event in `self.queue` * `ClickListener`: call `callback` only after putting event in `self.queue` * `_VS`: rename type variable to a more conventional `VideoStream_T` * `_VS`: rename type variable to a more conventional `VideoStream_T` * `typing_extensions`: uses typing if available so import directly, not via `instamatic._typing` * `typing_extensions`: uses typing if available so import directly, not via `instamatic._typing` * Do not pass numpy objects to the microscope. * `CameraSimu.get_movie`: convert into `Generator` * `VideoStream.get_movie`: convert into `Generator` * `VideoStream.get_movie`: Optimize thread release, remove unused event = best fps yet * `VideoStream.get_movie`: Optimize thread release, remove unused event = best fps yet 2 * `TEMController.get_movie`: convert into `Generator` * `ctrl.get_image`: fix `header_keys` could hold only 1 key as `str` not `tuple` * `CalibMovieRate`: first draft of the movie delay calibration * `TEMController.get_movie`: collect common metadata before the main loop for time precision * `TEMController`: move ugly explicit `self.cam` check to a method decorator * `CalibMovieDelays`: simplify and add multi-attempt mechanism * `CalibMovieDelaysMapping`: add a common class for reading/writing mappings * `CalibMovieDelaysMapping`: fix I/O, script, bugs, add custom `CalibError` * FEI Tecnai upper speed limit seem to be around 0.2 * `CameraServal.get_movie`: rewrite as generator, streamline by PIL->tifffile * `CameraBase.get_movie`: rewrite as generator * `CameraMerlin.get_movie`: rewrite as generator * `showcase_movie.py`: Add a temp script to feature get_movie streaming feature * `CameraServal.get_movie`: Actually replace previous implementation this time * `CalibMovieDelays`: vastly simplify, do for specific exposure only * `CalibMovieDelays`: fix documentation, warning criteria * Register `instamatic.calibrate.calibrate_movie_delays` as script * Fix bugs, typos * Adapt `test_get_movie` to the new structure * Add Daniel Tchoń to `CITATION.cff` * Implement simple cRED protocol in rats frame using `get_movie` * Fix `write_tiff` raising when writing to non-existing directory * Fix `DiffractionRun.middles` off-by-one error * Wrap generators in `try/finally` to correctly close them when not exhausted * RATS cRED: find and rotate with closest possible rotation speed * Reorganize rats frame, lock unusable buttons * `RatsFrame`: clean code formatting * `RatsFrame`: make `Run.experiments` a property * `RatsFrame`: can now extend/collect experiments in parts * These things don't work on Tecnai (fix better) * WHen using center, convert numpy types to python * Disable plotting until its fixed. * Adapt RATS experiment to updated movie calibration * Add typing, align `stage.rotating_speed` to `stage.rotation_speed` to match already-used wording * Fix beamshift deflector so that it passes `int` or `float` instead of `np.int` * Fix wrong definitions of goniometer speed vs pace * Fix mismatched axes order for the purpose of crystal tracking * Generalize image, tracking, diffraction config re/store * Correctly change config during RATS experiment * Add docstrings, streamline blanking * Add `with Beam().blanked()` and `with Beam().unblanked()` * Apply the new beam `un/blanked` context in various places * Various RATS frame fixes needed to run cRED on Tecnai * New console print of carriage return, with debug statements * Remove ANSI ESCAPE characters after all; this version handles tqdm * Remove debug statements * Improve RATS experiment: go to 0 at start, end, also collect image * Add beam blank button, block can when collecting stills, * Systematize `setStagePosition` signature, make `None` a sentinel for speed * Fix TEMController alignments being incorrectly stored and restored * Add generalized handling for PETS input using a dedicated factory * Since we need to support python 3.7 and 3.8, move csv file inside py * Produce just a single PETS file in RATS experiment * Add generalized handling for PETS input using a dedicated factory * Since we need to support python 3.7 and 3.8, move csv file inside py * Fix name of produced pets file to `pets.pts` * Fix name of produced pets file to `pets.pts` * Fix creating `pets.pts` if prefix or suffix are not defined * Fix creating `pets.pts` if prefix or suffix are not defined * Split PETS affix to blocks pre-adding: rejects only duplicate lines, not all affix * Split PETS affix to blocks pre-adding: rejects only duplicate lines, not all affix * Add doc, tests, move csv to resources, bump min Python version to 3.9 * GitHub tests fail for Python 3.13, may be not supported by some libraries yet * Simplify, accelerate the video provider, now `VideoServiceProcessor` * Further simplify, make faster, allow `VideoStreamProcessor` to display figures * Remove `[tool.setuptools.package-data]` from `pyproject.toml` Co-authored-by: Stef Smeets <stefsmeets@users.noreply.github.com> * Fix typo in src/instamatic/processing/ImgConversion.py Co-authored-by: Stef Smeets <stefsmeets@users.noreply.github.com> * Move `PetsInputWarning` to the top of the `PETS_input_factory.py` file * Make `PETS_KEYWORDS` uppercase, fix `PetsKeywords.from_file` type hint * Bump the suggested conda-forge Python version to 3.12 * Improve docstrings in `DeferredImageDraw` & `VideoStreamProcessor` * `on_frame`: scale 2-fold, clear a bunch of empty lines * `ExperimentalRats`: fix beam un/blanker * `ExperimentalRats`: export tracking to its own function * Merge `fix_pets_input` into `video_services` * Revert code unrelated to strictly stream processor updates * Improve `stream.processor.temporary_*`, docstring, add example code * Fix bug: clicking on VideoStreamFrame.panel.image was 2px off * Remove debug timing instructions * Fix clicked point was incorrect if window scrolled or partially visible * Rename `save_image` -> `save_frame`, new `save_image` job saves edited as PNG, remove unused imports * Remove discontinued showcase script * Fix incorrect yield placement in `processor.temporary` context * Make `DeferredImageDraw.Instruction` mutable dataclass (& .5% slower) * Make `DeferredImageDraw.Instruction` mutable dataclass (& .2% slower) * Forcing keyword args here for safety. Co-authored-by: Stef Smeets <stefsmeets@users.noreply.github.com> * Rewrite `VideoStreamProcessor.frame` return into a bit neater one-liner * Fix unsightly docstring, remove unused import * Improve type hints, docstrings * Revert "Forcing keyword args here for safety." This reverts commit 08806ff. * Remove code left "ONLY FOR DEVELOPMENT SHOWCASE PURPOSES" --------- Co-authored-by: Stef Smeets <stefsmeets@users.noreply.github.com>
1 parent e714b68 commit 69945bc

File tree

4 files changed

+241
-45
lines changed

4 files changed

+241
-45
lines changed

src/instamatic/gui/jobs.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
import time
1010
from datetime import datetime
1111

12+
import PIL.Image
13+
1214
from instamatic.formats import read_tiff, write_tiff
15+
from instamatic.processing.flatfield import apply_flatfield_correction
1316

1417

1518
def microscope_control(controller, **kwargs):
@@ -31,17 +34,15 @@ def collect_flatfield(controller, **kwargs):
3134
flatfield.collect_flatfield(controller.ctrl, confirm=False, drc=drc, **kwargs)
3235

3336

34-
def save_image(controller, **kwargs):
37+
def save_frame(controller, **kwargs):
3538
frame = kwargs.get('frame')
3639

3740
module_io = controller.app.get_module('io')
3841

3942
drc = module_io.get_experiment_directory()
4043
drc.mkdir(exist_ok=True, parents=True)
4144

42-
timestamp = datetime.now().strftime('%H-%M-%S.%f')[
43-
:-3
44-
] # cut last 3 digits for ms resolution
45+
timestamp = datetime.now().strftime('%H-%M-%S.%f')[:-3] # cut last 3 digits for ms res.
4546
outfile = drc / f'frame_{timestamp}.tiff'
4647

4748
try:
@@ -55,6 +56,15 @@ def save_image(controller, **kwargs):
5556
print('Wrote file:', outfile)
5657

5758

59+
def save_image(controller, image: PIL.Image.Image, **_):
60+
drc = controller.app.get_module('io').get_experiment_directory()
61+
drc.mkdir(exist_ok=True, parents=True)
62+
timestamp = datetime.now().strftime('%H-%M-%S.%f')[:-3] # cut last 3 digits for ms res.
63+
out_path = drc / f'image_{timestamp}.png'
64+
image.save(out_path, format='PNG')
65+
print('Wrote file:', out_path)
66+
67+
5868
def toggle_difffocus(controller, **kwargs):
5969
toggle = kwargs['toggle']
6070

@@ -85,6 +95,7 @@ def relax_beam(controller, **kwargs):
8595
JOBS = {
8696
'ctrl': microscope_control,
8797
'flatfield': collect_flatfield,
98+
'save_frame': save_frame,
8899
'save_image': save_image,
89100
'toggle_difffocus': toggle_difffocus,
90101
'relax_beam': relax_beam,

src/instamatic/gui/videostream_frame.py

Lines changed: 38 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@
22

33
import threading
44
import time
5-
from datetime import datetime
65
from tkinter import *
6+
from tkinter import Label as TkLabel
77
from tkinter.ttk import *
8+
from typing import Union
89

910
import numpy as np
10-
from PIL import Image, ImageEnhance, ImageTk
11+
from PIL import Image, ImageTk
12+
from PIL.Image import Resampling
1113

12-
from instamatic.formats import read_tiff, write_tiff
1314
from instamatic.gui.base_module import BaseModule
1415
from instamatic.gui.click_dispatcher import ClickDispatcher
15-
from instamatic.processing.flatfield import apply_flatfield_correction
16+
from instamatic.gui.videostream_processor import VideoStreamProcessor
1617
from instamatic.utils.spinbox import Spinbox
1718

1819

@@ -69,6 +70,7 @@ def __init__(self, parent, stream, app=None):
6970

7071
self.click_dispatcher = ClickDispatcher()
7172
self.panel.bind('<Button>', self.on_click)
73+
self.processor = VideoStreamProcessor(self)
7274

7375
def init_vars(self):
7476
self.var_fps = DoubleVar()
@@ -92,8 +94,17 @@ def init_vars(self):
9294
self.var_auto_contrast.trace_add('write', self.update_auto_contrast)
9395

9496
def buttonbox(self, master):
95-
btn = Button(master, text='Save image', command=self.saveImage)
96-
btn.pack(side='bottom', fill='both', padx=10, pady=10)
97+
btn_frame = Frame(master)
98+
btn_frame.pack(side='bottom', fill=BOTH, padx=10, pady=10)
99+
btn1 = Button(btn_frame, text='Save frame', command=self.save_frame)
100+
btn1.pack(side=LEFT, expand=True, fill='both')
101+
btn2 = Button(btn_frame, text='Save image', command=self.save_image)
102+
btn2.pack(side=LEFT, expand=True, fill='both')
103+
104+
@property
105+
def frame(self) -> Union[np.ndarray, None]:
106+
"""Raw image frame from the camera."""
107+
return self.processor.frame
97108

98109
def header(self, master):
99110
ewidth = 8
@@ -169,14 +180,10 @@ def makepanel(self, master, resolution=(512, 512)):
169180
if self.panel is None:
170181
image = Image.fromarray(np.zeros(resolution))
171182
image = ImageTk.PhotoImage(image)
172-
173-
self.panel = Label(master, image=image)
183+
self.panel = TkLabel(master, image=image, borderwidth=0)
174184
self.panel.image = image
175185
self.panel.pack(side='left', padx=10, pady=10)
176186

177-
def setup_stream(self):
178-
pass
179-
180187
def update_resize_image(self, name, index, mode):
181188
# print name, index, mode
182189
try:
@@ -214,9 +221,14 @@ def update_display_range(self, name, index, mode):
214221
except BaseException:
215222
pass
216223

217-
def saveImage(self):
218-
"""Dump the current frame to a file."""
219-
self.q.put(('save_image', {'frame': self.frame}))
224+
def save_frame(self):
225+
"""Save currently shown raw frame from the stream to a file in cwd."""
226+
self.q.put(('save_frame', {'frame': self.frame}))
227+
self.triggerEvent.set()
228+
229+
def save_image(self):
230+
"""Save currently shown, modified, & scaled image to a file in cwd."""
231+
self.q.put(('save_image', {'image': self.processor.image}))
220232
self.triggerEvent.set()
221233

222234
def set_trigger(self, trigger=None, q=None):
@@ -234,33 +246,17 @@ def start_stream(self):
234246
self.after(500, self.on_frame)
235247

236248
def on_frame(self, event=None):
237-
self.stream.lock.acquire(True)
238-
self.frame = frame = self.stream.frame
239-
self.stream.lock.release()
240-
241-
if frame is not None:
242-
# the display range in ImageTk is from 0 to 255
243-
if self.display_range != 255.0 or self.brightness != 1.0:
244-
if self.auto_contrast:
245-
display_range = 1 + np.percentile(frame[::4, ::4], 99.5)
246-
else:
247-
display_range = self.display_range
248-
frame = (self.brightness * 255 / display_range) * frame
249-
frame = np.clip(frame.astype(np.int16), 0, 255).astype(np.uint8)
250-
image = Image.fromarray(frame)
251-
249+
"""Get the newest image from `processor`, adapt to GUI and display."""
250+
if self.frame is not None:
251+
image = self.processor.image
252252
if self.resize_image:
253-
image = image.resize((950, 950))
254-
253+
size = [2 * dim for dim in image.size]
254+
image = image.resize(size=size, resample=Resampling.NEAREST)
255255
image = ImageTk.PhotoImage(image=image)
256-
257256
self.panel.configure(image=image)
258257
# keep a reference to avoid premature garbage collection
259258
self.panel.image = image
260-
261259
self.update_frametimes()
262-
# self.parent.update_idletasks()
263-
264260
self.after(self.frame_delay, self.on_frame)
265261

266262
def update_frametimes(self):
@@ -290,13 +286,15 @@ def on_click(self, event: Event) -> None:
290286
if not self.click_dispatcher.active:
291287
return
292288

289+
# Correct for window offset due to scrolling or not fitting on screen
290+
p = self.panel
291+
offset_x = (p.winfo_width() - p.image.width()) // 2
292+
offset_y = (p.winfo_height() - p.image.height()) // 2
293+
293294
# Convert window coordinates to image coordinates
294-
panel_width: int = self.panel.winfo_width()
295-
panel_height: int = self.panel.winfo_height()
296295
array_shape = self.frame.shape
297-
x = round(event.x * array_shape[1] / panel_width)
298-
y = round(event.y * array_shape[0] / panel_height)
299-
296+
x = round((event.x - offset_x) * array_shape[1] / p.image.width())
297+
y = round((event.y - offset_y) * array_shape[0] / p.image.height())
300298
self.click_dispatcher.handle_click(x=x, y=y, button=event.num)
301299

302300

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
from __future__ import annotations
2+
3+
import io
4+
from collections import deque
5+
from contextlib import contextmanager
6+
from dataclasses import dataclass
7+
from functools import wraps
8+
from typing import Any, Iterator, Literal, Optional, Protocol, Union
9+
10+
import numpy as np
11+
import PIL.Image
12+
from matplotlib.figure import Figure
13+
from PIL import Image, ImageDraw
14+
15+
from instamatic.camera.videostream import VideoStream
16+
17+
18+
class VideoStreamFrameProtocol(Protocol):
19+
"""Mimics the `VideoStreamFrame` interface to avoid circular import."""
20+
21+
auto_contrast: bool = True
22+
brightness: float = 1.0
23+
display_range: int = 255
24+
stream: VideoStream
25+
26+
27+
class DeferredImageDraw:
28+
"""Defer `ImageDraw` method calls: put them in deque, draw using `on`."""
29+
30+
@dataclass
31+
class Instruction:
32+
"""Stores info about `ImageDraw` calls deferred by `__getattr__`."""
33+
34+
attr_name: str
35+
args: tuple[Any, ...]
36+
kwargs: dict[str, Any]
37+
38+
def __init__(self, draw: Optional[ImageDraw.ImageDraw] = None) -> None:
39+
self._drawing = draw if draw else ImageDraw.Draw(Image.new('RGB', (1, 1)))
40+
self.instructions: deque[DeferredImageDraw.Instruction] = deque()
41+
42+
def __getattr__(self, attr_name: str) -> Any:
43+
"""Get the first of `self.attr_name` and `self._drawing.attr_name`.
44+
45+
If the attribute is a method of the internal `ImageDraw` object,
46+
return its wrapped version that defers it by appending a corresponding
47+
`Instruction` to `self.instructions` to be run at render time instead.
48+
`DeferredImageDraw.Instruction` instance returned this way is mutable
49+
and can be deleted by calling `self.instructions.remove(instruction)`.
50+
Otherwise, return the attribute of `DeferredImageDraw` instance as-is.
51+
"""
52+
try:
53+
attr = object.__getattribute__(self, attr_name)
54+
except AttributeError as e:
55+
reraise_on_fail = e
56+
try:
57+
attr = getattr(self._drawing, attr_name)
58+
except AttributeError:
59+
raise reraise_on_fail
60+
61+
if callable(attr):
62+
63+
@wraps(attr)
64+
def wrapped(*args, **kwargs) -> DeferredImageDraw.Instruction:
65+
instruction = self.Instruction(attr_name, args, kwargs)
66+
self.instructions.append(instruction)
67+
return instruction
68+
69+
return wrapped # do not call attr - delay it until _redraw()
70+
return attr # non-callable attr of self (if exists) or self._drawing
71+
72+
def on(self, image: Image.Image) -> Image.Image:
73+
"""Core method: draws all deferred `self.instructions` on image."""
74+
self._drawing = ImageDraw.Draw(image)
75+
for ins in self.instructions:
76+
getattr(self._drawing, ins.attr_name)(*ins.args, **ins.kwargs)
77+
return image
78+
79+
def circle(
80+
self,
81+
xy: tuple[int, int],
82+
radius: float,
83+
fill: Optional[Union[str, tuple[int, int, int]]] = None,
84+
outline: Optional[Union[str, tuple[int, int, int]]] = None,
85+
width: int = 1,
86+
) -> DeferredImageDraw.Instruction:
87+
"""Draw a circle by wrapping a call to `ImageDraw.ellipse`.
88+
89+
Since `ImageDraw.circle` was added only in Pillow 10.4.0, this
90+
provides a backward-compatible way to draw circles using ellipses.
91+
"""
92+
ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius)
93+
return self.ellipse(ellipse_xy, fill=fill, outline=outline, width=width)
94+
95+
96+
class VideoStreamProcessor:
97+
"""Encapsulate complex `VideoStreamFrame` frame/image processing.
98+
99+
This class handles converting mathematical operations behind efficiently
100+
converting raw frames into images, rendering matplotlib figures as static
101+
images in the video stream window, as well as drawing on top of images.
102+
103+
Streamed view can be altered by setting a temporary frame/image/figure.
104+
Each of these can be set/reset via corresponding attribute, or set
105+
temporarily via `with processor.temporary(frame/image/figure=...)` syntax.
106+
107+
Drawing is handled via the `draw` attribute of the `DeferredImageDraw`
108+
class which acts as a deferred proxy for PIL.ImageDraw. Instructions,
109+
instead of being applied directly on one frame only, are saved into the
110+
`draw.instructions` deque and efficiently re-applied continuously.
111+
"""
112+
113+
def __init__(self, vsf: VideoStreamFrameProtocol) -> None:
114+
self.vsf: VideoStreamFrameProtocol = vsf
115+
self.draw: DeferredImageDraw = DeferredImageDraw()
116+
self.color_mode: Literal['L', 'RGB'] = 'RGB'
117+
self.temporary_frame: Optional[np.ndarray] = None
118+
self.temporary_image: Optional[Image.Image] = None
119+
self._temporary_figure: Optional[Figure] = None
120+
121+
@property
122+
def frame(self) -> Union[np.ndarray, None]:
123+
"""The raw `np.ndarray` frame from the stream or `_temporary_frame`"""
124+
return self.vsf.stream.frame if (t := self.temporary_frame) is None else t
125+
126+
@property
127+
def image(self) -> Union[Image.Image, None]:
128+
"""Processed image with `draw.instructions`, or `_temporary_image`."""
129+
if (temporary_image := self.temporary_image) is not None:
130+
return temporary_image
131+
if (frame := self.frame) is not None:
132+
if self.vsf.display_range != 255.0 or self.vsf.brightness != 1.0:
133+
if self.vsf.auto_contrast:
134+
display_range = 1 + np.percentile(frame[::4, ::4], 99.5)
135+
else:
136+
display_range = self.vsf.display_range
137+
frame = (self.vsf.brightness * 255 / display_range) * frame
138+
frame = np.clip(frame.astype(np.int16), 0, 255).astype(np.uint8)
139+
if self.draw.instructions:
140+
image = Image.fromarray(frame).convert(self.color_mode)
141+
self.draw.on(image)
142+
else:
143+
image = Image.fromarray(frame)
144+
return image
145+
146+
def render_figure(self, figure: Figure) -> Image.Image:
147+
"""Convert a `Figure` into an `Image` to allow rendering it in GUI."""
148+
buffer = io.BytesIO()
149+
dpi = min(self.vsf.stream.frame.shape / figure.get_size_inches())
150+
figure.savefig(buffer, format='png', dpi=dpi, bbox_inches='tight', pad_inches=0)
151+
buffer.seek(0)
152+
return Image.open(buffer).convert('RGBA')
153+
154+
@contextmanager
155+
def temporary(
156+
self,
157+
*,
158+
frame: Optional[np.ndarray] = None,
159+
image: Optional[Image.Image] = None,
160+
figure: Optional[Figure] = None,
161+
) -> Iterator[None]:
162+
"""Temporarily override the current frame/image/figure for rendering.
163+
164+
Use via context manager using a `with` statement with one of the args:
165+
with processor.temporary(frame=..., image=..., figure=...):
166+
...
167+
"""
168+
pre_context_values = self.temporary_frame, self.temporary_image
169+
try:
170+
if frame is not None:
171+
self.temporary_frame = frame
172+
if image is not None:
173+
self.temporary_image = image
174+
elif figure is not None:
175+
self.temporary_image = self.render_figure(figure)
176+
yield
177+
finally:
178+
self.temporary_frame, self.temporary_image = pre_context_values
179+
180+
@property
181+
def temporary_figure(self) -> Figure:
182+
return self._temporary_figure
183+
184+
@temporary_figure.setter
185+
def temporary_figure(self, figure: Union[Figure, None]) -> None:
186+
self._temporary_figure = figure
187+
self.temporary_image = self.render_figure(figure) if figure else None

src/instamatic/microscope/components/stage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def set_with_speed(
7676
speed=speed,
7777
)
7878

79-
def set_rotation_speed(self, speed: Union[float, int] = 1) -> None:
79+
def set_rotation_speed(self, speed: Number = 1) -> None:
8080
"""Sets the stage (rotation) movement speed on the TEM."""
8181
self._tem.setRotationSpeed(value=speed)
8282

0 commit comments

Comments
 (0)