import json
import xml.etree.ElementTree as et
from collections import ChainMap
from typing import Any
from xml.etree.ElementTree import Element
from dodal.devices.oav.oav_errors import (
OAVError_BeamPositionNotFound,
OAVError_ZoomLevelNotFound,
)
from dodal.log import LOGGER
# GDA currently assumes this aspect ratio for the OAV window size.
# For some beamline this doesn't affect anything as the actual OAV aspect ratio
# matches. Others need to take it into account to rescale the values stored in
# the configuration files.
DEFAULT_OAV_WINDOW = (1024, 768)
OAV_CONFIG_JSON = (
"/dls_sw/i03/software/daq_configuration/json/OAVCentring_hyperion.json"
)
def _get_element_as_float(node: Element, element_name: str) -> float:
element = node.find(element_name)
assert element is not None, f"{element_name} not found in {node}"
assert element.text
return float(element.text)
[docs]
class OAVParameters:
"""
The parameters to set up the OAV depending on the context.
"""
def __init__(
self,
context="loopCentring",
oav_config_json=OAV_CONFIG_JSON,
):
self.oav_config_json: str = oav_config_json
self.context = context
self.global_params, self.context_dicts = self.load_json(self.oav_config_json)
self.active_params: ChainMap = ChainMap(
self.context_dicts[self.context], self.global_params
)
self.update_self_from_current_context()
[docs]
@staticmethod
def load_json(filename: str) -> tuple[dict[str, Any], dict[str, dict]]:
"""
Loads the json from the specified file, and returns a dict with all the
individual top-level k-v pairs, and one with all the subdicts.
"""
with open(filename) as f:
raw_params: dict[str, Any] = json.load(f)
global_params = {
k: raw_params.pop(k)
for k, v in list(raw_params.items())
if not isinstance(v, dict)
}
context_dicts = raw_params
return global_params, context_dicts
def update_context(self, context: str) -> None:
self.active_params.maps.pop()
self.active_params = self.active_params.new_child(self.context_dicts[context])
def update_self_from_current_context(self) -> None:
def update(name, param_type, default=None):
param = self.active_params.get(name, default)
try:
param = param_type(param)
return param
except AssertionError as e:
raise TypeError(
f"OAV param {name} from the OAV centring params json file has the "
f"wrong type, should be {param_type} but is {type(param)}."
) from e
self.exposure: float = update("exposure", float)
self.acquire_period: float = update("acqPeriod", float)
self.gain: float = update("gain", float)
self.canny_edge_upper_threshold: float = update(
"CannyEdgeUpperThreshold", float
)
self.canny_edge_lower_threshold: float = update(
"CannyEdgeLowerThreshold", float, default=5.0
)
self.minimum_height: int = update("minheight", int)
self.zoom: float = update("zoom", float)
self.preprocess: int = update(
"preprocess", int
) # gets blur type, e.g. 8 = gaussianBlur, 9 = medianBlur
self.preprocess_K_size: int = update(
"preProcessKSize", int
) # length scale for blur preprocessing
self.detection_script_filename: str = update("filename", str)
self.close_ksize: int = update("close_ksize", int, default=11)
self.min_callback_time: float = update("min_callback_time", float, default=0.08)
self.direction: int = update("direction", int)
self.max_tip_distance: float = update("max_tip_distance", float, default=300)
[docs]
def get_max_tip_distance_in_pixels(self, micronsPerPixel: float) -> float:
"""
Get the maximum tip distance in pixels.
"""
return self.max_tip_distance / micronsPerPixel
[docs]
class OAVConfigParams:
"""
The OAV parameters which may update depending on settings such as the zoom level.
"""
def __init__(
self,
zoom_params_file,
display_config,
):
self.zoom_params_file: str = zoom_params_file
self.display_config: str = display_config
def update_on_zoom(self, value, xsize, ysize, *args, **kwargs):
xsize, ysize = int(xsize), int(ysize)
if isinstance(value, str) and value.endswith("x"):
value = value.strip("x")
zoom = float(value)
self.load_microns_per_pixel(zoom, xsize, ysize)
self.beam_centre_i, self.beam_centre_j = self.get_beam_position_from_zoom(
zoom, xsize, ysize
)
[docs]
def load_microns_per_pixel(self, zoom: float, xsize: int, ysize: int) -> None:
"""
Loads the microns per x pixel and y pixel for a given zoom level. These are
currently generated by GDA, though hyperion could generate them in future.
"""
tree = et.parse(self.zoom_params_file)
self.micronsPerXPixel = self.micronsPerYPixel = None
root = tree.getroot()
levels = root.findall(".//zoomLevel")
for node in levels:
if _get_element_as_float(node, "level") == zoom:
self.micronsPerXPixel = (
_get_element_as_float(node, "micronsPerXPixel")
* DEFAULT_OAV_WINDOW[0]
/ xsize
)
self.micronsPerYPixel = (
_get_element_as_float(node, "micronsPerYPixel")
* DEFAULT_OAV_WINDOW[1]
/ ysize
)
if self.micronsPerXPixel is None or self.micronsPerYPixel is None:
raise OAVError_ZoomLevelNotFound(
f"""
Could not find the micronsPer[X,Y]Pixel parameters in
{self.zoom_params_file} for zoom level {zoom}.
"""
)
[docs]
def get_beam_position_from_zoom(
self, zoom: float, xsize: int, ysize: int
) -> tuple[int, int]:
"""
Extracts the beam location in pixels `xCentre` `yCentre`, for a requested zoom \
level. The beam location is manually inputted by the beamline operator on GDA \
by clicking where on screen a scintillator lights up, and stored in the \
display.configuration file.
"""
crosshair_x_line = None
crosshair_y_line = None
with open(self.display_config) as f:
file_lines = f.readlines()
for i in range(len(file_lines)):
if file_lines[i].startswith("zoomLevel = " + str(zoom)):
crosshair_x_line = file_lines[i + 1]
crosshair_y_line = file_lines[i + 2]
break
if crosshair_x_line is None or crosshair_y_line is None:
raise OAVError_BeamPositionNotFound(
f"Could not extract beam position at zoom level {zoom}"
)
beam_centre_i = (
int(crosshair_x_line.split(" = ")[1]) * xsize / DEFAULT_OAV_WINDOW[0]
)
beam_centre_j = (
int(crosshair_y_line.split(" = ")[1]) * ysize / DEFAULT_OAV_WINDOW[1]
)
LOGGER.info(f"Beam centre: {beam_centre_i, beam_centre_j}")
return int(beam_centre_i), int(beam_centre_j)
[docs]
def calculate_beam_distance(
self, horizontal_pixels: int, vertical_pixels: int
) -> tuple[int, int]:
"""
Calculates the distance between the beam centre and the given (horizontal, vertical).
Args:
horizontal_pixels (int): The x (camera coordinates) value in pixels.
vertical_pixels (int): The y (camera coordinates) value in pixels.
Returns:
The distance between the beam centre and the (horizontal, vertical) point in pixels as a tuple
(horizontal_distance, vertical_distance).
"""
return (
self.beam_centre_i - horizontal_pixels,
self.beam_centre_j - vertical_pixels,
)