Skip to content

Commit cef0161

Browse files
authored
FastADT: Add an option to re-use previous tracking (#159)
1 parent 703359d commit cef0161

2 files changed

Lines changed: 90 additions & 17 deletions

File tree

src/instamatic/experiments/fast_adt/experiment.py

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import logging
4+
import re
45
from collections import deque
56
from contextlib import contextmanager
67
from copy import deepcopy
@@ -15,6 +16,7 @@
1516
import numpy as np
1617
import pandas as pd
1718
from matplotlib import pyplot as plt
19+
from pandas.errors import EmptyDataError, ParserError
1820
from PIL.Image import Image
1921
from typing_extensions import Self
2022

@@ -97,6 +99,25 @@ def __str__(self) -> str:
9799
def __len__(self) -> int:
98100
return len(self.table)
99101

102+
@classmethod
103+
def from_csv(cls, path: AnyPath) -> Self:
104+
"""Read self, table from a csv and metadata from its comment header."""
105+
with open(path, 'r') as csv_file:
106+
header = csv_file.readline().lstrip('# ').strip()
107+
match = re.search(r'exposure=([^,]+),\s*continuous=(\S+)', header)
108+
e = float(match.group(1))
109+
c = match.group(2) == 'True'
110+
table = pd.read_csv(path, sep=',', comment='#')
111+
return cls(exposure=e, continuous=c, **{k: col for (k, col) in table.items()})
112+
113+
def to_csv(self, path: Path) -> None:
114+
"""Write self.table to a csv and metadata to its comment header."""
115+
header = f'# exposure={self.exposure:.6g}, continuous={self.continuous}\n'
116+
saved_cols = [c for c in self.table.columns if c != 'image']
117+
with open(path, 'w') as csv_file:
118+
csv_file.write(header)
119+
self.table.to_csv(path, mode='a', header=header, index=False, columns=saved_cols)
120+
100121
@property
101122
def steps(self) -> Iterator[Step]:
102123
"""Iterate over individual run `Step`s holding rows of `self.table`."""
@@ -277,6 +298,12 @@ def get_stage_rotation(self) -> CalibStageRotation:
277298
self.msg2(m2 := 'Please run `instamatic.calibrate_stage_rotation` first.')
278299
raise FastADTMissingCalibError(m1 + ' ' + m2)
279300

301+
def get_tracking_directory(self) -> Path:
302+
"""Return a Path to tracking directory, create if it doesn't exist."""
303+
tracking_dir = self.path / 'tracking'
304+
tracking_dir.mkdir(parents=True, exist_ok=True)
305+
return tracking_dir
306+
280307
def determine_rotation_speed_and_exposure(self, run: Run) -> tuple[float, float]:
281308
"""Closest possible speed setting & exposure considering dead time."""
282309
detector_dead_time = self.get_dead_time(run.exposure)
@@ -340,7 +367,12 @@ def start_collection(self, **params) -> None:
340367
if params['tracking_algo'] == 'manual':
341368
self.binsize = self.ctrl.cam.default_binsize
342369
self.runs.tracking = TrackingRun.from_params(params)
343-
self.determine_pathing_manually()
370+
self.pathing_determine_manually()
371+
self.pathing_save_to_files()
372+
elif params['tracking_algo'] == 'load':
373+
pathing_paths = params['tracking_details'].split(';')
374+
self.pathing_load_from_files(*pathing_paths)
375+
self.pathing_save_to_files()
344376
for pathing_run in self.runs.pathing:
345377
new_run = DiffractionRun.from_params(params)
346378
new_run.add_beamshifts(pathing_run)
@@ -363,7 +395,7 @@ def start_collection(self, **params) -> None:
363395
self.ctrl.stage.a = 0.0
364396

365397
@contextmanager
366-
def displayed_pathing(self, step: Step) -> None:
398+
def pathing_displayed(self, step: Step) -> None:
367399
"""Display step image with dots representing existing pathing."""
368400
draw = self.videostream_processor.draw
369401
instructions: list[draw.Instruction] = []
@@ -379,9 +411,9 @@ def displayed_pathing(self, step: Step) -> None:
379411
for instruction in instructions:
380412
draw.instructions.remove(instruction)
381413

382-
def determine_pathing_manually(self) -> None:
383-
"""Determine the target beam shifts `delta_x` and `delta_y` manually,
384-
based on the beam center found life (to find clicking offset) and
414+
def pathing_determine_manually(self) -> None:
415+
"""Determine the target `beamshift_x` and `beamshift_y` manually, based
416+
on the beam center found life (to find clicking offset) and
385417
`TrackingRun` to be used for crystal tracking in later experiment."""
386418
run: TrackingRun = cast(TrackingRun, self.runs.tracking)
387419
self.restore_fast_adt_diff_for_image()
@@ -401,7 +433,7 @@ def determine_pathing_manually(self) -> None:
401433
while tracking_in_progress:
402434
while (step := self.steps.get()) is not None:
403435
self.msg1(f'Click on tracked point: {step.summary}.')
404-
with self.displayed_pathing(step=step), self.click_listener:
436+
with self.pathing_displayed(step=step), self.click_listener:
405437
click = self.click_listener.get_click()
406438
click_xy = np.array(click.xy) * self.binsize
407439
delta_yx = (click_xy - obs_beampixel_xy)[::-1]
@@ -417,7 +449,7 @@ def determine_pathing_manually(self) -> None:
417449
self.msg1('Displaying tracking. Click LEFT mouse button to start the experiment,')
418450
self.msg2('MIDDLE to track another point, or RIGHT to cancel the experiment.')
419451
for step in sawtooth(self.runs.tracking.steps):
420-
with self.displayed_pathing(step=step):
452+
with self.pathing_displayed(step=step):
421453
image = self.videostream_processor.image
422454
image.info['_annotated_runs'] = len(self.runs.pathing)
423455
tracking_images[step.Index] = image
@@ -428,6 +460,7 @@ def determine_pathing_manually(self) -> None:
428460
self.msg2('')
429461
if click.button == MouseButton.RIGHT:
430462
self.msg1(msg := 'Experiment abandoned after tracking.')
463+
self.pathing_save_to_files()
431464
raise FastADTEarlyTermination(msg)
432465
if click.button == MouseButton.LEFT:
433466
tracking_in_progress = False
@@ -436,15 +469,31 @@ def determine_pathing_manually(self) -> None:
436469
self.steps.put(new_step)
437470
break
438471

439-
drc = self.path / 'tracking'
440-
drc.mkdir(parents=True, exist_ok=True)
472+
td = self.get_tracking_directory()
441473
with self.ctrl.cam.blocked():
442474
for step, image in zip(run.steps, tracking_images):
443475
i = f'image{step.Index:02d}_al{step.alpha:+03.0f}.png'.replace('+', '0')
444476
if image is None or image.info['_annotated_runs'] < len(self.runs.pathing):
445-
with self.displayed_pathing(step=step):
477+
with self.pathing_displayed(step=step):
446478
image = self.videostream_processor.image
447-
self.videostream_processor.vsf.save_image(image=image, path=drc / i)
479+
self.videostream_processor.vsf.save_image(image=image, path=td / i)
480+
481+
def pathing_load_from_files(self, *paths: AnyPath) -> None:
482+
"""Load pathing runs with 'beamshift_x/y' from existing csv files."""
483+
for path in paths:
484+
try:
485+
tracking = TrackingRun.from_csv(path=path)
486+
if m := {'beamshift_x', 'beamshift_y'} - set(tracking.table.columns):
487+
raise KeyError(f'Missing columns: {", ".join(m)}')
488+
self.runs.pathing.append(tracking)
489+
except (EmptyDataError, KeyError, OSError, ParserError) as e:
490+
self.msg2(f'{type(e).__name__}: {e} while loading "{path}".')
491+
492+
def pathing_save_to_files(self) -> None:
493+
"""Save all pathing runs to separate tracking_# files."""
494+
td = self.get_tracking_directory()
495+
for i, run in enumerate(self.runs.pathing):
496+
run.to_csv(path=td / f'path_{i + 1}.csv')
448497

449498
def collect_run(self, run: Run) -> None:
450499
"""Collect `run.steps` and place them in `self.steps` Queue."""

src/instamatic/gui/fast_adt_frame.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from functools import wraps
44
from tkinter import *
5+
from tkinter.filedialog import askopenfilenames
56
from tkinter.ttk import *
67
from typing import Any, Callable, Optional
78

@@ -54,6 +55,7 @@ def __init__(self, on_change: Optional[Callable[[], None]] = None) -> None:
5455
self.diffraction_step = DoubleVar(value=0.5)
5556
self.diffraction_time = DoubleVar(value=0.5)
5657
self.tracking_algo = StringVar()
58+
self.tracking_details = StringVar(value='')
5759
self.tracking_time = DoubleVar(value=0.5)
5860
self.tracking_step = DoubleVar(value=5.0)
5961

@@ -122,7 +124,8 @@ def __init__(self, parent):
122124

123125
Label(f, text='Tracking algorithm:').grid(row=3, column=2, **pad10)
124126
var = self.var.tracking_algo
125-
m = ['none', 'manual']
127+
var.trace_add('write', self.load_path_files)
128+
m = ['none', 'manual', 'load']
126129
self.tracking_algo = OptionMenu(f, var, m[0], *m)
127130
self.tracking_algo.grid(row=3, column=3, **pad10)
128131

@@ -219,12 +222,28 @@ def estimate_times(self) -> tuple[float, float]:
219222
diff_time = self.var.diffraction_time.get() * a_span / diff_step
220223
return track_time, diff_time
221224

225+
def load_path_files(self, *_) -> None:
226+
if self.var.tracking_algo.get() == 'load':
227+
paths = askopenfilenames(
228+
filetypes=[('CSV files', '*.csv'), ('All files', '*')],
229+
initialdir=self.app.get_module('io').get_experiment_directory(),
230+
parent=self,
231+
title='Select tracking path files to use',
232+
)
233+
if not paths:
234+
if not self.var.tracking_details.get():
235+
self.var.tracking_algo.set('none')
236+
else:
237+
self.var.tracking_details.set(';'.join(paths))
238+
else:
239+
self.var.tracking_details.set('')
240+
222241
def toggle_beam_blank(self) -> None:
223242
(self.ctrl.beam.unblank if self.ctrl.beam.is_blanked else self.ctrl.beam.blank)()
224243

225244
def update_widget(self, *_, busy: Optional[bool] = None, **__) -> None:
226245
self.busy = busy if busy is not None else self.busy
227-
no_tracking = self.var.tracking_algo.get() == 'none'
246+
no_tracking = self.var.tracking_algo.get() in ('none', 'load')
228247
widget_state = 'disabled' if self.busy else 'enabled'
229248
tracking_state = 'disabled' if self.busy or no_tracking else 'enabled'
230249

@@ -244,10 +263,15 @@ def update_widget(self, *_, busy: Optional[bool] = None, **__) -> None:
244263
return
245264
tt = '{:.0f}:{:02.0f}'.format(*divmod(tracking_time, 60))
246265
dt = '{:.0f}:{:02.0f}'.format(*divmod(diffraction_time, 60))
247-
if tracking_time: # don't display tracking time or per-attempts if zero
248-
msg = f'Estimated time required: {tt} + {dt} / tracking.'
249-
else:
250-
msg = f'Estimated time required: {dt}.'
266+
267+
if (ta := self.var.tracking_algo.get()) == 'none':
268+
msg = f'Minimum time required: {dt}.'
269+
elif ta == 'manual':
270+
msg = f'Minimum time required: {tt} + {dt} / tracking.'
271+
else: # ta == 'load'
272+
track_count = 1 + self.var.tracking_details.get().count(';')
273+
st = '{:.0f}:{:02.0f}'.format(*divmod(diffraction_time * track_count, 60))
274+
msg = f'Minimum time required: {dt} x {track_count} paths loaded = {st}.'
251275
self.message2.set(msg)
252276

253277
def start_collection(self) -> None:

0 commit comments

Comments
 (0)