Skip to content

Commit

Permalink
Finish setting up an ophyd_async OAV (#857)
Browse files Browse the repository at this point in the history
* Set up skeleton of async mjpg

* Start filling things in

* Try using completed_status

* Add SnapshotWithBeamCentre

* Add SnapshotWithBeamCentre

* A first try at adding snapshot to oav

* Rename

* Start adding async grid overlay

* Actually add file

* Start adding a test - might be deleted

* Move mjpg to plugins and get trigger working with async

* Remove microns per pixels from mjpg

* Pass an instance of soft signal beam_centre and fix imports

* Fix some typing and signals

* A bunch of fixmes

* A bunch of fixmes with test

* Improving file saving to make async and looking for the problem

* A passing test

* get test to pass

* Tidy up and fix tests

* Add a test for snapshot with grid

* Fix oav

* Start breaking things: remove old OAV

* Rename oav async file

* Remove old MJPG code and move around some files

* Rename

* Instantiate async oav on beamlines

* Tidy up some tests

* Add commented out test

* Add a sort of similar test for utils

* Remove some prints

* Use async oav in system test

* Remove some old tests and add to new ones

* Remove tests for old oav

* Remove old OAVConfigParams

* Fix import

* Hopefully fix system test

* Fix more imports

* Add small missing test

* Fix snapshot prefixes

* Fix snapshot prefixes - part 2

* Add small test

* Try to make codecov happy

* Fix test

* Add a small cam plugin for oav since we use it

* Try to fix test

* Add test for parameters

* Add array sizes

* Add check for zoom level and test

* Fix linting

* Really fix linting

* Fix docstring

* Change i04 oav instatiation

* Add connect

* Remove i10_4 again

* Fix CAM plugin

* Remove unused signals

* Do no catch exception when failing to create snapshot

* Put format insto constant

* Remove unused oav error file and redefine zoom level error

* Fix super

* Fix typo

* Remove extra connect

* Remove subscription id for snapshot

* Fix string enum

* Pull draw_crosshair out of class

* Mock describe instead of method

* Have a util function for saving image with asyncio

* Use raise_for_status in clientsession

* Tidy up mocks in snapshots

* Separate test for mkdir

* Add readables

* More add_children_as_readables

* Use IMG_FORMAT
  • Loading branch information
noemifrisina authored Nov 4, 2024
1 parent b0e233c commit cc5c259
Show file tree
Hide file tree
Showing 24 changed files with 809 additions and 1,044 deletions.
9 changes: 5 additions & 4 deletions src/dodal/beamlines/i03.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
from dodal.devices.flux import Flux
from dodal.devices.focusing_mirror import FocusingMirrorWithStripes, MirrorVoltages
from dodal.devices.motors import XYZPositioner
from dodal.devices.oav.oav_detector import OAV, OAVConfigParams
from dodal.devices.oav.oav_detector import OAV
from dodal.devices.oav.oav_parameters import OAVConfig
from dodal.devices.oav.pin_image_recognition import PinTipDetection
from dodal.devices.qbpm import QBPM
from dodal.devices.robot import BartRobot
Expand Down Expand Up @@ -227,18 +228,18 @@ def panda_fast_grid_scan(
def oav(
wait_for_connection: bool = True,
fake_with_ophyd_sim: bool = False,
params: OAVConfigParams | None = None,
params: OAVConfig | None = None,
) -> OAV:
"""Get the i03 OAV device, instantiate it if it hasn't already been.
If this is called when already instantiated in i03, it will return the existing object.
"""
return device_instantiation(
OAV,
"oav",
"",
"-DI-OAV-01:",
wait_for_connection,
fake_with_ophyd_sim,
params=params or OAVConfigParams(ZOOM_PARAMS_FILE, DISPLAY_CONFIG),
config=params or OAVConfig(ZOOM_PARAMS_FILE, DISPLAY_CONFIG),
)


Expand Down
13 changes: 9 additions & 4 deletions src/dodal/beamlines/i04.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
from dodal.devices.i04.transfocator import Transfocator
from dodal.devices.ipin import IPin
from dodal.devices.motors import XYZPositioner
from dodal.devices.oav.oav_detector import OAV, OAVConfigParams
from dodal.devices.oav.oav_detector import OAV
from dodal.devices.oav.oav_parameters import OAVConfig
from dodal.devices.oav.oav_to_redis_forwarder import OAVToRedisForwarder
from dodal.devices.robot import BartRobot
from dodal.devices.s4_slit_gaps import S4SlitGaps
Expand Down Expand Up @@ -344,17 +345,21 @@ def zebra(wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False) -


@skip_device(lambda: BL == "s04")
def oav(wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False) -> OAV:
def oav(
wait_for_connection: bool = True,
fake_with_ophyd_sim: bool = False,
params: OAVConfig | None = None,
) -> OAV:
"""Get the i04 OAV device, instantiate it if it hasn't already been.
If this is called when already instantiated in i04, it will return the existing object.
"""
return device_instantiation(
OAV,
"oav",
"",
"-DI-OAV-01:",
wait_for_connection,
fake_with_ophyd_sim,
params=OAVConfigParams(ZOOM_PARAMS_FILE, DISPLAY_CONFIG),
config=params or OAVConfig(ZOOM_PARAMS_FILE, DISPLAY_CONFIG),
)


Expand Down
2 changes: 1 addition & 1 deletion src/dodal/beamlines/i24.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from dodal.devices.i24.i24_detector_motion import DetectorMotion
from dodal.devices.i24.i24_vgonio import VGonio
from dodal.devices.i24.pmac import PMAC
from dodal.devices.oav.oav_async import OAV
from dodal.devices.oav.oav_detector import OAV
from dodal.devices.oav.oav_parameters import OAVConfig
from dodal.devices.zebra import Zebra
from dodal.log import set_beamline as set_log_beamline
Expand Down
31 changes: 31 additions & 0 deletions src/dodal/devices/areadetector/plugins/CAM.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from enum import Enum

from ophyd_async.core import StandardReadable
from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw


class ColorMode(str, Enum):
"""
Enum to store the various color modes of the camera. We use RGB1.
"""

MONO = "Mono"
BAYER = "Bayer"
RGB1 = "RGB1"
RGB2 = "RGB2"
RGB3 = "RGB3"
YUV444 = "YUV444"
YUV422 = "YUV422"
YUV421 = "YUV421"


class Cam(StandardReadable):
def __init__(self, prefix: str, name: str = "") -> None:
self.color_mode = epics_signal_rw(ColorMode, prefix + "ColorMode")
self.acquire_period = epics_signal_rw(float, prefix + "AcquirePeriod")
self.acquire_time = epics_signal_rw(float, prefix + "AcquireTime")
self.gain = epics_signal_rw(float, prefix + "Gain")

self.array_size_x = epics_signal_r(int, prefix + "ArraySizeX_RBV")
self.array_size_y = epics_signal_r(int, prefix + "ArraySizeY_RBV")
super().__init__(name)
157 changes: 51 additions & 106 deletions src/dodal/devices/areadetector/plugins/MJPG.py
Original file line number Diff line number Diff line change
@@ -1,138 +1,83 @@
import os
import threading
from abc import ABC, abstractmethod
from io import BytesIO
from pathlib import Path

import requests
from ophyd import Component, Device, DeviceStatus, EpicsSignal, EpicsSignalRO, Signal
from PIL import Image, ImageDraw
import aiofiles
from aiohttp import ClientSession
from bluesky.protocols import Triggerable
from ophyd_async.core import AsyncStatus, StandardReadable, soft_signal_rw
from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw
from PIL import Image

from dodal.devices.oav.oav_parameters import OAVConfigParams
from dodal.log import LOGGER

IMG_FORMAT = "png"

class MJPG(Device, ABC):

async def asyncio_save_image(image: Image.Image, path: str):
buffer = BytesIO()
image.save(buffer, format=IMG_FORMAT)
async with aiofiles.open(path, "wb") as fh:
await fh.write(buffer.getbuffer())


class MJPG(StandardReadable, Triggerable, ABC):
"""The MJPG areadetector plugin creates an MJPG video stream of the camera's output.
This devices uses that stream to grab images. When it is triggered it will send the
latest image from the stream to the `post_processing` method for child classes to handle.
"""

filename = Component(Signal)
directory = Component(Signal)
last_saved_path = Component(Signal)
url = Component(EpicsSignal, "JPG_URL_RBV", string=True)
x_size = Component(EpicsSignalRO, "ArraySize1_RBV")
y_size = Component(EpicsSignalRO, "ArraySize2_RBV")
input_rbpv = Component(EpicsSignalRO, "NDArrayPort_RBV")
input_plugin = Component(EpicsSignal, "NDArrayPort")
def __init__(self, prefix: str, name: str = "") -> None:
self.url = epics_signal_rw(str, prefix + "JPG_URL_RBV")

# scaling factors for the snapshot at the time it was triggered
microns_per_pixel_x = Component(Signal)
microns_per_pixel_y = Component(Signal)
self.x_size = epics_signal_r(int, prefix + "ArraySize1_RBV")
self.y_size = epics_signal_r(int, prefix + "ArraySize2_RBV")

oav_params: OAVConfigParams | None = None
with self.add_children_as_readables():
self.filename = soft_signal_rw(str)
self.directory = soft_signal_rw(str)
self.last_saved_path = soft_signal_rw(str)

KICKOFF_TIMEOUT: float = 30.0
self.KICKOFF_TIMEOUT = 30.0

def _save_image(self, image: Image.Image):
"""A helper function to save a given image to the path supplied by the directory
and filename signals. The full resultant path is put on the last_saved_path signal
super().__init__(name)

async def _save_image(self, image: Image.Image):
"""A helper function to save a given image to the path supplied by the \
directory and filename signals. The full resultant path is put on the \
last_saved_path signal
"""
filename_str = self.filename.get()
directory_str: str = self.directory.get() # type: ignore
filename_str = await self.filename.get_value()
directory_str = await self.directory.get_value()

path = Path(f"{directory_str}/{filename_str}.png").as_posix()
if not os.path.isdir(Path(directory_str)):
path = Path(f"{directory_str}/{filename_str}.{IMG_FORMAT}").as_posix()
if not Path(directory_str).is_dir():
LOGGER.info(f"Snapshot folder {directory_str} does not exist, creating...")
os.mkdir(directory_str)
Path(directory_str).mkdir(parents=True)

LOGGER.info(f"Saving image to {path}")
image.save(path)
self.last_saved_path.put(path)

def trigger(self):
await asyncio_save_image(image, path)

await self.last_saved_path.set(path, wait=True)

@AsyncStatus.wrap
async def trigger(self):
"""This takes a snapshot image from the MJPG stream and send it to the
post_processing method, expected to be implemented by a child of this class.
It is the responsibility of the child class to save any resulting images.
It is the responsibility of the child class to save any resulting images by \
calling _save_image.
"""
st = DeviceStatus(device=self, timeout=self.KICKOFF_TIMEOUT)
url_str = self.url.get()
url_str = await self.url.get_value()

assert isinstance(
self.oav_params, OAVConfigParams
), "MJPG does not have valid OAV parameters"
self.microns_per_pixel_x.set(self.oav_params.micronsPerXPixel)
self.microns_per_pixel_y.set(self.oav_params.micronsPerYPixel)

def get_snapshot():
try:
response = requests.get(url_str, stream=True)
response.raise_for_status()
with Image.open(BytesIO(response.content)) as image:
self.post_processing(image)
st.set_finished()
except requests.HTTPError as e:
st.set_exception(e)

threading.Thread(target=get_snapshot, daemon=True).start()

return st
async with ClientSession(raise_for_status=True) as session:
async with session.get(url_str) as response:
data = await response.read()
with Image.open(BytesIO(data)) as image:
await self.post_processing(image)

@abstractmethod
def post_processing(self, image: Image.Image):
async def post_processing(self, image: Image.Image):
pass


class SnapshotWithBeamCentre(MJPG):
"""A child of MJPG which, when triggered, draws an outlined crosshair at the beam
centre in the image and saves the image to disk."""

CROSSHAIR_LENGTH_PX = 20
CROSSHAIR_OUTLINE_COLOUR = "Black"
CROSSHAIR_FILL_COLOUR = "White"

def post_processing(self, image: Image.Image):
assert (
self.oav_params is not None
), "Snapshot device does not have valid OAV parameters"
beam_x = self.oav_params.beam_centre_i
beam_y = self.oav_params.beam_centre_j

SnapshotWithBeamCentre.draw_crosshair(image, beam_x, beam_y)

self._save_image(image)

@classmethod
def draw_crosshair(cls, image: Image.Image, beam_x: int, beam_y: int):
draw = ImageDraw.Draw(image)
OUTLINE_WIDTH = 1
HALF_LEN = cls.CROSSHAIR_LENGTH_PX / 2
draw.rectangle(
[
beam_x - OUTLINE_WIDTH,
beam_y - HALF_LEN - OUTLINE_WIDTH,
beam_x + OUTLINE_WIDTH,
beam_y + HALF_LEN + OUTLINE_WIDTH,
],
fill=cls.CROSSHAIR_OUTLINE_COLOUR,
)
draw.rectangle(
[
beam_x - HALF_LEN - OUTLINE_WIDTH,
beam_y - OUTLINE_WIDTH,
beam_x + HALF_LEN + OUTLINE_WIDTH,
beam_y + OUTLINE_WIDTH,
],
fill=cls.CROSSHAIR_OUTLINE_COLOUR,
)
draw.line(
((beam_x, beam_y - HALF_LEN), (beam_x, beam_y + HALF_LEN)),
fill=cls.CROSSHAIR_FILL_COLOUR,
)
draw.line(
((beam_x - HALF_LEN, beam_y), (beam_x + HALF_LEN, beam_y)),
fill=cls.CROSSHAIR_FILL_COLOUR,
)
Loading

0 comments on commit cc5c259

Please sign in to comment.