-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Finish setting up an ophyd_async OAV (#857)
* 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
1 parent
b0e233c
commit cc5c259
Showing
24 changed files
with
809 additions
and
1,044 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
Oops, something went wrong.