diff --git a/src/odemis/acq/milling/fibsemos.py b/src/odemis/acq/milling/fibsemos.py index 5a84e6e101..aedc2df6ca 100644 --- a/src/odemis/acq/milling/fibsemos.py +++ b/src/odemis/acq/milling/fibsemos.py @@ -203,11 +203,24 @@ def convert_pattern_to_fibsemos(p: MillingPatternParameters) -> 'BasePattern': else: raise NotImplementedError(f"Conversion not implemented for pattern type: {type(p)}") + +def _apply_spot_size_correction(dimension: float, correction: float, dimension_name: str) -> float: + """Reduce a scan dimension by the measured opening excess.""" + corrected_dimension = dimension - correction + if corrected_dimension <= 0: + raise ValueError( + f"Spot size correction {correction} m makes the {dimension_name} non-positive " + f"({corrected_dimension} m)." + ) + return corrected_dimension + + def _convert_rectangle_pattern(p: RectanglePatternParameters) -> 'RectanglePattern': """Convert an Odemis rectangle pattern to a fibsemOS RectanglePattern.""" + correction = p.spot_size_correction.value return RectanglePattern( - width=p.width.value, - height=p.height.value, + width=_apply_spot_size_correction(p.width.value, correction, "rectangle width"), + height=_apply_spot_size_correction(p.height.value, correction, "rectangle height"), depth=p.depth.value, rotation=p.rotation.value, scan_direction=p.scan_direction.value, @@ -216,20 +229,24 @@ def _convert_rectangle_pattern(p: RectanglePatternParameters) -> 'RectanglePatte def _convert_trench_pattern(p: TrenchPatternParameters) -> 'TrenchPattern': """Convert an Odemis trench pattern to a fibsemOS TrenchPattern.""" + correction = p.spot_size_correction.value return TrenchPattern( - width=p.width.value, - upper_trench_height=p.height.value, - lower_trench_height=p.height.value, - spacing=p.spacing.value, + width=_apply_spot_size_correction(p.width.value, correction, "trench width"), + upper_trench_height=_apply_spot_size_correction(p.height.value, correction, "upper trench height"), + lower_trench_height=_apply_spot_size_correction(p.height.value, correction, "lower trench height"), + # fibsemOS derives trench centers from height and spacing. Increasing + # spacing keeps the smaller sent rectangles centered on the displayed ones. + spacing=p.spacing.value + correction, depth=p.depth.value, point=Point(x=p.center.value[0], y=p.center.value[1]) ) def _convert_microexpansion_pattern(p: MicroexpansionPatternParameters) -> 'MicroExpansionPattern': """Convert an Odemis microexpansion pattern to a fibsemOS MicroExpansionPattern.""" + correction = p.spot_size_correction.value return MicroExpansionPattern( - width=p.width.value, - height=p.height.value, + width=_apply_spot_size_correction(p.width.value, correction, "microexpansion width"), + height=_apply_spot_size_correction(p.height.value, correction, "microexpansion height"), depth=p.depth.value, distance=p.spacing.value, point=Point(x=p.center.value[0], y=p.center.value[1]) diff --git a/src/odemis/acq/milling/milling_tasks.yaml b/src/odemis/acq/milling/milling_tasks.yaml index abd1aade2e..eb5fe081c0 100644 --- a/src/odemis/acq/milling/milling_tasks.yaml +++ b/src/odemis/acq/milling/milling_tasks.yaml @@ -6,6 +6,8 @@ # task_name: # milling: Milling parameters # patterns: List of patterns (and parameters) to mill +# spot_size_correction is the total measured excess in the milled opening size. +# It must be nonnegative and is subtracted once from each dimension sent to fibsemOS. 'Microexpansion': name: 'Microexpansion' milling: @@ -20,6 +22,7 @@ height: 1.5e-05 depth: 1.0e-06 spacing: 1.0e-05 + spot_size_correction: 0.0 center_x: 0 center_y: 0 pattern: 'microexpansion' @@ -37,6 +40,7 @@ height: 6.0e-06 depth: 1.0e-06 spacing: 3.0e-06 + spot_size_correction: 0.0 center_x: 0 center_y: 0 pattern: 'trench' @@ -54,6 +58,7 @@ height: 4.0e-06 depth: 0.8e-06 spacing: 1.5e-06 + spot_size_correction: 0.0 center_x: 0 center_y: 0 pattern: 'trench' @@ -71,6 +76,7 @@ height: 1.0e-06 depth: 0.6e-06 spacing: 600.0e-09 + spot_size_correction: 0.0 center_x: 0 center_y: 0 pattern: 'trench' @@ -88,6 +94,7 @@ height: 0.6e-06 depth: 0.5e-06 spacing: 300.0e-09 + spot_size_correction: 0.0 center_x: 0 center_y: 0 pattern: 'trench' diff --git a/src/odemis/acq/milling/patterns.py b/src/odemis/acq/milling/patterns.py index a1ae00266c..0d45a915b7 100644 --- a/src/odemis/acq/milling/patterns.py +++ b/src/odemis/acq/milling/patterns.py @@ -56,7 +56,9 @@ def generate(self) -> List['MillingPatternParameters']: class RectanglePatternParameters(MillingPatternParameters): """Represents rectangle pattern parameters""" - def __init__(self, width: float, height: float, depth: float, rotation: float = 0.0, center = (0, 0), scan_direction: str = "TopToBottom", name: str = "Rectangle"): + def __init__(self, width: float, height: float, depth: float, rotation: float = 0.0, + center=(0, 0), scan_direction: str = "TopToBottom", name: str = "Rectangle", + spot_size_correction: float = 0.0): self.name = model.StringVA(name) self.width = model.FloatContinuous(width, unit="m", range=(1e-9, 900e-6)) self.height = model.FloatContinuous(height, unit="m", range=(1e-9, 900e-6)) @@ -64,6 +66,9 @@ def __init__(self, width: float, height: float, depth: float, rotation: float = self.rotation = model.FloatContinuous(rotation, unit="rad", range=(0, 2 * math.pi)) self.center = model.TupleContinuous(center, unit="m", range=((-1e3, -1e3), (1e3, 1e3)), cls=(int, float)) self.scan_direction = model.StringEnumerated(scan_direction, choices=set(["TopToBottom", "BottomToTop", "LeftToRight", "RightToLeft"])) + self.spot_size_correction = model.FloatContinuous( + spot_size_correction, unit="m", range=(0, 900e-6) + ) def to_dict(self) -> dict: """Convert the parameters to a json object""" @@ -75,6 +80,7 @@ def to_dict(self) -> dict: "center_x": self.center.value[0], "center_y": self.center.value[1], "scan_direction": self.scan_direction.value, + "spot_size_correction": self.spot_size_correction.value, "pattern": "rectangle" } @@ -87,7 +93,8 @@ def from_dict(data: dict) -> 'RectanglePatternParameters': rotation=data.get("rotation", 0), center=(data.get("center_x", 0), data.get("center_y", 0)), scan_direction=data.get("scan_direction", "TopToBottom"), - name=data.get("name", "Rectangle")) + name=data.get("name", "Rectangle"), + spot_size_correction=data.get("spot_size_correction", 0.0)) def __repr__(self) -> str: return f"{self.to_dict()}" @@ -101,13 +108,17 @@ def generate(self) -> List[MillingPatternParameters]: class TrenchPatternParameters(MillingPatternParameters): """Represents trench pattern parameters""" - def __init__(self, width: float, height: float, depth: float, spacing: float, center = (0, 0), name: str = "Trench"): + def __init__(self, width: float, height: float, depth: float, spacing: float, + center=(0, 0), name: str = "Trench", spot_size_correction: float = 0.0): self.name = model.StringVA(name) self.width = model.FloatContinuous(width, unit="m", range=(1e-9, 900e-6)) self.height = model.FloatContinuous(height, unit="m", range=(1e-9, 900e-6)) self.depth = model.FloatContinuous(depth, unit="m", range=(1e-9, 100e-6)) self.spacing = model.FloatContinuous(spacing, unit="m", range=(1e-9, 900e-6)) self.center = model.TupleContinuous(center, unit="m", range=((-1e3, -1e3), (1e3, 1e3)), cls=(int, float)) + self.spot_size_correction = model.FloatContinuous( + spot_size_correction, unit="m", range=(0, 900e-6) + ) def to_dict(self) -> dict: """Convert the parameters to a json object""" @@ -118,6 +129,7 @@ def to_dict(self) -> dict: "spacing": self.spacing.value, "center_x": self.center.value[0], "center_y": self.center.value[1], + "spot_size_correction": self.spot_size_correction.value, "pattern": "trench" } @@ -129,7 +141,8 @@ def from_dict(data: dict) -> 'TrenchPatternParameters': depth=data["depth"], spacing=data["spacing"], center=(data.get("center_x", 0), data.get("center_y", 0)), - name=data.get("name", "Trench")) + name=data.get("name", "Trench"), + spot_size_correction=data.get("spot_size_correction", 0.0)) def __repr__(self) -> str: return f"{self.to_dict()}" @@ -141,6 +154,7 @@ def generate(self) -> List[MillingPatternParameters]: height = self.height.value depth = self.depth.value spacing = self.spacing.value + spot_size_correction = self.spot_size_correction.value center = self.center.value # pattern center @@ -157,6 +171,7 @@ def generate(self) -> List[MillingPatternParameters]: rotation=0, center = (center_x, upper_center_y), # x, y scan_direction="TopToBottom", + spot_size_correction=spot_size_correction, ), RectanglePatternParameters( name=f"{name} (Lower)", @@ -166,6 +181,7 @@ def generate(self) -> List[MillingPatternParameters]: rotation=0, center = (center_x, lower_center_y), # x, y scan_direction="BottomToTop", + spot_size_correction=spot_size_correction, ), ] @@ -175,13 +191,17 @@ def generate(self) -> List[MillingPatternParameters]: class MicroexpansionPatternParameters(MillingPatternParameters): """Represents microexpansion pattern parameters""" - def __init__(self, width: float, height: float, depth: float, spacing: float, center = (0, 0), name: str = "Trench"): + def __init__(self, width: float, height: float, depth: float, spacing: float, + center=(0, 0), name: str = "Trench", spot_size_correction: float = 0.0): self.name = model.StringVA(name) self.width = model.FloatContinuous(width, unit="m", range=(1e-9, 900e-6)) self.height = model.FloatContinuous(height, unit="m", range=(1e-9, 900e-6)) self.depth = model.FloatContinuous(depth, unit="m", range=(1e-9, 100e-6)) self.spacing = model.FloatContinuous(spacing, unit="m", range=(1e-9, 900e-6)) self.center = model.TupleContinuous(center, unit="m", range=((-1e3, -1e3), (1e3, 1e3)), cls=(int, float)) + self.spot_size_correction = model.FloatContinuous( + spot_size_correction, unit="m", range=(0, 900e-6) + ) def to_dict(self) -> dict: """Convert the parameters to a json object""" @@ -192,6 +212,7 @@ def to_dict(self) -> dict: "spacing": self.spacing.value, "center_x": self.center.value[0], "center_y": self.center.value[1], + "spot_size_correction": self.spot_size_correction.value, "pattern": "microexpansion" } @@ -204,7 +225,8 @@ def from_dict(data: dict) -> 'MicroexpansionPatternParameters': depth=data["depth"], spacing=data["spacing"], center=(data.get("center_x", 0), data.get("center_y", 0)), - name=data.get("name", "Microexpansion")) + name=data.get("name", "Microexpansion"), + spot_size_correction=data.get("spot_size_correction", 0.0)) def __repr__(self) -> str: return f"{self.to_dict()}" @@ -216,6 +238,7 @@ def generate(self) -> List[MillingPatternParameters]: height = self.height.value depth = self.depth.value spacing = self.spacing.value + spot_size_correction = self.spot_size_correction.value center_x, center_y = self.center.value patterns = [ @@ -227,6 +250,7 @@ def generate(self) -> List[MillingPatternParameters]: rotation=0, center = (center_x - spacing, center_y), scan_direction="TopToBottom", + spot_size_correction=spot_size_correction, ), RectanglePatternParameters( name=f"{name} (Right)", @@ -236,6 +260,7 @@ def generate(self) -> List[MillingPatternParameters]: rotation=0, center = (center_x + spacing, center_y), scan_direction="TopToBottom", + spot_size_correction=spot_size_correction, ), ] diff --git a/src/odemis/acq/milling/test/fibsemos_test.py b/src/odemis/acq/milling/test/fibsemos_test.py index 2ee803317c..25c9082ed2 100644 --- a/src/odemis/acq/milling/test/fibsemos_test.py +++ b/src/odemis/acq/milling/test/fibsemos_test.py @@ -19,6 +19,7 @@ """ import logging import unittest +from unittest import mock import numpy from odemis.acq.milling import fibsemos # to load the fibsemOS module @@ -55,7 +56,7 @@ logging.getLogger().setLevel(logging.DEBUG) # Create dummy parameter objects to pass into converter functions. -def create_rectangle_pattern_params(): +def create_rectangle_pattern_params(spot_size_correction=0.0): return RectanglePatternParameters( name="Rectangle-1", width=10e-6, @@ -64,9 +65,10 @@ def create_rectangle_pattern_params(): rotation=0, center=(100, 150), scan_direction="TopToBottom", + spot_size_correction=spot_size_correction, ) -def create_trench_pattern_params(): +def create_trench_pattern_params(spot_size_correction=0.0): return TrenchPatternParameters( name="Trench-1", width=12e-6, @@ -74,6 +76,7 @@ def create_trench_pattern_params(): depth=4e-6, spacing=3e-6, center=(50, 75), + spot_size_correction=spot_size_correction, ) def create_microexpansion_pattern_params(): @@ -134,6 +137,47 @@ def test_convert_microexpansion_pattern(self): self.assertEqual(converted.point, Point(x=pattern_param.center.value[0], y=pattern_param.center.value[1])) + +class _FakeFibsemPattern: + def __init__(self, **kwargs): + vars(self).update(kwargs) + + +class _FakePoint: + def __init__(self, x, y): + self.x = x + self.y = y + + +class TestSpotSizeCorrectionConversion(unittest.TestCase): + def setUp(self): + patcher = mock.patch.multiple( + fibsemos, + RectanglePattern=_FakeFibsemPattern, + TrenchPattern=_FakeFibsemPattern, + Point=_FakePoint, + create=True, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_spot_size_correction_changes_sent_dimensions_only(self): + correction = 1e-6 + rectangle_param = create_rectangle_pattern_params(correction) + rectangle = fibsemos.convert_pattern_to_fibsemos(rectangle_param) + self.assertAlmostEqual(rectangle.width, rectangle_param.width.value - correction) + self.assertAlmostEqual(rectangle.height, rectangle_param.height.value - correction) + self.assertAlmostEqual(rectangle_param.width.value, 10e-6) + self.assertAlmostEqual(rectangle_param.height.value, 15e-6) + + def test_trench_centers_stay_on_displayed_positions(self): + correction = 0.1e-6 + trench_param = create_trench_pattern_params(correction) + trench = fibsemos.convert_pattern_to_fibsemos(trench_param) + sent_offset = (trench.spacing + trench.upper_trench_height) / 2 + displayed_offset = (trench_param.spacing.value + trench_param.height.value) / 2 + self.assertAlmostEqual(sent_offset, displayed_offset) + class TestConvertMillingSettings(unittest.TestCase): @classmethod diff --git a/src/odemis/acq/milling/test/patterns_test.py b/src/odemis/acq/milling/test/patterns_test.py index 046e437a03..6207eb535f 100644 --- a/src/odemis/acq/milling/test/patterns_test.py +++ b/src/odemis/acq/milling/test/patterns_test.py @@ -35,6 +35,7 @@ def setUp(self): self.rotation = 0 self.center = (0, 0) self.scan_direction = "TopToBottom" + self.spot_size_correction = 0.1e-6 self.pattern = RectanglePatternParameters( name=self.name, @@ -44,6 +45,7 @@ def setUp(self): rotation=self.rotation, center=self.center, scan_direction=self.scan_direction, + spot_size_correction=self.spot_size_correction, ) def test_assignment(self): @@ -55,6 +57,8 @@ def test_assignment(self): self.assertEqual(self.pattern.rotation.value, self.rotation) self.assertEqual(self.pattern.center.value, self.center) self.assertEqual(self.pattern.scan_direction.value, self.scan_direction) + with self.assertRaises(IndexError): + self.pattern.spot_size_correction.value = -0.1e-6 def test_dict(self): # test to_dict @@ -67,6 +71,7 @@ def test_dict(self): self.assertEqual(rectangle_pattern_dict["center_x"], 0) self.assertEqual(rectangle_pattern_dict["center_y"], 0) self.assertEqual(rectangle_pattern_dict["scan_direction"], self.scan_direction) + self.assertEqual(rectangle_pattern_dict["spot_size_correction"], self.spot_size_correction) self.assertEqual(rectangle_pattern_dict["pattern"], "rectangle") # test from_dict @@ -78,6 +83,14 @@ def test_dict(self): self.assertEqual(rectangle_pattern_from_dict.rotation.value, self.rotation) self.assertEqual(rectangle_pattern_from_dict.center.value, self.center) self.assertEqual(rectangle_pattern_from_dict.scan_direction.value, self.scan_direction) + self.assertEqual(rectangle_pattern_from_dict.spot_size_correction.value, + self.spot_size_correction) + + # Existing task files do not have a spot size correction and must retain the + # previous behavior. + del rectangle_pattern_dict["spot_size_correction"] + rectangle_without_correction = RectanglePatternParameters.from_dict(rectangle_pattern_dict) + self.assertEqual(rectangle_without_correction.spot_size_correction.value, 0.0) def test_generate(self): # test generate @@ -94,6 +107,7 @@ def setUp(self): self.depth = 10e-6 self.spacing = 5e-6 self.center = (0, 0) + self.spot_size_correction = 0.1e-6 self.pattern = TrenchPatternParameters( name=self.name, @@ -102,6 +116,7 @@ def setUp(self): depth=self.depth, spacing=self.spacing, center=self.center, + spot_size_correction=self.spot_size_correction, ) def test_assignment(self): @@ -112,6 +127,7 @@ def test_assignment(self): self.assertEqual(self.pattern.depth.value, self.depth) self.assertEqual(self.pattern.spacing.value, self.spacing) self.assertEqual(self.pattern.center.value, self.center) + self.assertEqual(self.pattern.spot_size_correction.value, self.spot_size_correction) def test_dict(self): # test to_dict @@ -123,6 +139,7 @@ def test_dict(self): self.assertEqual(trench_pattern_dict["spacing"], self.spacing) self.assertEqual(trench_pattern_dict["center_x"], 0) self.assertEqual(trench_pattern_dict["center_y"], 0) + self.assertEqual(trench_pattern_dict["spot_size_correction"], self.spot_size_correction) self.assertEqual(trench_pattern_dict["pattern"], "trench") # test from_dict @@ -133,6 +150,12 @@ def test_dict(self): self.assertEqual(trench_pattern_from_dict.depth.value, self.depth) self.assertEqual(trench_pattern_from_dict.spacing.value, self.spacing) self.assertEqual(trench_pattern_from_dict.center.value, self.center) + self.assertEqual(trench_pattern_from_dict.spot_size_correction.value, + self.spot_size_correction) + + del trench_pattern_dict["spot_size_correction"] + trench_without_correction = TrenchPatternParameters.from_dict(trench_pattern_dict) + self.assertEqual(trench_without_correction.spot_size_correction.value, 0.0) def test_generate(self): # test generate @@ -145,6 +168,7 @@ def test_generate(self): self.assertAlmostEqual(patterns[0].rotation.value, 0) numpy.testing.assert_array_almost_equal(patterns[0].center.value, (0, (self.spacing + self.height) / 2)) self.assertEqual(patterns[0].scan_direction.value, "TopToBottom") + self.assertEqual(patterns[0].spot_size_correction.value, self.spot_size_correction) self.assertEqual(patterns[1].name.value, f"{self.name} (Lower)") self.assertAlmostEqual(patterns[1].width.value, self.width) @@ -165,6 +189,7 @@ def setUp(self): self.depth = 5e-6 self.spacing = 20e-6 self.center = (0, 0) + self.spot_size_correction = 0.1e-6 self.pattern = MicroexpansionPatternParameters( name=self.name, @@ -173,6 +198,7 @@ def setUp(self): depth=self.depth, spacing=self.spacing, center=self.center, + spot_size_correction=self.spot_size_correction, ) def test_assignment(self): @@ -183,6 +209,7 @@ def test_assignment(self): self.assertEqual(self.pattern.depth.value, self.depth) self.assertEqual(self.pattern.spacing.value, self.spacing) self.assertEqual(self.pattern.center.value, self.center) + self.assertEqual(self.pattern.spot_size_correction.value, self.spot_size_correction) def test_dict(self): # test to_dict @@ -194,6 +221,8 @@ def test_dict(self): self.assertEqual(microexpansion_pattern_dict["spacing"], self.spacing) self.assertEqual(microexpansion_pattern_dict["center_x"], 0) self.assertEqual(microexpansion_pattern_dict["center_y"], 0) + self.assertEqual(microexpansion_pattern_dict["spot_size_correction"], + self.spot_size_correction) self.assertEqual(microexpansion_pattern_dict["pattern"], "microexpansion") # test from_dict @@ -204,6 +233,14 @@ def test_dict(self): self.assertEqual(microexpansion_pattern_from_dict.depth.value, self.depth) self.assertEqual(microexpansion_pattern_from_dict.spacing.value, self.spacing) self.assertEqual(microexpansion_pattern_from_dict.center.value, self.center) + self.assertEqual(microexpansion_pattern_from_dict.spot_size_correction.value, + self.spot_size_correction) + + del microexpansion_pattern_dict["spot_size_correction"] + microexpansion_without_correction = MicroexpansionPatternParameters.from_dict( + microexpansion_pattern_dict + ) + self.assertEqual(microexpansion_without_correction.spot_size_correction.value, 0.0) def test_generate(self): # test generate @@ -216,6 +253,7 @@ def test_generate(self): self.assertAlmostEqual(patterns[0].rotation.value, 0) numpy.testing.assert_array_almost_equal(patterns[0].center.value, (-self.spacing, 0)) self.assertEqual(patterns[0].scan_direction.value, "TopToBottom") + self.assertEqual(patterns[0].spot_size_correction.value, self.spot_size_correction) self.assertEqual(patterns[1].name.value, f"{self.name} (Right)") self.assertAlmostEqual(patterns[1].width.value, self.width) diff --git a/src/odemis/gui/comp/milling.py b/src/odemis/gui/comp/milling.py index ad0cccf72f..96d22377d7 100644 --- a/src/odemis/gui/comp/milling.py +++ b/src/odemis/gui/comp/milling.py @@ -71,6 +71,16 @@ def __init__(self, parent, task: MillingTaskSettings): "height": {"label": "Height", "accuracy": 2, "unit": "m"}, "depth": {"label": "Depth", "accuracy": 2, "unit": "m"}, "spacing": {"label": "Spacing", "accuracy": 2, "unit": "m"}, + "spot_size_correction": { + "label": "Spot size correction", + "accuracy": 2, + "unit": "m", + "key_step_min": 1e-6, + "tooltip": "Total measured excess in the milled opening size. The value " + "is subtracted from each dimension sent to the microscope. " + "Solid: desired opening. Dashed: estimated uncorrected opening. " + "Shaded band: measured excess.", + }, } unsupported_parameters = ["name", "rotation", @@ -112,7 +122,8 @@ def __init__(self, parent, task: MillingTaskSettings): def _add_value_field(self, label, val, conf, param: str): """Add a value field to the panel (label, ctrl)""" - lbl_ctrl = self._add_side_label(label) + tooltip = conf.pop("tooltip", None) + lbl_ctrl = self._add_side_label(label, tooltip=tooltip) value_ctrl = self._add_value_ctrl(val, conf) if value_ctrl is None: @@ -132,6 +143,8 @@ def _add_value_field(self, label, val, conf, param: str): value_ctrl.SetForegroundColour(gui.FG_COLOUR_EDIT) value_ctrl.SetBackgroundColour(gui.BG_COLOUR_MAIN) + if tooltip: + value_ctrl.SetToolTip(tooltip) self.num_rows += 1 def _add_value_ctrl(self, val, conf): diff --git a/src/odemis/gui/cont/milling.py b/src/odemis/gui/cont/milling.py index 07348b1092..847de9ad86 100644 --- a/src/odemis/gui/cont/milling.py +++ b/src/odemis/gui/cont/milling.py @@ -32,6 +32,7 @@ from datetime import datetime from typing import Dict, List, Optional, Tuple +import cairo import wx from odemis import model @@ -42,7 +43,11 @@ ) from odemis.acq.milling import millmng from odemis.acq.milling.millmng import MillingWorkflowTask, run_automated_milling -from odemis.acq.milling.patterns import RectanglePatternParameters +from odemis.acq.milling.patterns import ( + MicroexpansionPatternParameters, + RectanglePatternParameters, + TrenchPatternParameters, +) from odemis.acq.milling.tasks import MillingTaskSettings from odemis.gui.comp.milling import MillingTaskPanel from odemis.gui.comp.overlay.base import Vec @@ -70,6 +75,8 @@ # Step sizes to move the milling patterns horizontally MOVE_DELTA_X_SHORT = 1 # px MOVE_DELTA_X_LONG = 5 # px +SPOT_SIZE_CORRECTION_OPACITY = 0.18 +SPOT_SIZE_CORRECTION_DASH = [6, 4] def _get_milling_colour(task_name: str, idx: int) -> str: """Get the colour based on the task name or index""" @@ -99,14 +106,97 @@ def pos_to_absolute(pos: Tuple[float, float], ref_img: model.DataArray) -> Tuple return center_x, center_y + +class MillingRectangleOverlay(RectangleOverlay): + """Rectangle overlay that can show the estimated uncorrected opening.""" + + def __init__(self, *args, spot_size_correction: float = 0.0, + show_spot_size_correction: bool = False, **kwargs): + super().__init__(*args, **kwargs) + self.spot_size_correction = spot_size_correction + self.show_spot_size_correction = show_spot_size_correction + + def _draw_spot_size_correction(self, ctx) -> None: + correction = self.spot_size_correction + if not self.show_spot_size_correction or correction == 0: + return + + points = (self.p_point1, self.p_point2, self.p_point3, self.p_point4) + if any(point is None for point in points): + return + + xmin = min(point.x for point in points) + xmax = max(point.x for point in points) + ymin = min(point.y for point in points) + ymax = max(point.y for point in points) + correction_per_edge = correction / 2 + estimated_xmin = xmin - correction_per_edge + estimated_xmax = xmax + correction_per_edge + estimated_ymin = ymin - correction_per_edge + estimated_ymax = ymax + correction_per_edge + + displayed = ( + Vec(xmin, ymax), Vec(xmax, ymax), Vec(xmax, ymin), Vec(xmin, ymin) + ) + estimated = ( + Vec(estimated_xmin, estimated_ymax), + Vec(estimated_xmax, estimated_ymax), + Vec(estimated_xmax, estimated_ymin), + Vec(estimated_xmin, estimated_ymin), + ) + outer, inner = estimated, displayed + offset = self.cnvs.get_half_buffer_size() + estimated_buffer_points = [ + self.cnvs.phys_to_buffer(point, offset) for point in estimated + ] + + ctx.save() + ctx.new_path() + ctx.set_fill_rule(cairo.FILL_RULE_EVEN_ODD) + for rectangle in (outer, inner): + buffer_points = [self.cnvs.phys_to_buffer(point, offset) for point in rectangle] + ctx.move_to(*buffer_points[0]) + for point in buffer_points[1:]: + ctx.line_to(*point) + ctx.close_path() + red, green, blue, _ = self.colour + ctx.set_source_rgba(red, green, blue, SPOT_SIZE_CORRECTION_OPACITY) + ctx.fill() + + # The solid desired outline is drawn below by RectangleOverlay. Draw + # the estimated uncorrected opening on the other edge of the shaded band. + ctx.new_path() + ctx.move_to(*estimated_buffer_points[0]) + for point in estimated_buffer_points[1:]: + ctx.line_to(*point) + ctx.close_path() + ctx.set_line_width(2) + ctx.set_line_join(cairo.LINE_JOIN_MITER) + ctx.set_dash(SPOT_SIZE_CORRECTION_DASH) + ctx.set_source_rgba(*self.colour) + ctx.stroke() + ctx.restore() + + def draw(self, ctx, shift=(0, 0), scale=1.0, line_width=4): + self._draw_spot_size_correction(ctx) + super().draw(ctx, shift=shift, scale=scale, line_width=line_width) + + # TODO: support other shapes def rectangle_pattern_to_shape(canvas, ref_img: model.DataArray, pattern: RectanglePatternParameters, colour: str = "#FFFF00", - name: str = None) -> EditableShape: + name: str = None, + show_spot_size_correction: bool = False) -> EditableShape: """Convert a rectangle pattern to a shape""" - rect = RectangleOverlay(cnvs=canvas, colour = colour, show_selection_points = False) + rect = MillingRectangleOverlay( + cnvs=canvas, + colour=colour, + show_selection_points=False, + spot_size_correction=pattern.spot_size_correction.value, + show_spot_size_correction=show_spot_size_correction, + ) width = pattern.width.value height = pattern.height.value x, y = pos_to_absolute(pattern.center.value, ref_img) # image coordinates -> physical coordinates @@ -161,6 +251,7 @@ def __init__(self, tab_data, tab_panel, tab): # load the milling tasks self.milling_tasks: Dict[str, MillingTaskSettings] = {} # TODO: move to main_data self.allow_milling_pattern_move = True + self._active_spot_size_pattern = None # pattern overlay self.rectangles_overlay = ShapesOverlay( @@ -173,6 +264,7 @@ def __init__(self, tab_data, tab_panel, tab): self.selected_tasks = model.ListVA([]) # List of strings, names of the selected milling tasks self._panel.milling_task_chk_list.Bind(wx.EVT_CHECKLISTBOX, handler=self._update_selected_tasks) + self._panel.milling_task_chk_list.Bind(wx.EVT_LISTBOX, handler=self._on_milling_task_selected) self._tab_data.main.currentFeature.subscribe(self._on_current_feature_changes, init=True) @@ -221,6 +313,7 @@ def _update_pattern_panels(self) -> None: # self._panel.pnl_patterns.Destroy() self._panel.pnl_patterns.DestroyChildren() self.controls = {} + self._active_spot_size_pattern = None # create the panels self._panel.pnl_patterns._panel_sizer = wx.BoxSizer(wx.VERTICAL) @@ -228,7 +321,9 @@ def _update_pattern_panels(self) -> None: # create the setting panels, and connectors self.controls: Dict[str, MillingTaskPanel] = {} - pattern_parameters = ["width", "height", "depth", "spacing"] # TODO: add milling params + pattern_parameters = [ + "width", "height", "depth", "spacing", "spot_size_correction" + ] # TODO: add milling params # milling params: current, voltage, field of view, mode milling_parameters = ["current", "align", "mode"] @@ -260,6 +355,10 @@ def _update_pattern_panels(self) -> None: # VA connector, bind events getattr(parameters, param).subscribe(self._on_patterns) + panel.ctrl_dict[param].Bind( + wx.EVT_SET_FOCUS, + lambda evt, pattern=parameters: self._on_pattern_control_interaction(evt, pattern), + ) # milling parameters for param in milling_parameters: @@ -278,6 +377,27 @@ def _update_pattern_panels(self) -> None: # VA connector, bind events getattr(milling, param).subscribe(self._on_patterns) + activation_events = [wx.EVT_SET_FOCUS] + if isinstance(val, model.BooleanVA): + activation_events.append(wx.EVT_CHECKBOX) + if isinstance(val, model.StringEnumerated): + activation_events.append(wx.EVT_COMBOBOX) + for activation_event in activation_events: + panel.ctrl_dict[param].Bind( + activation_event, + lambda event, pattern=parameters: self._on_pattern_control_interaction( + event, pattern + ), + ) + + # Some wx controls, especially OwnerDrawnComboBox, send focus and + # mouse events from an internal child window instead of the control. + for control in panel.ctrl_dict.values(): + self._bind_pattern_activation(control, parameters) + panel.Bind( + wx.EVT_CHILD_FOCUS, + lambda evt, pattern=parameters: self._on_pattern_control_interaction(evt, pattern), + ) if not task.selected: panel.Hide() @@ -295,6 +415,25 @@ def _on_shapes_update(self, shapes): """Called when the shapes are updated""" logging.debug("Shapes updated: %s", shapes) + for task_name in self.selected_tasks.value: + task = self.milling_tasks.get(task_name) + if task is None: + continue + for pattern in task.patterns: + if not isinstance(pattern, (RectanglePatternParameters, + TrenchPatternParameters, + MicroexpansionPatternParameters)): + continue + correction = pattern.spot_size_correction.value + if correction >= min(pattern.width.value, pattern.height.value): + logging.warning( + "Spot size correction %s m is not smaller than pattern %s dimensions", + correction, + pattern.name.value, + ) + self.valid_patterns.value = False + return + # check if any of the points of the shapes are outside the bounding box of the image s_bbox = self.acq_cont.stream.getBoundingBox() for shape in shapes: @@ -308,6 +447,30 @@ def _on_shapes_update(self, shapes): # all shapes are valid self.valid_patterns.value = True + def _bind_pattern_activation(self, control: wx.Window, pattern) -> None: + """Activate a pattern when its control or an internal child is clicked.""" + control.Bind( + wx.EVT_LEFT_DOWN, + lambda evt, active_pattern=pattern: self._on_pattern_control_interaction( + evt, active_pattern + ), + ) + for child in control.GetChildren(): + self._bind_pattern_activation(child, pattern) + + def _on_pattern_control_interaction(self, evt, pattern): + if self._active_spot_size_pattern is not pattern: + self._active_spot_size_pattern = pattern + self.draw_milling_tasks() + evt.Skip() + + def _on_milling_task_selected(self, evt: wx.CommandEvent): + """Show the correction overlay for the highlighted pattern list row.""" + task = self.milling_tasks.get(evt.GetString()) + self._active_spot_size_pattern = task.patterns[0] if task and task.patterns else None + self.draw_milling_tasks() + evt.Skip() + def on_mouse_down(self, evt): active_canvas = evt.GetEventObject() logging.debug(f"mouse down event, canvas: {active_canvas}") @@ -483,7 +646,10 @@ def draw_milling_tasks(self, _=None): ref_img=feature.reference_image, pattern=pshape, colour=_get_milling_colour(task_name, i), - name=name) + name=name, + show_spot_size_correction=( + pattern is self._active_spot_size_pattern + )) self.rectangles_overlay.add_shape(shape) # validate the patterns @@ -615,7 +781,7 @@ def _update_mill_btn(self, _: wx.Event = None): self._panel.txt_automated_milling_est_time.SetLabel(txt) if not valid_patterns: - txt = "Patterns drawn outside image..." + txt = "Invalid milling pattern..." self._panel.txt_milling_est_time.SetLabel(txt) self._panel.txt_automated_milling_est_time.SetLabel(txt)