Skip to content

Commit 1fdd7db

Browse files
srishtysajeevtpoliawrtuck99olliesilvesterDominicOram
authored
Add a device that will calculate a beam centre (#1757)
* Add centring with the ellipse method as a device * Add DeviceManager as alternative to make_all_devices (#1549) * Proof of concept device manager * Use fixtures parameter instead of **kwargs * Expand dependencies * Pre-optional handling * 'Working' optional dependencies * Docs, comments and warnings * Check for positional only arguments * Handle connections and support new loader in cli * Convert adsim to new loading * Add auto_connect to device manager * Remove references to connecting from device manager * DeviceResult wrappers * Demo adsim * build_and_connect method * Multiple manager CLI * FIXTUEES * Use device decorator for timeouts * Type build_and_connect and rely on fixtures for path provider * mostly reformatting * Make fixture generators lazy * Apologise for build_order method * Return function from fixture decorator * Add timeout parameter to build_and_connect * Remove dead comment * Use set in expand_dependencies to prevent repetition * Check for duplicate factory names * Add Ophyd v1 support * Connect Ophyd v1 devices * Move device_manager to new module Instead of burying it amongst the beamlines. * Remove test code from device_manager module * Remove debugging and commented code * Merge connectionspec and connectionparameters * Add or_raise method to DeviceBuildResult * Add docstrings * Use or_raise to handle build errors * Only set device name if required * Add TODOs to remove v1 support * Make v1 device timeout configurable * Default to waiting for v1 device connection * Add repr to v1 device factory * Split DeviceBuildResult devices and connection specs Makes access to the devices easier and makes it easier to build parameters * Remove device_type property from factories * Include fixture overrides in built devices * Fix duplication in factory repr * Add initial device_manager tests * Revert adsim changes for now * Enough tests to get full coverage Maybe enough tests to cover basic use * Create DeviceManager in fixture * Add test for docstrings * Reformat tests * Linting et al * Support single device manager name in CLI * Ignore mock type warnings * Appease pre-commit lints * Add tests for device manager use in CLI * Make 'devices' default name for device manager in CLI * Clean up TODO comments * Set return_value when creating mocks * Fix typing in v1_init decorator * Use ParamSpec when passing through __call__ * Handle or ignore var args * Update tests * Rename ignore skip test * Simplify LazyFixture __getitem__ * Used UserDict as base class for LazyFixtures * Make pyright happy * Remove need for type ignore --------- Co-authored-by: Robert Tuck <robert.tuck@diamond.ac.uk> * Prevent Eiger from being disarmed multiple times at once (#1719) * Eiger changes from i04 beamline * Add test for two eiger stops being called at the same time * Revert async arming changes * Fix tests --------- Co-authored-by: Dominic Oram <dominic.oram@diamond.ac.uk> * Create a device that will give the max pixel from an AD camera (#1723) * first try * add trigger function which writes to soft signals * small changes * get rid of max pixel location * add tests - need modifying as made with copilot * make triggerable for blesky plan * start to add max-pixel device in i04 (WIP because on beamline) * Finish adding device to i04.py * Add changes from beamline testing - from github * add comment for kernal size * attemt own tests * add my tests * add assert called onece tests * make changes from Dom's comments * add greyscale test * remove print statements in test * Fix merge issue --------- Co-authored-by: Dominic Oram <dominic.oram@diamond.ac.uk> * test planning * change file names * change so that the centring deviceuses the same preprocessing function as the max pixel device (to blur and convert to greyscale) * fix max pixel test which was failing after function change * add more tests * more tests * tests working now * fix: linting issues and add more to docstring * fix import * fix: patch * add trigger test * add device to i04.py * Add changes from tests * responding to Ollie comments * get rid of comments from beamline testing * fix issue relating to using time.sleep and use asyncio.sleep instead * get rid of uneeded awaits * change lints * Tidy some things up from review * Separate zoom controller out * Tidy up and add test * Use a small move time for mock zoom controller * Fix import * Fix tests * Fix linting * Fix linting * Address review comments --------- Co-authored-by: Peter Holloway <peter.holloway@diamond.ac.uk> Co-authored-by: Robert Tuck <robert.tuck@diamond.ac.uk> Co-authored-by: olliesilvester <122091460+olliesilvester@users.noreply.github.com> Co-authored-by: Dominic Oram <dominic.oram@diamond.ac.uk>
1 parent 8b911e5 commit 1fdd7db

File tree

8 files changed

+325
-40
lines changed

8 files changed

+325
-40
lines changed

src/dodal/beamlines/i04.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from dodal.devices.fast_grid_scan import ZebraFastGridScanThreeD
2222
from dodal.devices.flux import Flux
2323
from dodal.devices.i03.dcm import DCM
24+
from dodal.devices.i04.beam_centre import CentreEllipseMethod
2425
from dodal.devices.i04.beamsize import Beamsize
2526
from dodal.devices.i04.constants import RedisConstants
2627
from dodal.devices.i04.max_pixel import MaxPixel
@@ -29,7 +30,10 @@
2930
from dodal.devices.ipin import IPin
3031
from dodal.devices.motors import XYZStage
3132
from dodal.devices.mx_phase1.beamstop import Beamstop
32-
from dodal.devices.oav.oav_detector import OAVBeamCentrePV
33+
from dodal.devices.oav.oav_detector import (
34+
OAVBeamCentrePV,
35+
ZoomControllerWithBeamCentres,
36+
)
3337
from dodal.devices.oav.oav_parameters import OAVConfig
3438
from dodal.devices.oav.oav_to_redis_forwarder import OAVToRedisForwarder
3539
from dodal.devices.oav.pin_image_recognition import PinTipDetection
@@ -271,6 +275,14 @@ def zebra() -> Zebra:
271275
)
272276

273277

278+
@device_factory()
279+
def zoom_controller() -> ZoomControllerWithBeamCentres:
280+
"""Get the i04 zoom controller, instantiate it if it hasn't already been.
281+
If this is called when already instantiated in i04, it will return the existing object.
282+
"""
283+
return ZoomControllerWithBeamCentres(f"{PREFIX.beamline_prefix}-EA-OAV-01:FZOOM:")
284+
285+
274286
@device_factory(
275287
skip=BL == "s04",
276288
)
@@ -412,3 +424,13 @@ def beamsize() -> Beamsize:
412424
transfocator=transfocator(),
413425
aperture_scatterguard=aperture_scatterguard(),
414426
)
427+
428+
429+
@device_factory()
430+
def beam_centre() -> CentreEllipseMethod:
431+
"""Get the i04 centring device, instantiate it if it hasn't already been.
432+
If this is called when already instantiated in i04, it will return the existing object.
433+
"""
434+
return CentreEllipseMethod(
435+
f"{PREFIX.beamline_prefix}-DI-OAV-01:",
436+
)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import cv2
2+
import numpy as np
3+
from bluesky.protocols import Triggerable
4+
from ophyd_async.core import AsyncStatus, StandardReadable, soft_signal_r_and_setter
5+
from ophyd_async.epics.core import (
6+
epics_signal_r,
7+
)
8+
9+
from dodal.devices.oav.utils import convert_to_gray_and_blur
10+
from dodal.log import LOGGER
11+
12+
# Constant was chosen from trial and error with test images
13+
ADDITIONAL_BINARY_THRESH = 20
14+
15+
16+
def convert_image_to_binary(image: np.ndarray):
17+
"""
18+
Creates a binary image from OAV image array data.
19+
20+
Pixels of the input image are converted to one of two values (a high and a low value).
21+
Otsu's method is used for automatic thresholding.
22+
See https://docs.opencv.org/4.x/d7/d4d/tutorial_py_thresholding.html.
23+
The threshold is increased by ADDITIONAL_BINARY_THRESH in order to get more of
24+
the centre of the beam.
25+
"""
26+
max_pixel_value = 255
27+
28+
blurred_image = convert_to_gray_and_blur(image)
29+
30+
threshold_value, _ = cv2.threshold(
31+
blurred_image, 0, max_pixel_value, cv2.THRESH_BINARY + cv2.THRESH_OTSU
32+
)
33+
34+
# Adjusting because the inner beam is less noisy compared to the outer
35+
threshold_value += ADDITIONAL_BINARY_THRESH
36+
37+
_, thresholded_image = cv2.threshold(
38+
blurred_image, threshold_value, max_pixel_value, cv2.THRESH_BINARY
39+
)
40+
41+
LOGGER.info(f"Image binarised with threshold of {threshold_value}")
42+
return thresholded_image
43+
44+
45+
class CentreEllipseMethod(StandardReadable, Triggerable):
46+
"""
47+
Upon triggering, fits an ellipse to a binary image from the area detector defined by
48+
the prefix.
49+
50+
This is used, in conjunction with a scintillator, to determine the centre of the beam
51+
on the image.
52+
"""
53+
54+
def __init__(self, prefix: str, name: str = ""):
55+
self.oav_array_signal = epics_signal_r(np.ndarray, f"pva://{prefix}PVA:ARRAY")
56+
57+
self.center_x_val, self._center_x_val_setter = soft_signal_r_and_setter(float)
58+
self.center_y_val, self._center_y_val_setter = soft_signal_r_and_setter(float)
59+
super().__init__(name)
60+
61+
def _fit_ellipse(self, binary_img: cv2.typing.MatLike) -> cv2.typing.RotatedRect:
62+
contours, _ = cv2.findContours(
63+
binary_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE
64+
)
65+
if not contours:
66+
raise ValueError("No contours found in image.")
67+
68+
largest_contour = max(contours, key=cv2.contourArea)
69+
if len(largest_contour) < 5:
70+
raise ValueError(
71+
f"Not enough points to fit an ellipse. Found {largest_contour} points and need at least 5."
72+
)
73+
74+
return cv2.fitEllipse(largest_contour)
75+
76+
@AsyncStatus.wrap
77+
async def trigger(self):
78+
array_data = await self.oav_array_signal.get_value()
79+
binary = convert_image_to_binary(array_data)
80+
ellipse_fit = self._fit_ellipse(binary)
81+
centre_x = ellipse_fit[0][0]
82+
centre_y = ellipse_fit[0][1]
83+
self._center_x_val_setter(centre_x)
84+
self._center_y_val_setter(centre_y)

src/dodal/devices/i04/max_pixel.py

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import cv2
21
import numpy as np
32
from bluesky.protocols import Triggerable
43
from ophyd_async.core import AsyncStatus, StandardReadable, soft_signal_r_and_setter
54
from ophyd_async.epics.core import (
65
epics_signal_r,
76
)
87

9-
# kernal size describes how many of the neigbouring pixels are used for the blur,
10-
# higher kernal size means more of a blur effect
11-
KERNAL_SIZE = (7, 7)
8+
from dodal.devices.oav.utils import convert_to_gray_and_blur
129

1310

1411
class MaxPixel(StandardReadable, Triggerable):
@@ -19,20 +16,10 @@ def __init__(self, prefix: str, name: str = "") -> None:
1916
self.max_pixel_val, self._max_val_setter = soft_signal_r_and_setter(float)
2017
super().__init__(name)
2118

22-
async def _convert_to_gray_and_blur(self):
23-
"""
24-
Preprocess the image array data (convert to grayscale and apply a gaussian blur)
25-
Image is converted to grayscale (using a weighted mean as green contributes more to brightness)
26-
as we aren't interested in data relating to colour. A blur is then applied to mitigate
27-
errors due to rogue hot pixels.
28-
"""
29-
data = await self.array_data.get_value()
30-
gray_arr = cv2.cvtColor(data, cv2.COLOR_BGR2GRAY)
31-
return cv2.GaussianBlur(gray_arr, KERNAL_SIZE, 0)
32-
3319
@AsyncStatus.wrap
3420
async def trigger(self):
35-
arr = await self._convert_to_gray_and_blur()
36-
max_val = float(np.max(arr)) # np.int64
21+
img_data = await self.array_data.get_value()
22+
arr = convert_to_gray_and_blur(img_data)
23+
max_val = float(np.max(arr))
3724
assert isinstance(max_val, float)
3825
self._max_val_setter(max_val)

src/dodal/devices/oav/oav_detector.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
import asyncio
12
from enum import IntEnum
23

34
from bluesky.protocols import Movable
45
from ophyd_async.core import (
56
DEFAULT_TIMEOUT,
67
AsyncStatus,
8+
DeviceMock,
9+
DeviceVector,
710
LazyMock,
811
SignalR,
912
SignalRW,
1013
StandardReadable,
14+
default_mock_class,
1115
derived_signal_r,
1216
soft_signal_rw,
1317
)
@@ -22,6 +26,7 @@
2226
)
2327
from dodal.devices.oav.snapshots.snapshot import Snapshot
2428
from dodal.devices.oav.snapshots.snapshot_with_grid import SnapshotWithGrid
29+
from dodal.log import LOGGER
2530

2631

2732
class Coords(IntEnum):
@@ -56,26 +61,85 @@ async def set(self, value: str) -> None:
5661
await self.level.set(value, wait=True)
5762

5863

64+
class BeamCentreForZoom(StandardReadable):
65+
"""These PVs hold the beam centre on the OAV at each zoom level.
66+
67+
When the zoom level is changed the IOC will update the OAV overlay PVs to be at these positions."""
68+
69+
def __init__(
70+
self, prefix: str, level_name_pv_suffix: str, centre_value_pv_suffix: str
71+
) -> None:
72+
self.level_name = epics_signal_r(
73+
str, f"{prefix}MP:SELECT.{level_name_pv_suffix}"
74+
)
75+
self.x_centre = epics_signal_rw(
76+
float, f"{prefix}PBCX:VAL{centre_value_pv_suffix}"
77+
)
78+
self.y_centre = epics_signal_rw(
79+
float, f"{prefix}PBCY:VAL{centre_value_pv_suffix}"
80+
)
81+
super().__init__()
82+
83+
84+
class InstantMovingZoom(DeviceMock["ZoomController"]):
85+
"""Mock behaviour that instantly moves the zoom."""
86+
87+
async def connect(self, device: "ZoomController") -> None:
88+
"""Mock signals to do an instant move on setpoint write."""
89+
device.DELAY_BETWEEN_MOTORS_AND_IMAGE_UPDATING_S = 0.001 # type:ignore
90+
91+
92+
@default_mock_class(InstantMovingZoom)
5993
class ZoomController(BaseZoomController):
6094
"""
6195
Device to control the zoom level. This should be set like
6296
o = OAV(name="oav")
6397
oav.zoom_controller.set("1.0x")
6498
6599
Note that changing the zoom may change the AD wiring on the associated OAV, as such
66-
you should wait on any zoom changs to finish before changing the OAV wiring.
100+
you should wait on any zoom changes to finish before changing the OAV wiring.
67101
"""
68102

103+
DELAY_BETWEEN_MOTORS_AND_IMAGE_UPDATING_S = 2
104+
69105
def __init__(self, prefix: str, name: str = "") -> None:
70106
self.percentage = epics_signal_rw(float, f"{prefix}ZOOMPOSCMD")
71107

72108
# Level is the string description of the zoom level e.g. "1.0x" or "1.0"
73109
self.level = epics_signal_rw(str, f"{prefix}MP:SELECT")
110+
74111
super().__init__(name=name)
75112

76113
@AsyncStatus.wrap
77114
async def set(self, value: str):
78115
await self.level.set(value, wait=True)
116+
LOGGER.info(
117+
"Waiting {self.DELAY_BETWEEN_MOTORS_AND_IMAGE_UPDATING_S} seconds for zoom to be noticeable"
118+
)
119+
await asyncio.sleep(self.DELAY_BETWEEN_MOTORS_AND_IMAGE_UPDATING_S)
120+
121+
122+
class ZoomControllerWithBeamCentres(ZoomController):
123+
def __init__(self, prefix: str, name: str = "") -> None:
124+
level_to_centre_mapping = [
125+
("ZRST", "A"),
126+
("ONST", "B"),
127+
("TWST", "C"),
128+
("THST", "D"),
129+
("FRST", "E"),
130+
("FVST", "F"),
131+
("SXST", "G"),
132+
("SVST", "H"),
133+
]
134+
135+
self.beam_centres = DeviceVector(
136+
{
137+
i: BeamCentreForZoom(prefix, *level_to_centre_mapping[i])
138+
for i in range(len(level_to_centre_mapping))
139+
}
140+
)
141+
142+
super().__init__(prefix, name)
79143

80144

81145
class OAV(StandardReadable):
@@ -118,6 +182,7 @@ def __init__(
118182
self.zoom_controller = zoom_controller
119183

120184
self.cam = Cam(f"{prefix}CAM:", name=name)
185+
121186
with self.add_children_as_readables():
122187
self.grid_snapshot = SnapshotWithGrid(
123188
f"{prefix}{mjpeg_prefix}:", name, mjpg_x_size_pv, mjpg_y_size_pv

src/dodal/devices/oav/utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from enum import IntEnum
33

44
import bluesky.plan_stubs as bps
5+
import cv2
56
import numpy as np
67
from bluesky.utils import Msg
78

@@ -119,3 +120,19 @@ def wait_for_tip_to_be_found(
119120
raise PinNotFoundError(f"No pin found after {timeout} seconds")
120121

121122
return Pixel((int(found_tip[0]), int(found_tip[1])))
123+
124+
125+
def convert_to_gray_and_blur(data: cv2.typing.MatLike) -> cv2.typing.MatLike:
126+
"""
127+
Preprocess the image array data (convert to grayscale and apply a gaussian blur)
128+
Image is converted to grayscale (using a weighted mean as green contributes more to brightness)
129+
as we aren't interested in data relating to colour. A blur is then applied to mitigate
130+
errors due to rogue hot pixels.
131+
"""
132+
133+
# kernel size describes how many of the neighbouring pixels are used for the blur,
134+
# higher kernal size means more of a blur effect
135+
kernel_size = (7, 7)
136+
137+
gray_arr = cv2.cvtColor(data, cv2.COLOR_BGR2GRAY)
138+
return cv2.GaussianBlur(gray_arr, kernel_size, 0)

0 commit comments

Comments
 (0)