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

Surface detection and faster z-stacks #188

Merged
merged 56 commits into from
Jan 25, 2024
Merged
Show file tree
Hide file tree
Changes from 55 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
02e6643
basic focus algorithm (worked on sample stacks)
outofculture Nov 14, 2023
66ad3a1
wire a button up, calculate the actual depth
outofculture Nov 20, 2023
9cb3d8b
depth from the range, not the images
outofculture Nov 20, 2023
270b0f7
teach a Microscope to find its own imager
outofculture Nov 24, 2023
d214605
commented-out, proper handling of CameraTaskResult
outofculture Nov 24, 2023
298d466
refactor camera imaging in prep for fast z scan
jessicatrnh Nov 11, 2023
315f487
future-based frame acquisition
outofculture Nov 28, 2023
86a6477
no floats allowed
outofculture Nov 28, 2023
ca3c376
starting the fast-z-stack implementation
outofculture Nov 28, 2023
86b1c99
no debug prints
outofculture Nov 28, 2023
79a589e
improving on the FrameAcquisitionFuture
outofculture Nov 30, 2023
cbfce72
small changes: whitespace, docstrings
outofculture Nov 30, 2023
adbb1cb
camel-ing the apis
outofculture Nov 30, 2023
600ad76
add simple camera controls to acquireFrames
outofculture Nov 30, 2023
9ec4fe5
Camera.run as a convenience method for start/stop logic
outofculture Nov 30, 2023
b09afef
teach CameraTask to use FrameAcquisitionFuture
outofculture Dec 1, 2023
79d3a35
use pg.SpinBox for floats
outofculture Dec 1, 2023
cba05e8
make runZStack helper function
outofculture Dec 1, 2023
70f2c16
put the z-stack code to use in the findSurface method
outofculture Dec 1, 2023
b8fc4b2
put the surface-finding execution into a Future
outofculture Dec 1, 2023
147fe46
checkStop needs to be called occasionally to prevent lockups
outofculture Dec 4, 2023
5a74368
passing the whole Future into wrapped functions offers better control
outofculture Dec 4, 2023
9d28ebc
unused
outofculture Dec 4, 2023
5f64478
allow for timelapses of z-stacks
outofculture Dec 4, 2023
9c85560
Future.waitFor only ever gets called with one future
outofculture Dec 4, 2023
5ea3d24
teach takeReferenceFrames to use runZStack
outofculture Dec 4, 2023
50a5691
just the corr value needed
outofculture Dec 4, 2023
fa3a2a1
typing, cleanup
outofculture Dec 4, 2023
9a1c05f
missing import, whitespace, cleanup
outofculture Dec 4, 2023
c100bf6
use Camera.run
outofculture Dec 6, 2023
8bb12a9
Merge branch 'main' into 166_surfs_up
outofculture Dec 6, 2023
2057cde
only restart the camera when necessary
outofculture Dec 6, 2023
35d64ee
rename for clarity
outofculture Dec 6, 2023
b39707f
DO channels _do_ use integers, so cast instead of mask
outofculture Dec 6, 2023
d638aca
instead of blocking arg, user can just decide to call getResult or not
outofculture Dec 6, 2023
7469e52
Merge branch 'main' into 166_surfs_up
outofculture Dec 6, 2023
752f894
Merge branch 'main' into 166_surfs_up
outofculture Dec 7, 2023
cfa6a88
refactor frame info to be reuseable. reuse it
outofculture Dec 7, 2023
bba4977
unused array
outofculture Dec 7, 2023
738498c
get the MockCameraTask to work with fixed or non-fixed
outofculture Dec 8, 2023
1df633d
estimate FPS at the acquisition thread with a rolling average
outofculture Dec 8, 2023
667e0f4
pass the speed on down from setFocusDepth to move
outofculture Dec 8, 2023
27cc32c
checkStop is not so private after all
outofculture Dec 8, 2023
25f45a5
we know that repetitions needs to be int-cast
outofculture Dec 8, 2023
fb1c6b7
in-progress making the z-stacks linear if possible
outofculture Dec 11, 2023
26f4fdd
implement Scientifica.positionUpdatesPerSecond
outofculture Dec 11, 2023
7eead7e
3.9-compatible typing
outofculture Dec 13, 2023
837d2a5
scientifica to use minInterval to calculate positionUpdatesPerSecond
outofculture Dec 19, 2023
4abd225
still prune down the z stack when slow
outofculture Dec 20, 2023
9e9e7c6
implement positionUpdatesPerSecond for all the rest of the Stages
outofculture Dec 20, 2023
2eeb190
Merge branch 'main' into 166_surfs_up
outofculture Dec 20, 2023
6930f98
imager passed into _set_focus_depth
outofculture Dec 21, 2023
108fa0b
Merge branch 'main' into 166_surfs_up
outofculture Jan 19, 2024
c0523d6
we still need to start the camera
outofculture Jan 22, 2024
6178b9c
Merge branch 'main' into 166_surfs_up
outofculture Jan 24, 2024
feb5af0
changes from code review
outofculture Jan 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
319 changes: 207 additions & 112 deletions acq4/devices/Camera/Camera.py

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion acq4/devices/Camera/CameraInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,8 +346,10 @@ def boundingRect(self):
return self.cam.getBoundary().boundingRect()

def takeImage(self, closeShutter=None):
# TODO this is maybe unused
# closeShutter is used for laser scanning devices; we can ignore it here.
return self.getDevice().acquireFrames(1, stack=False)
with self.getDevice().run():
return self.getDevice().acquireFrames(1).getResult()[0]


class CameraItemGroup(DeviceTreeItemGroup):
Expand Down
2 changes: 1 addition & 1 deletion acq4/devices/Camera/taskGUI.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def generateTask(self, params=None):
task['pushState'] = None
task['popState'] = None
if state['fixedFrameEnabled']:
task['minFrames'] = state['minFrames']
task['minFrames'] = int(state['minFrames'])
return task

def taskSequenceStarted(self):
Expand Down
2 changes: 2 additions & 0 deletions acq4/devices/DAQGeneric/DaqChannelGui.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ def updateHolding(self):
hv = self.getHoldingValue()
if hv is not None:
if not self.ui.holdingCheck.isChecked():
if self.config['type'] == 'do':
hv = int(hv)
self.ui.holdingSpin.setValue(hv)
self.ui.waveGeneratorWidget.setOffset(hv)

Expand Down
19 changes: 14 additions & 5 deletions acq4/devices/Device.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@

import os
import traceback
import weakref
from contextlib import contextmanager
from typing import Optional

import acq4
from acq4.Interfaces import InterfaceMixin
from acq4.util import Qt
from acq4.util.Mutex import Mutex
from acq4.util.debug import printExc
from acq4.util.optional_weakref import Weakref


class Device(InterfaceMixin, Qt.QObject): # QObject calls super, which is disastrous if not last in the MRO
Expand All @@ -30,7 +32,7 @@ def __init__(self, deviceManager: acq4.Manager.Manager, config: dict, name: str)
self._lock_tb_ = None
self.dm = deviceManager
self.dm.declareInterface(name, ['device'], self)
Device._deviceCreationOrder.append(weakref.ref(self))
Device._deviceCreationOrder.append(Weakref(self))
self._name = name

def name(self):
Expand Down Expand Up @@ -86,6 +88,14 @@ def appendConfigFile(self, data, filename):
fileName = os.path.join(self.configPath(), filename)
return self.dm.appendConfigFile(data, fileName)

@contextmanager
def reserved(self):
self.reserve()
try:
yield
finally:
self.release()

def reserve(self, block=True, timeout=20):
"""Reserve this device globally.

Expand Down Expand Up @@ -160,7 +170,7 @@ def __init__(self, dev, cmd, parentTask):
operating synchronously.
"""
self.dev = dev
self.__parentTask = weakref.ref(parentTask)
self.__parentTask = Weakref(parentTask)

def parentTask(self):
return self.__parentTask()
Expand Down Expand Up @@ -227,7 +237,6 @@ def getStartOrder(self):
"""
return [], []


def start(self):
"""
This method instructs the device to begin execution of the task.
Expand Down Expand Up @@ -346,7 +355,7 @@ def listSequence(self):
"""
return {}

def generateTask(self, params: "Dict | None" = None) -> dict:
def generateTask(self, params: Optional[dict] = None) -> dict:
"""
This method should convert params' index-values back into task-values, along with any default work non-sequenced
tasks need. WARNING! Long sequences will not automatically lock the UI or preserve the state of your parameter
Expand Down
4 changes: 1 addition & 3 deletions acq4/devices/MicroManagerCamera/mmcamera.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,10 @@ def _acquireFrames(self, n=1):
self.mmc.setCameraDevice(self.camName)
self.mmc.startSequenceAcquisition(n, 0, True)
frames = []
frameTimes = []
timeoutStart = ptime.time()
while self.mmc.isSequenceRunning() or self.mmc.getRemainingImageCount() > 0:
if self.mmc.getRemainingImageCount() > 0:
frameTimes.append(ptime.time())
timeoutStart = frameTimes[-1]
timeoutStart = ptime.time()
frames.append(self.mmc.popNextImage().T[np.newaxis, ...])
else:
if ptime.time() - timeoutStart > 10.0:
Expand Down
11 changes: 8 additions & 3 deletions acq4/devices/MicroManagerStage/mmstage.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from acq4.util.Thread import Thread
from acq4.util.micromanager import getMMCorePy
from ..Stage import Stage, MoveFuture, StageInterface
from ...util.debug import printExc


class MicroManagerStage(Stage):
Expand Down Expand Up @@ -131,6 +132,10 @@ def abort(self):
except:
printExc("Error stopping axis %s:" % ax)

@property
def positionUpdatesPerSecond(self):
return 1 / self.monitor.minInterval

def setUserSpeed(self, v):
"""Set the maximum speed of the stage (m/sec) when under manual control.

Expand Down Expand Up @@ -205,6 +210,7 @@ def __init__(self, dev):
self.lock = Mutex(recursive=True)
self.stopped = False
self.interval = 0.3
self.minInterval = 100e-3

Thread.__init__(self)

Expand All @@ -221,8 +227,7 @@ def setInterval(self, i):
self.interval = i

def run(self):
minInterval = 100e-3
interval = minInterval
interval = self.minInterval
lastPos = None
while True:
try:
Expand All @@ -234,7 +239,7 @@ def run(self):
pos = self.dev._getPosition() # this causes sigPositionChanged to be emitted
if pos != lastPos:
# if there was a change, then loop more rapidly for a short time.
interval = minInterval
interval = self.minInterval
lastPos = pos
else:
interval = min(maxInterval, interval * 2)
Expand Down
78 changes: 71 additions & 7 deletions acq4/devices/Microscope/Microscope.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import print_function

import collections
from typing import Tuple

import numpy as np
import scipy.ndimage

import pyqtgraph as pg
from acq4.util.typing import Number
from pyqtgraph.units import µm

from acq4.Manager import getManager
from acq4.devices.Device import Device
Expand All @@ -15,7 +16,7 @@
from acq4.util import Qt
from acq4.util.Mutex import Mutex
from acq4.util.debug import printExc
from acq4.util.future import MultiFuture
from acq4.util.future import Future, MultiFuture

Ui_Form = Qt.importTemplate('.deviceTemplate')

Expand Down Expand Up @@ -211,7 +212,64 @@ def setFocusDepth(self, z, speed='fast'):
fdpos[2] += dif
return fd.moveToGlobal(fdpos, speed)

def getSurfaceDepth(self):
def getDefaultImager(self):
name = self.config.get('defaultImager', None)
if name is None:
cameras = self.dm.listInterfaces("camera")
if len(cameras) == 0:
raise RuntimeError("No camera devices available.")
name = cameras[0]
return self.dm.getDevice(name)

def getZStack(self, imager: "Device", z_range: tuple[Number]) -> Future:
"""Acquire a z-stack of images using the given imager.

The z-stack is returned as frames.
"""
from acq4.util.imaging.sequencer import runZStack

return runZStack(imager, z_range)

@Future.wrap
def findSurfaceDepth(self, imager: "Device", _future: Future) -> None:
"""Set the surface of the sample based on how focused the images are."""

def center_area(img: np.ndarray) -> Tuple[slice, slice]:
"""Return a slice that selects the center of the image."""
minimum = 50
center_w = img.shape[0] // 2
start_w = max(min(int(img.shape[0] * 0.4), center_w - minimum), 0)
end_w = max(min(int(img.shape[0] * 0.6), center_w + minimum), img.shape[0])
center_h = img.shape[1] // 2
start_h = max(min(int(img.shape[1] * 0.4), center_h - minimum), 0)
end_h = max(min(int(img.shape[1] * 0.6), center_h + minimum), img.shape[1])
return slice(start_w, end_w), slice(start_h, end_h)

def downsample(arr, n):
new_shape = n * (np.array(arr.shape[1:]) / n).astype(int)
clipped = arr[:, :new_shape[0], :new_shape[1]]
mean1 = clipped.reshape(clipped.shape[0], clipped.shape[1], clipped.shape[2]//n, n).mean(axis=3)
mean2 = mean1.reshape(mean1.shape[0], mean1.shape[1]//n, n, mean1.shape[2]).mean(axis=2)
return mean2

def calculate_focus_score(image):
# image += np.random.normal(size=image.shape, scale=100)
image = scipy.ndimage.laplace(image) / np.mean(image)
return image.var()

z_range = (self.getSurfaceDepth() + 200 * µm, max(0, self.getSurfaceDepth() - 200 * µm), 1 * µm)
z_stack = _future.waitFor(self.getZStack(imager, z_range)).getResult()
filtered = downsample(np.array([f.data() for f in z_stack]), 5)
centers = filtered[(..., *center_area(filtered[0]))]
scored = np.array([calculate_focus_score(img) for img in centers])
surface = np.argmax(scored > 0.005) # arbitrary threshold? seems about right on the test data
if surface == 0:
return

surface_frame = z_stack[surface]
self.setSurfaceDepth(surface_frame.info()['Depth'])

def getSurfaceDepth(self) -> Number:
"""Return the z-position of the sample surface as marked by the user.
"""
return self._surfaceDepth
Expand Down Expand Up @@ -340,7 +398,6 @@ def __repr__(self):
return "<Objective %s.%s offset=%0.2g,%0.2g scale=%0.2g>" % (self._scope.name(), self.name(), self.offset().x(), self.offset().y(), self.scale().x())



class ScopeGUI(Qt.QWidget):
"""Microscope GUI displayed in Manager window.
Shows selection of objectives and allows scale/offset to be changed for each."""
Expand Down Expand Up @@ -487,8 +544,12 @@ def __init__(self, dev, mod):
self.layout.addWidget(self.setSurfaceBtn, 0, 0)
self.setSurfaceBtn.clicked.connect(self.setSurfaceClicked)

self.findSurfaceBtn = Qt.QPushButton('Find Surface')
self.layout.addWidget(self.findSurfaceBtn, 1, 0)
self.findSurfaceBtn.clicked.connect(self.findSurfaceClicked)

self.depthLabel = pg.ValueLabel(suffix='m', siPrefix=True)
self.layout.addWidget(self.depthLabel, 1, 0)
self.layout.addWidget(self.depthLabel, 2, 0)

dev.sigGlobalTransformChanged.connect(self.transformChanged)
dev.sigSurfaceDepthChanged.connect(self.surfaceDepthChanged)
Expand All @@ -504,6 +565,9 @@ def setSurfaceClicked(self):
self.getDevice().setSurfaceDepth(focus)
self.transformChanged()

def findSurfaceClicked(self):
self.getDevice().findSurfaceDepth(self.getDevice().getDefaultImager())

def surfaceDepthChanged(self, depth):
self.surfaceLine.setValue(depth)

Expand Down
69 changes: 45 additions & 24 deletions acq4/devices/MockCamera/mock_camera.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
from collections import OrderedDict
from typing import Union

import numpy as np
import scipy
import six
import time
from six.moves import range
from six.moves import zip

import acq4.util.functions as fn
import acq4.util.ptime as ptime
Expand Down Expand Up @@ -127,7 +125,7 @@ def startCamera(self):
self.lastFrameTime = ptime.time()

def stopCamera(self):
pass
self.lastFrameTime = None

def getNoise(self, shape):
n = shape[0] * shape[1]
Expand Down Expand Up @@ -221,10 +219,30 @@ def pixelVectors(self):

return x, y

def _acquireFrames(self, n: int):
self.startCamera()
try:
frames = []
while True:
frames.extend([f["data"][np.newaxis, ...] for f in self.newFrames()])
if len(frames) >= n or not self._cameraRunning():
break
time.sleep(0.1)
finally:
self.stopCamera()

return np.concatenate(frames[:int(n)])

def _cameraRunning(self):
return self.lastFrameTime is not None

def newFrames(self):
"""Return a list of all frames acquired since the last call to newFrames."""
prof = pg.debug.Profiler(disabled=True)

if self.lastFrameTime is None:
return []

now = ptime.time()
dt = now - self.lastFrameTime
exp = self.getParam("exposure")
Expand Down Expand Up @@ -284,27 +302,21 @@ def newFrames(self):
prof()

self.frameId += 1
frames = []
for i in range(nf):
frames.append({"data": data, "time": now + (i / fps), "id": self.frameId})
frames = [{"data": data, "time": now + (i / fps), "id": self.frameId} for i in range(nf)]
prof()
return frames

def quit(self):
pass

def listParams(self, params=None):
"""List properties of specified parameters, or of all parameters if None"""
def listParams(self, params: Union[list, str, None] = None):
"""List properties of specified parameter(s), or of all parameters if None"""
if params is None:
return self.paramRanges
else:
if isinstance(params, six.string_types):
return self.paramRanges[params]
if isinstance(params, str):
return self.paramRanges[params]

out = OrderedDict()
for k in params:
out[k] = self.paramRanges[k]
return out
return {k: self.paramRanges[k] for k in params}

def setParams(self, params, autoRestart=True, autoCorrect=True):
dp = []
Expand Down Expand Up @@ -363,14 +375,23 @@ def makeExpWave(self):
cmd = self.parentTask().tasks[daq].cmd
start = self.parentTask().startTime
sampleRate = cmd["rate"]

data = np.zeros(cmd["numPts"], dtype=np.uint8)
for f in self.frames:
t = f.info()["time"]
exp = f.info()["exposure"]
i0 = int((t - start) * sampleRate)
i1 = i0 + int((exp - 0.1e-3) * sampleRate)
data[i0:i1] = 1
numPts = cmd["numPts"]
data = np.zeros(numPts, dtype=np.uint8)
if self.fixedFrameCount is None:
frames = self._future.peekAtResult() # not exact, but close enough for a mock
for f in frames:
t = f.info()["time"]
exp = f.info()["exposure"]
i0 = int((t - start) * sampleRate)
i1 = i0 + int((exp - 0.1e-3) * sampleRate)
data[i0:i1] = 1
else:
n = self.fixedFrameCount
exp = int((self.dev.getParam("exposure") - 0.1e-3) * sampleRate)
minLength = max(numPts - exp, exp * n)
for i0 in np.linspace(1, minLength - 2, n, dtype=int):
i1 = i0 + exp
data[i0:i1] = 1

return data

Expand Down