From 1315c25f271b2822a19dfa8ab4ea10fc0ffd6941 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Sat, 4 Nov 2023 22:49:14 -0700 Subject: [PATCH 01/60] adding registration and initial yml config files --- .gitignore | 3 +- copylot/assemblies/photom/__init__.py | 0 .../photom/demo/demo_affine_scan.py | 63 ++++++ copylot/assemblies/photom/photom.py | 21 ++ .../photom/settings/affine_transform.yml | 4 + copylot/assemblies/photom/tests/__init__.py | 0 .../assemblies/photom/tests/test_photom.py | 25 +++ copylot/assemblies/photom/utils/__init__.py | 0 .../photom/utils/affine_transform.py | 106 ++++++++++ copylot/assemblies/photom/utils/io.py | 101 +++++++++ .../photom/utils/scanning_algorithms.py | 196 ++++++++++++++++++ copylot/assemblies/photom/utils/settings.py | 21 ++ .../assemblies/photom/utils/tests/__init__.py | 0 .../utils/tests/test_scanning_algorithms.py | 24 +++ 14 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 copylot/assemblies/photom/__init__.py create mode 100644 copylot/assemblies/photom/demo/demo_affine_scan.py create mode 100644 copylot/assemblies/photom/photom.py create mode 100644 copylot/assemblies/photom/settings/affine_transform.yml create mode 100644 copylot/assemblies/photom/tests/__init__.py create mode 100644 copylot/assemblies/photom/tests/test_photom.py create mode 100644 copylot/assemblies/photom/utils/__init__.py create mode 100644 copylot/assemblies/photom/utils/affine_transform.py create mode 100644 copylot/assemblies/photom/utils/io.py create mode 100644 copylot/assemblies/photom/utils/scanning_algorithms.py create mode 100644 copylot/assemblies/photom/utils/settings.py create mode 100644 copylot/assemblies/photom/utils/tests/__init__.py create mode 100644 copylot/assemblies/photom/utils/tests/test_scanning_algorithms.py diff --git a/.gitignore b/.gitignore index a59f68cb..603a8b38 100644 --- a/.gitignore +++ b/.gitignore @@ -133,4 +133,5 @@ ToDo.txt defaults.txt copylot/_version.py -.DS_Store \ No newline at end of file +.DS_Store +.vscode/settings.json diff --git a/copylot/assemblies/photom/__init__.py b/copylot/assemblies/photom/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/copylot/assemblies/photom/demo/demo_affine_scan.py b/copylot/assemblies/photom/demo/demo_affine_scan.py new file mode 100644 index 00000000..0d80e2db --- /dev/null +++ b/copylot/assemblies/photom/demo/demo_affine_scan.py @@ -0,0 +1,63 @@ +# %% +import sys +import os + +sys.path.append(os.path.join(os.pardir, os.pardir, os.pardir)) +import numpy as np +import matplotlib as mpl +import matplotlib.pyplot as plt + +from copylot.assemblies.photom.utils.scanning_algorithms import ScanAlgorithm +from copylot.assemblies.photom.utils.affine_transform import AffineTransform + +mpl.rcParams['agg.path.chunksize'] = 20000 + +# %% +initial_coord = (549.5, 754.5) +size = (300, 100) +gap = 10 +shape = 'disk' +sec_per_cycle = 0.003 +sg = ScanAlgorithm(initial_coord, size, gap, shape, sec_per_cycle) + +# coord = sg.generate_cornerline() +coord = sg.generate_lissajous() +# coord = sg.generate_sin() + +plt.figure() +plt.plot(coord[0], coord[1]) +plt.show() + +# Load affine matrix +trans_obj = AffineTransform(config_file='../settings/affine_transform.yml') + +# %% +# compute affine matrix +xv, yv = np.meshgrid( + np.linspace(1, 10, 10), + np.linspace(1, 10, 10), +) +coord = (list(xv.flatten()), list(yv.flatten())) +pts1 = [ + [xv[0, 0], yv[0, 0]], + [xv[-1, -1], yv[-1, -1]], + [xv[0, -1], yv[0, -1]], +] + +pts2 = [ + [xv[0, 0] + 1, yv[0, 0] + 1], + [xv[-1, -1] + 1, yv[-1, -1] + 1], + [xv[0, -1] + 1, yv[0, -1] + 1], +] +trans_obj.get_affine_matrix(pts1, pts2) + +# %% +data_trans = trans_obj.apply_affine(coord) + +plt.figure() +plt.plot(coord[0], coord[1]) +plt.plot(data_trans[0], data_trans[1]) +plt.legend(['raw', 'transformed']) +plt.show() + +# %% diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py new file mode 100644 index 00000000..ad52d920 --- /dev/null +++ b/copylot/assemblies/photom/photom.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + + +class photom: + def __init__(self, camera, laser, mirror, dac): + self.camera = camera + self.laser = laser + self.mirror = mirror + self.DAC = dac + + ## Camera Functions + def capture(self): + pass + + ## Mirror Functions + def move_mirror(self): + pass + + ## LASER Fucntions + def change_laser_power(self): + pass diff --git a/copylot/assemblies/photom/settings/affine_transform.yml b/copylot/assemblies/photom/settings/affine_transform.yml new file mode 100644 index 00000000..6145c36c --- /dev/null +++ b/copylot/assemblies/photom/settings/affine_transform.yml @@ -0,0 +1,4 @@ +affine_transform_yx: +- [1.0, 0.0, 0.0] +- [0.0, 1.0, 0.0] +- [0.0, 0.0, 1.0] \ No newline at end of file diff --git a/copylot/assemblies/photom/tests/__init__.py b/copylot/assemblies/photom/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/copylot/assemblies/photom/tests/test_photom.py b/copylot/assemblies/photom/tests/test_photom.py new file mode 100644 index 00000000..fa6e42aa --- /dev/null +++ b/copylot/assemblies/photom/tests/test_photom.py @@ -0,0 +1,25 @@ +import unittest +from unittest.mock import MagicMock +from photom import photom + + +class TestPhotom(unittest.TestCase): + def test_capture(self): + # Create mock objects for the hardware + camera = MagicMock() + laser = MagicMock() + mirror = MagicMock() + dac = MagicMock() + + # Instantiate your class with the mock objects + p = photom(camera, laser, mirror, dac) + + # Call the method you want to test + p.capture() + + # Assert that the camera's method was called + camera.capture.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/copylot/assemblies/photom/utils/__init__.py b/copylot/assemblies/photom/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/copylot/assemblies/photom/utils/affine_transform.py b/copylot/assemblies/photom/utils/affine_transform.py new file mode 100644 index 00000000..9022fecc --- /dev/null +++ b/copylot/assemblies/photom/utils/affine_transform.py @@ -0,0 +1,106 @@ +from collections.abc import Iterable +from warnings import warn + +from skimage import transform +import numpy as np +from copylot.assemblies.photom.utils.io import yaml_to_model +from copylot.assemblies.photom.utils.settings import AffineTransformationSettings + + +class AffineTransform: + """ + A class object for handling affine transformation. + """ + + def __init__(self, config_file='affine_transform.yml'): + settings = yaml_to_model(config_file, AffineTransformationSettings) + self.T_affine = np.array(settings.affine_transform_yx) + + def reset_T_affine(self): + self.T_affine = None + + def get_affine_matrix(self, origin, dest): + """ + Compute affine matrix from 2 origin & 2 destination coordinates. + :param origin: 3 sets of coordinate of origin e.g. [(x1, y1), (x2, y2), (x3, y3)] + :param dest: 3 sets of coordinate of destination e.g. [(x1, y1), (x2, y2), (x3, y3)] + :return: affine matrix + """ + if not (isinstance(origin, Iterable) and len(origin) >= 3): + raise ValueError('origin needs 3 coordinates.') + if not (isinstance(dest, Iterable) and len(dest) >= 3): + raise ValueError('dest needs 3 coordinates.') + self.T_affine = transform.estimate_transform( + 'affine', np.float32(origin), np.float32(dest) + ) + return self.T_affine + + def apply_affine(self, cord_list): + """ + Perform affine transformation. + :param cord_list: a list of origin coordinate (e.g. [[x,y], ...] or [[list for ch0], [list for ch1]]) + :return: destination coordinate + """ + cord_array = np.stack(cord_list, axis=0) + if len(cord_list) <= 2: + cord_array = cord_array.reshape(2, -1) + else: + cord_array = cord_array.T + if self.T_affine is None: + warn( + 'Affine matrix has not been determined yet. \ncord_list is returned without transformation.' + ) + dest_list = cord_array + else: + cord_array = np.vstack((cord_array, np.ones((1, cord_array.shape[1])))) + dest_list = self.T_affine @ cord_array + return [list(i) for i in dest_list] + + # will be used for non numpy & opencv version + def trans_pointwise(self, cord_list): + """ + Convert coordinate list from channel wise to point wise. + (e.g. [[1, 2, 4, .... ], [4, 2, 5, ... ] to [[1, 4], [2, 2], ...]) + :param cord_list: coordinate list in channel wise + :return: coordinate list in point wise + """ + return list(map(lambda x, y: [x, y], cord_list[0], cord_list[1])) + + # will be used for non numpy & opencv version + def trans_chwise(self, cord_list): + """ + Convert coordinate list from channel wise to point wise. + (e.g. [[1, 4], [2, 2], ...] to [[1, 2, 4, .... ], [4, 2, 5, ... ]) + :param cord_list: coordinate list in point wise + :return: coordinate list in channel wise + """ + chwise1 = list(map(lambda x: x[0], cord_list)) + chwise2 = list(map(lambda x: x[1], cord_list)) + return [chwise1, chwise2] + + def save_matrix(self, matrix=None, filename='affinematrix.txt'): + raise NotImplementedError("save_matrix not implemented") + # Save affine matrix as txt. + # :param matrix: affine matrix + # :param filename: filename + # """ + # if matrix is None: + # if self.T_affine is None: + # raise ValueError('matrix is not defined for affineTrans.') + # else: + # matrix = self.T_affine + # with open(filename, 'w') as file: + # for row in matrix: + # for element in row: + # file.write('%s\n' % element) + + # def load_matrix(self, filename='affinematrix.yml'): + # """ + # Save affine matrix as txt. + # :param filename: filename + # """ + # try: + # + # self.T_affine = + # except Exception as e: + # raise ValueError(f'Error in loading affine matrix: {e}') diff --git a/copylot/assemblies/photom/utils/io.py b/copylot/assemblies/photom/utils/io.py new file mode 100644 index 00000000..2149b21c --- /dev/null +++ b/copylot/assemblies/photom/utils/io.py @@ -0,0 +1,101 @@ +from pathlib import Path +import yaml + + +def model_to_yaml(model, yaml_path: Path) -> None: + """ + Save a model's dictionary representation to a YAML file. + + Borrowing from recOrder==0.4.0 + + Parameters + ---------- + model : object + The model object to convert to YAML. + yaml_path : Path + The path to the output YAML file. + + Raises + ------ + TypeError + If the `model` object does not have a `dict()` method. + + Notes + ----- + This function converts a model object into a dictionary representation + using the `dict()` method. It removes any fields with None values before + writing the dictionary to a YAML file. + + Examples + -------- + >>> from my_model import MyModel + >>> model = MyModel() + >>> model_to_yaml(model, 'model.yaml') + + """ + yaml_path = Path(yaml_path) + + if not hasattr(model, "dict"): + raise TypeError("The 'model' object does not have a 'dict()' method.") + + model_dict = model.dict() + + # Remove None-valued fields + clean_model_dict = { + key: value for key, value in model_dict.items() if value is not None + } + + with open(yaml_path, "w+") as f: + yaml.dump(clean_model_dict, f, default_flow_style=False, sort_keys=False) + + +def yaml_to_model(yaml_path: Path, model): + """ + Load model settings from a YAML file and create a model instance. + + Borrowing from recOrder==0.4.0 + + Parameters + ---------- + yaml_path : Path + The path to the YAML file containing the model settings. + model : class + The model class used to create an instance with the loaded settings. + + Returns + ------- + object + An instance of the model class with the loaded settings. + + Raises + ------ + TypeError + If the provided model is not a class or does not have a callable constructor. + FileNotFoundError + If the YAML file specified by `yaml_path` does not exist. + + Notes + ----- + This function loads model settings from a YAML file using `yaml.safe_load()`. + It then creates an instance of the provided `model` class using the loaded settings. + + Examples + -------- + >>> from my_model import MyModel + >>> model = yaml_to_model('model.yaml', MyModel) + + """ + yaml_path = Path(yaml_path) + + if not callable(getattr(model, "__init__", None)): + raise TypeError( + "The provided model must be a class with a callable constructor." + ) + + try: + with open(yaml_path, "r") as file: + raw_settings = yaml.safe_load(file) + except FileNotFoundError: + raise FileNotFoundError(f"The YAML file '{yaml_path}' does not exist.") + + return model(**raw_settings) diff --git a/copylot/assemblies/photom/utils/scanning_algorithms.py b/copylot/assemblies/photom/utils/scanning_algorithms.py new file mode 100644 index 00000000..4145eae7 --- /dev/null +++ b/copylot/assemblies/photom/utils/scanning_algorithms.py @@ -0,0 +1,196 @@ +import math + +""" +Borrowing from Hirofumi's photom-code + +""" + + +class ScanAlgorithm: + def __init__(self, initial_cord, size, gap, shape, sec_per_cycle): + """ + Generates lists of x & y coordinates for various shapes with different scanning curves. + :param initial_cord: the initial coordinates of x & y + :param size: the bounding box size of the shape + :param gap: the gap between two scan lines. determined by the beam size + :param shape: str; outer shape name i.e. 'square' or 'disk' + :param sec_per_cycle: the amount of seconds to scan one cycle of the entire shape; + <100 sec (= self.max_num_points / self.sampling_rate) + """ + + self.initial_x = initial_cord[0] + self.initial_y = initial_cord[1] + self.height = size[0] / 2 + self.width = size[1] / 2 + self.gap = gap + self.shape = shape + max_num_points = 1e7 # max coordinates data to be transferred to DAC; 10MB:6s, 8MB:4s, 4MB:2s, 400kB:<1s + sampling_rate = int(1e5) # max sampling rate is 100kS/s + self.num_points_cycle = int( + sec_per_cycle * sampling_rate + ) # number of points for one scan of the entire shape + self.resolution = 1 # obsoleted argument + # check if total number of points exceeds the max buffer memory + if self.num_points_cycle > max_num_points: + raise ValueError( + 'The total number of points exceeded buffer memory. \nTry shorter sec/cycle or smaller scanning area.' + ) + + def shape_coeff(self, x): + """ + Output a coefficient for y to generate the desired outer shape. + Used in generate_lissajous and generate_sin + :return: lists of x & y coordinates + """ + if self.shape.lower() in ('square', 'rect'): + return 1 + elif self.shape.lower() == 'disk': + return math.sqrt(1 - x**2) + else: + ValueError('Invalid shape.') + + def generate_cornerline(self): + """ + Generates a rigorous raster scan path but hard to scale to different shapes other than square. + Currently obsolete. + :return: lists of x and y coordinates + """ + cord_x = [self.initial_x] + cord_y = [self.initial_y] + count_y = 0 + steps_h = max(int(self.width // self.resolution), 1) + steps_v = max(int(self.gap // self.resolution), 1) + while cord_y[-1] <= self.initial_y + self.height: + cord_x += [ + cord_x[-1] + (i + 1) * self.resolution * (-1) ** (count_y % 2) + for i in range(steps_h) + ] + cord_y += [cord_y[-1] for _ in range(steps_h)] + count_y += 1 + cord_x += [cord_x[-1] for _ in range(steps_v)] + cord_y += [cord_y[-1] + (i + 1) * self.resolution for i in range(steps_v)] + return cord_x[:-1], cord_y[:-1] + + def generate_spiral(self): + """ + :return: lists of x and y coordinates of a spiral + """ + cord_x = [] + cord_y = [] + r = 0 + aspect = self.width / self.height + num_cycle = self.height / self.gap + num_points_unitround = int(self.num_points_cycle / num_cycle) + i = 0 + if self.shape.lower() == 'disk': + while r <= self.height: + x = math.cos(2 * math.pi * i / num_points_unitround) + y = math.sin(2 * math.pi * i / num_points_unitround) + cord_x.append(r * x * aspect + self.initial_x) + cord_y.append(r * y + self.initial_y) + r += self.gap / num_points_unitround + i += 1 + elif self.shape.lower() in ('square', 'rect'): + while r <= self.height: + if i % num_points_unitround < num_points_unitround / 4: + y = r * math.cos(((2 * i / num_points_unitround) // 0.5) * math.pi) + x = r * math.tan( + 2 * math.pi * i / num_points_unitround - math.pi / 4 + ) + elif ( + num_points_unitround / 4 + <= i % num_points_unitround + < num_points_unitround / 2 + ): + y = -r * math.tan( + 2 * math.pi * i / num_points_unitround + math.pi / 4 + ) + x = -r * math.cos(((2 * i / num_points_unitround) // 0.5) * math.pi) + elif ( + num_points_unitround / 2 + <= i % num_points_unitround + < 3 * num_points_unitround / 4 + ): + y = -r * math.cos(((2 * i / num_points_unitround) // 0.5) * math.pi) + x = -r * math.tan( + 2 * math.pi * i / num_points_unitround - math.pi / 4 + ) + else: + y = r * math.tan( + 2 * math.pi * i / num_points_unitround + math.pi / 4 + ) + x = r * math.cos(((2 * i / num_points_unitround) // 0.5) * math.pi) + r += self.gap / num_points_unitround + i += 1 + cord_x.append(x * aspect + self.initial_x) + cord_y.append(y + self.initial_y) + return cord_x, cord_y + + def generate_lissajous(self): + """ + :return: lists of x and y coordinates of a Lissajous curve + """ + cord_x = [] + cord_y = [] + r = self.height + a = 5 * 10 / self.gap + b = math.pi * 6 * 10 / self.gap + for i in range(self.num_points_cycle): + x = math.cos(2 * a * math.pi * i / self.num_points_cycle + (math.pi / 8)) + cord_x.append(self.width * x + self.initial_x) + cord_y.append( + self.height + * self.shape_coeff(x) + * math.sin(2 * b * math.pi * i / self.num_points_cycle) + + self.initial_y + ) + return cord_x, cord_y + + def generate_sin(self): + """ + :return: lists of x and y coordinates of a sine curve as an approximate raster scan + """ + startingy = self.initial_y - self.height + freq = self.height / self.gap + cord_x = [] + cord_y = [] + for i in range(self.num_points_cycle): + x = math.sin(2 * math.pi * freq * i / self.num_points_cycle - math.pi / 2) + cord_x.append( + self.width * self.shape_coeff(1 - 2 * i / self.num_points_cycle) * x + + self.initial_x + ) + cord_y.append(i * 2 * self.height / self.num_points_cycle + startingy) + return cord_x, cord_y + + def generate_rect(self): + """ + :return: lists of x and y coordinates of a rectangular + """ + cord_x = [] + cord_y = [] + num_cycle = self.height * 2 / self.gap + num_points_unitround = int(self.num_points_cycle / num_cycle) + x = self.initial_x - self.width + y = self.initial_y - self.height + num_points_side = num_points_unitround / 4 + for i in range(num_points_unitround): + if i % num_points_unitround < num_points_side: + x += self.width * 2 / num_points_side + elif ( + num_points_unitround / 4 + <= i % num_points_unitround + < num_points_unitround / 2 + ): + y += self.height * 2 / num_points_side + elif ( + num_points_unitround / 2 + <= i % num_points_unitround + < 3 * num_points_unitround / 4 + ): + x -= self.width * 2 / num_points_side + else: + y -= self.height * 2 / num_points_side + cord_x.append(x) + cord_y.append(y) + return cord_x, cord_y diff --git a/copylot/assemblies/photom/utils/settings.py b/copylot/assemblies/photom/utils/settings.py new file mode 100644 index 00000000..336d991c --- /dev/null +++ b/copylot/assemblies/photom/utils/settings.py @@ -0,0 +1,21 @@ +import os +from typing import List, Literal, Optional, Union + +from pydantic import ( + BaseModel, + Extra, + NonNegativeFloat, + NonNegativeInt, + PositiveFloat, + root_validator, + validator, +) + + +# All settings classes inherit from MyBaseModel, which forbids extra parameters to guard against typos +class MyBaseModel(BaseModel, extra=Extra.forbid): + pass + + +class AffineTransformationSettings(MyBaseModel): + affine_transform_yx: list[list[float]] diff --git a/copylot/assemblies/photom/utils/tests/__init__.py b/copylot/assemblies/photom/utils/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/copylot/assemblies/photom/utils/tests/test_scanning_algorithms.py b/copylot/assemblies/photom/utils/tests/test_scanning_algorithms.py new file mode 100644 index 00000000..3e0cf40a --- /dev/null +++ b/copylot/assemblies/photom/utils/tests/test_scanning_algorithms.py @@ -0,0 +1,24 @@ +import sys +import os + +sys.path.append(os.path.join(os.pardir, os.pardir, os.pardir)) +import matplotlib.pyplot as plt +from copylot.assemblies.photom.utils.scanning_algorithms import ScanAlgorithm + +initial_coord = (0, 0) +size = (100, 20) +gap = size[1] +shape = 'disk' # 'disk' # +sec_per_cycle = 1 +sg = ScanAlgorithm(initial_coord, size, gap, shape, sec_per_cycle) + +# coord = sg.generate_cornerline() +# coord = sg.generate_lissajous() +# coord = sg.generate_sin() +coord = sg.generate_spiral() +# coord = sg.generate_rect() + +plt.figure(figsize=(5, 5)) +plt.plot(coord[0], coord[1]) + +# %% From b06911a737ece69530e6d6b7d75bfedf78672b30 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Sat, 4 Nov 2023 23:12:35 -0700 Subject: [PATCH 02/60] saving function using the model --- .../photom/demo/demo_affine_scan.py | 3 +- .../photom/utils/affine_transform.py | 67 +++++++++++-------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_affine_scan.py b/copylot/assemblies/photom/demo/demo_affine_scan.py index 0d80e2db..a7fb3944 100644 --- a/copylot/assemblies/photom/demo/demo_affine_scan.py +++ b/copylot/assemblies/photom/demo/demo_affine_scan.py @@ -50,7 +50,8 @@ [xv[0, -1] + 1, yv[0, -1] + 1], ] trans_obj.get_affine_matrix(pts1, pts2) - +# %% +trans_obj.save_matrix(config_file='./test.yml') # %% data_trans = trans_obj.apply_affine(coord) diff --git a/copylot/assemblies/photom/utils/affine_transform.py b/copylot/assemblies/photom/utils/affine_transform.py index 9022fecc..a5af85b6 100644 --- a/copylot/assemblies/photom/utils/affine_transform.py +++ b/copylot/assemblies/photom/utils/affine_transform.py @@ -3,8 +3,9 @@ from skimage import transform import numpy as np -from copylot.assemblies.photom.utils.io import yaml_to_model +from copylot.assemblies.photom.utils.io import yaml_to_model, model_to_yaml from copylot.assemblies.photom.utils.settings import AffineTransformationSettings +from pathlib import Path class AffineTransform: @@ -13,11 +14,15 @@ class AffineTransform: """ def __init__(self, config_file='affine_transform.yml'): - settings = yaml_to_model(config_file, AffineTransformationSettings) + self.config_file = config_file + settings = yaml_to_model(self.config_file, AffineTransformationSettings) self.T_affine = np.array(settings.affine_transform_yx) def reset_T_affine(self): - self.T_affine = None + """ + Reset affine matrix to identity matrix. + """ + self.T_affine = np.eye(3) def get_affine_matrix(self, origin, dest): """ @@ -78,29 +83,35 @@ def trans_chwise(self, cord_list): chwise2 = list(map(lambda x: x[1], cord_list)) return [chwise1, chwise2] - def save_matrix(self, matrix=None, filename='affinematrix.txt'): - raise NotImplementedError("save_matrix not implemented") - # Save affine matrix as txt. - # :param matrix: affine matrix - # :param filename: filename - # """ - # if matrix is None: - # if self.T_affine is None: - # raise ValueError('matrix is not defined for affineTrans.') - # else: - # matrix = self.T_affine - # with open(filename, 'w') as file: - # for row in matrix: - # for element in row: - # file.write('%s\n' % element) + def save_matrix(self, matrix: np.array = None, config_file: Path = None) -> None: + """ + Save affine matrix to a YAML file. + + Parameters + ---------- + matrix :np.array, optional + 3x3 affine_transformation, by default None will save the current matrix + config_file : str, optional + path to the YAML file, by default None will save to the current config_file. + This updates the config_file attribute. + Raises + ------ + ValueError + matrix is not defined + """ - # def load_matrix(self, filename='affinematrix.yml'): - # """ - # Save affine matrix as txt. - # :param filename: filename - # """ - # try: - # - # self.T_affine = - # except Exception as e: - # raise ValueError(f'Error in loading affine matrix: {e}') + if matrix is None: + if self.T_affine is None: + raise ValueError('provided matrix is not defined') + else: + matrix = self.T_affine + else: + assert matrix.shape == (3, 3) + + if config_file is not None: + self.config_file = config_file + + model = AffineTransformationSettings( + affine_transform_yx=np.array(matrix).tolist(), + ) + model_to_yaml(model, self.config_file) From 022beb8c4db86f032f4eee3c591a4beee5b8d1e4 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Tue, 7 Nov 2023 09:55:46 -0800 Subject: [PATCH 03/60] demo moving window and reading yml to make gui --- copylot/assemblies/photom/demo/config.yml | 5 + .../assemblies/photom/demo/demo_yml_gui.py | 224 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 copylot/assemblies/photom/demo/config.yml create mode 100644 copylot/assemblies/photom/demo/demo_yml_gui.py diff --git a/copylot/assemblies/photom/demo/config.yml b/copylot/assemblies/photom/demo/config.yml new file mode 100644 index 00000000..f62f04f6 --- /dev/null +++ b/copylot/assemblies/photom/demo/config.yml @@ -0,0 +1,5 @@ +lasers: + - name: laser_1 + power: 50 + - name: laser_2 + power: 30 \ No newline at end of file diff --git a/copylot/assemblies/photom/demo/demo_yml_gui.py b/copylot/assemblies/photom/demo/demo_yml_gui.py new file mode 100644 index 00000000..aa9410a1 --- /dev/null +++ b/copylot/assemblies/photom/demo/demo_yml_gui.py @@ -0,0 +1,224 @@ +import sys +import yaml +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QApplication, + QMainWindow, + QWidget, + QPushButton, + QLabel, + QSlider, + QVBoxLayout, + QGraphicsView, + QGroupBox, + QGraphicsScene, + QGraphicsSimpleTextItem, + QGraphicsItem, +) +from PyQt5.QtGui import QColor, QPen + + +class Laser: + def __init__(self, name, power=0): + self.name = name + self.power = power + self.laser_on = False + + def toggle(self): + self.laser_on = not self.laser_on + + def set_power(self, power): + self.power = power + + +class Mirror: + def __init__(self, initial_x=0, initial_y=0): + self.x = initial_x + self.y = initial_y + + +class LaserWidget(QWidget): + def __init__(self, laser): + super().__init__() + self.laser = laser + self.initUI() + + def initUI(self): + layout = QVBoxLayout() + + laser_label = QLabel(self.laser.name) + layout.addWidget(laser_label) + + laser_power_slider = QSlider(Qt.Horizontal) + laser_power_slider.setMinimum(0) + laser_power_slider.setMaximum(100) + laser_power_slider.setValue(self.laser.power) + laser_power_slider.valueChanged.connect(self.update_power) + layout.addWidget(laser_power_slider) + + laser_toggle_button = QPushButton('Toggle') + laser_toggle_button.clicked.connect(self.toggle_laser) + layout.addWidget(laser_toggle_button) + + self.setLayout(layout) + + def toggle_laser(self): + self.laser.toggle() + + def update_power(self, value): + self.laser.set_power(value) + + +class MirrorWidget(QWidget): + def __init__(self, mirror): + super().__init__() + self.mirror = mirror + self.initUI() + + def initUI(self): + layout = QVBoxLayout() + + mirror_x_label = QLabel('Mirror X Position') + layout.addWidget(mirror_x_label) + + self.mirror_x_slider = QSlider(Qt.Horizontal) + self.mirror_x_slider.setMinimum(0) + self.mirror_x_slider.setMaximum(100) + self.mirror_x_slider.valueChanged.connect(self.update_mirror_x) + layout.addWidget(self.mirror_x_slider) + + mirror_y_label = QLabel('Mirror Y Position') + layout.addWidget(mirror_y_label) + + self.mirror_y_slider = QSlider(Qt.Horizontal) + self.mirror_y_slider.setMinimum(0) + self.mirror_y_slider.setMaximum(100) + self.mirror_y_slider.valueChanged.connect(self.update_mirror_y) + layout.addWidget(self.mirror_y_slider) + + self.setLayout(layout) + + def update_mirror_x(self, value): + self.mirror.x = value + + def update_mirror_y(self, value): + self.mirror.y = value + + +class LaserApp(QMainWindow): + def __init__(self, lasers, mirror): + super().__init__() + self.lasers = lasers + self.mirror = mirror + self.initUI() + + def initUI(self): + self.setGeometry(100, 100, 400, 500) + self.setWindowTitle('Laser and Mirror Control App') + + laser_group = QGroupBox('Lasers') + laser_layout = QVBoxLayout() + for laser in self.lasers: + laser_widget = LaserWidget(laser) + laser_layout.addWidget(laser_widget) + laser_group.setLayout(laser_layout) + + mirror_group = QGroupBox('Mirror') + mirror_layout = QVBoxLayout() + mirror_widget = MirrorWidget(self.mirror) + mirror_layout.addWidget(mirror_widget) + mirror_group.setLayout(mirror_layout) + + main_layout = QVBoxLayout() + main_layout.addWidget(laser_group) + main_layout.addWidget(mirror_group) + + self.calibrate_button = QPushButton('Calibrate') + self.calibrate_button.clicked.connect(self.calibrate) + main_layout.addWidget(self.calibrate_button) + + main_widget = QWidget(self) + main_widget.setLayout(main_layout) + + self.setCentralWidget(main_widget) + self.show() + + def calibrate(self): + # Implement your calibration function here + print("Calibration function executed") + + +class LaserMarkerWindow(QMainWindow): + def __init__(self): + super().__init__() + self.windowGeo = (300, 300, 1000, 1000) + self.setMouseTracking(True) + self.mouseX = None + self.mouseY = None + self.board_num = 0 + self.setWindowOpacity(0.7) + self.scale = 0.025 + self.offset = (-0.032000, -0.046200) + + self.initMarker() + self.initUI() + + def initUI(self): + self.setGeometry( + self.windowGeo[0], + self.windowGeo[1], + self.windowGeo[2], + self.windowGeo[3], + ) + self.setWindowTitle('Mouse Tracker') + # self.setFixedSize( + # self.windowGeo[2], + # self.windowGeo[3], + # ) + self.show() + + def initMarker(self): + scene = QGraphicsScene(self) + view = QGraphicsView(scene) + view.setMouseTracking(True) + self.setCentralWidget(view) + self.setMouseTracking(True) + self.marker = QGraphicsSimpleTextItem('X') + self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) + scene.addItem(self.marker) + + def recordinate(self, rawcord): + return -self.scale * (rawcord - (self.windowGeo[2] / 2)) / 50 + + def mouseMoveEvent(self, event: 'QGraphicsSceneMouseEvent'): + new_cursor_position = event.screenPos() + + print(f'current x: {new_cursor_position}') + + def mousePressEvent(self, event): + marker_x = self.marker.pos().x() + marker_y = self.marker.pos().y() + print(f'x position: {(marker_x, marker_y)}') + # print('Mouse press coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + + def mouseReleaseEvent(self, event): + print('Mouse release coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + + +if __name__ == '__main__': + import os + + os.environ["DISPLAY"] = ":1005" + config_path = ( + "/home/eduardo.hirata/repos/coPylot/copylot/assemblies/photom/demo/config.yml" + ) + with open(config_path, 'r') as config_file: + config = yaml.load(config_file, Loader=yaml.FullLoader) + lasers = [Laser(**laser_data) for laser_data in config['lasers']] + mirror = Mirror(initial_x=0, initial_y=0) # Initial mirror position + + app = QApplication(sys.argv) + ctrl_window = LaserApp(lasers, mirror) + photom_window = LaserMarkerWindow() + # ctrl_window.show() + sys.exit(app.exec_()) From 384f3c81a8627bb0bff9c125d1f8eeec75101a69 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Tue, 7 Nov 2023 10:16:01 -0800 Subject: [PATCH 04/60] adding transparency and label power values --- .../assemblies/photom/demo/demo_yml_gui.py | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_yml_gui.py b/copylot/assemblies/photom/demo/demo_yml_gui.py index aa9410a1..4c21a22a 100644 --- a/copylot/assemblies/photom/demo/demo_yml_gui.py +++ b/copylot/assemblies/photom/demo/demo_yml_gui.py @@ -56,6 +56,10 @@ def initUI(self): laser_power_slider.valueChanged.connect(self.update_power) layout.addWidget(laser_power_slider) + # Add a QLabel to display the power value + self.power_label = QLabel(f'Power: {self.laser.power}') + layout.addWidget(self.power_label) + laser_toggle_button = QPushButton('Toggle') laser_toggle_button.clicked.connect(self.toggle_laser) layout.addWidget(laser_toggle_button) @@ -67,6 +71,8 @@ def toggle_laser(self): def update_power(self, value): self.laser.set_power(value) + # Update the QLabel with the new power value + self.power_label.setText(f'Power: {value}') class MirrorWidget(QWidget): @@ -87,6 +93,10 @@ def initUI(self): self.mirror_x_slider.valueChanged.connect(self.update_mirror_x) layout.addWidget(self.mirror_x_slider) + # Add a QLabel to display the mirror X value + self.mirror_x_label = QLabel(f'X: {self.mirror.x}') + layout.addWidget(self.mirror_x_label) + mirror_y_label = QLabel('Mirror Y Position') layout.addWidget(mirror_y_label) @@ -96,18 +106,27 @@ def initUI(self): self.mirror_y_slider.valueChanged.connect(self.update_mirror_y) layout.addWidget(self.mirror_y_slider) + # Add a QLabel to display the mirror Y value + self.mirror_y_label = QLabel(f'Y: {self.mirror.y}') + layout.addWidget(self.mirror_y_label) + self.setLayout(layout) def update_mirror_x(self, value): self.mirror.x = value + # Update the QLabel with the new X value + self.mirror_x_label.setText(f'X: {value}') def update_mirror_y(self, value): self.mirror.y = value + # Update the QLabel with the new Y value + self.mirror_y_label.setText(f'Y: {value}') class LaserApp(QMainWindow): - def __init__(self, lasers, mirror): + def __init__(self, lasers, mirror, photom_window): super().__init__() + self.photom_window = photom_window self.lasers = lasers self.mirror = mirror self.initUI() @@ -116,6 +135,23 @@ def initUI(self): self.setGeometry(100, 100, 400, 500) self.setWindowTitle('Laser and Mirror Control App') + # Adding slider to adjust transparency + transparency_group = QGroupBox('Photom Transparency') + transparency_layout = QVBoxLayout() + # Create a slider to adjust the transparency + self.transparency_slider = QSlider(Qt.Horizontal) + self.transparency_slider.setMinimum(0) + self.transparency_slider.setMaximum(100) + self.transparency_slider.setValue(100) # Initial value is fully opaque + self.transparency_slider.valueChanged.connect(self.update_transparency) + transparency_layout.addWidget(self.transparency_slider) + + # Add a QLabel to display the current percent transparency value + self.transparency_label = QLabel(f'Transparency: 100%') + transparency_layout.addWidget(self.transparency_label) + + transparency_group.setLayout(transparency_layout) + # Adding a group box for the lasers laser_group = QGroupBox('Lasers') laser_layout = QVBoxLayout() for laser in self.lasers: @@ -129,10 +165,13 @@ def initUI(self): mirror_layout.addWidget(mirror_widget) mirror_group.setLayout(mirror_layout) + # Add the laser and mirror group boxes to the main layout main_layout = QVBoxLayout() + main_layout.addWidget(transparency_group) main_layout.addWidget(laser_group) main_layout.addWidget(mirror_group) + # Add a button to calibrate the mirror self.calibrate_button = QPushButton('Calibrate') self.calibrate_button.clicked.connect(self.calibrate) main_layout.addWidget(self.calibrate_button) @@ -147,6 +186,12 @@ def calibrate(self): # Implement your calibration function here print("Calibration function executed") + def update_transparency(self, value): + transparency_percent = value + self.transparency_label.setText(f'Transparency: {transparency_percent}%') + opacity = 1.0 - (transparency_percent / 100.0) # Calculate opacity (0.0 to 1.0) + self.photom_window.setWindowOpacity(opacity) # Update photom_window opacity + class LaserMarkerWindow(QMainWindow): def __init__(self): @@ -218,7 +263,25 @@ def mouseReleaseEvent(self, event): mirror = Mirror(initial_x=0, initial_y=0) # Initial mirror position app = QApplication(sys.argv) - ctrl_window = LaserApp(lasers, mirror) + + # Define the positions and sizes for the windows + screen_width = app.desktop().screenGeometry().width() + screen_height = app.desktop().screenGeometry().height() + + ctrl_window_width = screen_width // 3 # Adjust the width as needed + ctrl_window_height = screen_height // 3 # Use the full screen height + + # Making the photom_window a square + photom_window_width = screen_width // 3 # Adjust the width as needed + photom_window_height = screen_width // 3 # Adjust the width as needed + photom_window = LaserMarkerWindow() - # ctrl_window.show() + photom_window.setGeometry( + ctrl_window_width, 0, photom_window_width, photom_window_height + ) + + # Set the positions of the windows + ctrl_window = LaserApp(lasers, mirror, photom_window) + ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_height) + sys.exit(app.exec_()) From b1357c423a20b604af537d64c11adc68c4690506 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Tue, 7 Nov 2023 10:43:07 -0800 Subject: [PATCH 05/60] making mirror widget be based on the number of mirror objects in config --- copylot/assemblies/photom/demo/config.yml | 18 +++++++++++++- .../assemblies/photom/demo/demo_yml_gui.py | 24 ++++++++++++------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/copylot/assemblies/photom/demo/config.yml b/copylot/assemblies/photom/demo/config.yml index f62f04f6..df27eb8d 100644 --- a/copylot/assemblies/photom/demo/config.yml +++ b/copylot/assemblies/photom/demo/config.yml @@ -2,4 +2,20 @@ lasers: - name: laser_1 power: 50 - name: laser_2 - power: 30 \ No newline at end of file + power: 30 + +mirrors: + - name: mirror_1_VIS + x_position: 0 + y_position: 0 + affine_matrix: + - [1, 0, 0] + - [0, 1, 0] + - [0, 0, 1] + - name: mirror_2_IR + x_position: 0 + y_position: 0 + affine_matrix: + - [1, 0, 0] + - [0, 1, 0] + - [0, 0, 1] diff --git a/copylot/assemblies/photom/demo/demo_yml_gui.py b/copylot/assemblies/photom/demo/demo_yml_gui.py index 4c21a22a..48ebbd96 100644 --- a/copylot/assemblies/photom/demo/demo_yml_gui.py +++ b/copylot/assemblies/photom/demo/demo_yml_gui.py @@ -32,9 +32,9 @@ def set_power(self, power): class Mirror: - def __init__(self, initial_x=0, initial_y=0): - self.x = initial_x - self.y = initial_y + def __init__(self, x_position=0, y_position=0): + self.x = x_position + self.y = y_position class LaserWidget(QWidget): @@ -149,8 +149,8 @@ def initUI(self): # Add a QLabel to display the current percent transparency value self.transparency_label = QLabel(f'Transparency: 100%') transparency_layout.addWidget(self.transparency_label) - transparency_group.setLayout(transparency_layout) + # Adding a group box for the lasers laser_group = QGroupBox('Lasers') laser_layout = QVBoxLayout() @@ -159,10 +159,12 @@ def initUI(self): laser_layout.addWidget(laser_widget) laser_group.setLayout(laser_layout) + # Adding a group box for the mirror mirror_group = QGroupBox('Mirror') mirror_layout = QVBoxLayout() - mirror_widget = MirrorWidget(self.mirror) - mirror_layout.addWidget(mirror_widget) + for mirror in self.mirror: + mirror_widget = MirrorWidget(mirror) + mirror_layout.addWidget(mirror_widget) mirror_group.setLayout(mirror_layout) # Add the laser and mirror group boxes to the main layout @@ -249,7 +251,7 @@ def mousePressEvent(self, event): def mouseReleaseEvent(self, event): print('Mouse release coords: ( %f : %f )' % (self.mouseX, self.mouseY)) - +class Laser if __name__ == '__main__': import os @@ -260,7 +262,13 @@ def mouseReleaseEvent(self, event): with open(config_path, 'r') as config_file: config = yaml.load(config_file, Loader=yaml.FullLoader) lasers = [Laser(**laser_data) for laser_data in config['lasers']] - mirror = Mirror(initial_x=0, initial_y=0) # Initial mirror position + mirror = [ + Mirror( + x_position=mirror_data['x_position'], + y_position=mirror_data['y_position'], + ) + for mirror_data in config['mirrors'] + ] # Initial mirror position app = QApplication(sys.argv) From d9e1d5ebf81c28c4a7132ad9fefa332a126269ff Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Tue, 7 Nov 2023 16:13:57 -0800 Subject: [PATCH 06/60] switch from calibration to shooting mode --- .../photom/demo/demo_draw_tetragon.py | 59 ++++++++ .../photom/demo/demo_drawing_windows.py | 134 ++++++++++++++++++ .../assemblies/photom/demo/demo_yml_gui.py | 107 ++++++++++++-- 3 files changed, 292 insertions(+), 8 deletions(-) create mode 100644 copylot/assemblies/photom/demo/demo_draw_tetragon.py create mode 100644 copylot/assemblies/photom/demo/demo_drawing_windows.py diff --git a/copylot/assemblies/photom/demo/demo_draw_tetragon.py b/copylot/assemblies/photom/demo/demo_draw_tetragon.py new file mode 100644 index 00000000..57a6b217 --- /dev/null +++ b/copylot/assemblies/photom/demo/demo_draw_tetragon.py @@ -0,0 +1,59 @@ +import sys +from PyQt5.QtWidgets import ( + QApplication, + QGraphicsView, + QGraphicsScene, + QGraphicsEllipseItem, + QMainWindow, +) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPolygonF, QPen + + +class TetragonEditor(QMainWindow): + def __init__(self): + super().__init__() + + self.setWindowTitle("Tetragon Editor") + self.setGeometry(100, 100, 800, 600) + + # Create a QGraphicsView to display the scene + self.view = QGraphicsView(self) + self.view.setGeometry(10, 10, 780, 580) + + # Create a QGraphicsScene + self.scene = QGraphicsScene() + self.view.setScene(self.scene) + + # Create initial vertices for the tetragon + self.vertices = [] + for x, y in [(100, 100), (200, 100), (200, 200), (100, 200)]: + vertex = QGraphicsEllipseItem(x - 5, y - 5, 10, 10) + vertex.setBrush(Qt.red) + vertex.setFlag(QGraphicsEllipseItem.ItemIsMovable) + self.vertices.append(vertex) + self.scene.addItem(vertex) + + def getCoordinates(self): + return [vertex.pos() for vertex in self.vertices] + + def resizeEvent(self, event): + # Resize the view when the main window is resized + self.view.setGeometry(10, 10, self.width() - 20, self.height() - 20) + + def updateVertices(self, new_coordinates): + for vertex, (x, y) in zip(self.vertices, new_coordinates): + vertex.setPos(x, y) + + +if __name__ == '__main__': + import os + + os.environ["DISPLAY"] = ":1005" + app = QApplication(sys.argv) + window = TetragonEditor() + # print(window.getCoordinates()) + # window.updateVertices([(10, 10), (300, 200), (0, 300), (200, 300)]) + # print(window.getCoordinates()) + window.show() + sys.exit(app.exec_()) diff --git a/copylot/assemblies/photom/demo/demo_drawing_windows.py b/copylot/assemblies/photom/demo/demo_drawing_windows.py new file mode 100644 index 00000000..5917445d --- /dev/null +++ b/copylot/assemblies/photom/demo/demo_drawing_windows.py @@ -0,0 +1,134 @@ +import sys + +from PyQt5.QtWidgets import ( + QApplication, + QMainWindow, + QGraphicsScene, + QGraphicsSimpleTextItem, + QGraphicsItem, + QGraphicsView, + QPushButton, + QTabWidget, + QWidget, + QVBoxLayout, + QDesktopWidget, +) + +from PyQt5.QtCore import Qt, QPointF +from PyQt5.QtGui import QPainter, QPen, QMouseEvent, QCursor +from copylot import logger + + +class CustomWindow(QMainWindow): + def __init__(self): + super().__init__() + self.windowGeo = (300, 300, 1000, 1000) + self.setMouseTracking(True) + self.mouseX = None + self.mouseY = None + self.board_num = 0 + self.setWindowOpacity(0.7) + self.scale = 0.025 + self.offset = (-0.032000, -0.046200) + + self.initMarker() + self.initUI() + + def initUI(self): + self.setGeometry( + self.windowGeo[0], + self.windowGeo[1], + self.windowGeo[2], + self.windowGeo[3], + ) + self.setWindowTitle('Mouse Tracker') + # self.setFixedSize( + # self.windowGeo[2], + # self.windowGeo[3], + # ) + self.show() + + def initMarker(self): + scene = QGraphicsScene(self) + view = QGraphicsView(scene) + view.setMouseTracking(True) + self.setCentralWidget(view) + self.setMouseTracking(True) + self.marker = QGraphicsSimpleTextItem('X') + self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) + scene.addItem(self.marker) + + def recordinate(self, rawcord): + return -self.scale * (rawcord - (self.windowGeo[2] / 2)) / 50 + + def mouseMoveEvent(self, event: 'QGraphicsSceneMouseEvent'): + new_cursor_position = event.screenPos() + + print(f'current x: {new_cursor_position}') + + def mousePressEvent(self, event): + marker_x = self.marker.pos().x() + marker_y = self.marker.pos().y() + print(f'x position: {(marker_x, marker_y)}') + print('Mouse press coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + + def mouseReleaseEvent(self, event): + print('Mouse release coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + + +class CtrlWindow(QMainWindow): + def __init__(self): + super().__init__() + # Get the number of screens and make the live view + num_screens = QDesktopWidget().screenCount() + logger.debug(f'num screens {num_screens}') + + self.windowGeo = (1300, 300, 500, 1000) + self.buttonSize = (200, 100) + self.initUI() + + def initUI(self): + self.layout = QVBoxLayout(self) + self.setGeometry( + self.windowGeo[0], + self.windowGeo[1], + self.windowGeo[2], + self.windowGeo[3], + ) + self.setWindowTitle('Control panel') + self.setFixedSize( + self.windowGeo[2], + self.windowGeo[3], + ) + + self.button = QPushButton("OK", self) + self.button.setStyleSheet( + "QPushButton{" + "font-size: 24px;" + "font-family: Helvetica;" + "border-width: 2px;" + "border-radius: 15px;" + "border-color: black;" + "border-style: outset;" + "}\n" + "QPushButton:pressed {background-color: gray;}" + ) + self.button.move(250, 250) # button location + self.button.setGeometry( + (self.windowGeo[2] - self.buttonSize[0]) // 2, + 100, + self.buttonSize[0], + self.buttonSize[1], + ) + + self.show() + + +if __name__ == "__main__": + import os + + os.environ["DISPLAY"] = ":1005" + app = QApplication(sys.argv) + dac = CustomWindow() + ctrl = CtrlWindow() + sys.exit(app.exec_()) diff --git a/copylot/assemblies/photom/demo/demo_yml_gui.py b/copylot/assemblies/photom/demo/demo_yml_gui.py index 48ebbd96..124bb6f9 100644 --- a/copylot/assemblies/photom/demo/demo_yml_gui.py +++ b/copylot/assemblies/photom/demo/demo_yml_gui.py @@ -14,6 +14,8 @@ QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsItem, + QGraphicsEllipseItem, + QStackedWidget, ) from PyQt5.QtGui import QColor, QPen @@ -178,6 +180,12 @@ def initUI(self): self.calibrate_button.clicked.connect(self.calibrate) main_layout.addWidget(self.calibrate_button) + # Add a "Done Calibration" button (initially hidden) + self.done_calibration_button = QPushButton('Done Calibration') + self.done_calibration_button.clicked.connect(self.done_calibration) + self.done_calibration_button.hide() + main_layout.addWidget(self.done_calibration_button) + main_widget = QWidget(self) main_widget.setLayout(main_layout) @@ -187,6 +195,20 @@ def initUI(self): def calibrate(self): # Implement your calibration function here print("Calibration function executed") + # Hide the 'X' marker in photom_window + # self.photom_window.marker.hide() + self.display_rectangle() + # Show the "Done Calibration" button + self.done_calibration_button.show() + + def done_calibration(self): + # Perform any necessary actions after calibration is done + print("Calibration done") + self.photom_window.switch_to_shooting_scene() + self.photom_window.marker.show() + + # Hide the "Done Calibration" button + self.done_calibration_button.hide() def update_transparency(self, value): transparency_percent = value @@ -194,6 +216,32 @@ def update_transparency(self, value): opacity = 1.0 - (transparency_percent / 100.0) # Calculate opacity (0.0 to 1.0) self.photom_window.setWindowOpacity(opacity) # Update photom_window opacity + def calculate_rectangle_corners(self, window_size): + # window_size is a tuple of (width, height) + + # Calculate the coordinates of the rectangle corners + x0y0 = ( + -window_size[0] / 2, + -window_size[1] / 2, + ) + x1y0 = (x0y0[0] + window_size[0], x0y0[1]) + x1y1 = (x0y0[0] + window_size[0], x0y0[1] + window_size[1]) + x0y1 = (x0y0[0], x0y0[1] + window_size[1]) + return x0y0, x1y0, x1y1, x0y1 + + def display_rectangle(self): + # Calculate the coordinates of the rectangle corners + rectangle_scaling = 0.5 + window_size = (self.photom_window.width(), self.photom_window.height()) + rectangle_size = ( + (window_size[0] * rectangle_scaling), + (window_size[1] * rectangle_scaling), + ) + rectangle_coords = self.calculate_rectangle_corners(rectangle_size) + print(rectangle_coords) + self.photom_window.updateVertices(rectangle_coords) + self.photom_window.switch_to_calibration_scene() + class LaserMarkerWindow(QMainWindow): def __init__(self): @@ -202,14 +250,21 @@ def __init__(self): self.setMouseTracking(True) self.mouseX = None self.mouseY = None - self.board_num = 0 self.setWindowOpacity(0.7) self.scale = 0.025 - self.offset = (-0.032000, -0.046200) + # self.offset = (-0.032000, -0.046200) + + # Create a QStackedWidget + self.stacked_widget = QStackedWidget() + # Set the QStackedWidget as the central widget self.initMarker() + self.init_tetragon() + self.initUI() + self.setCentralWidget(self.stacked_widget) + def initUI(self): self.setGeometry( self.windowGeo[0], @@ -222,17 +277,53 @@ def initUI(self): # self.windowGeo[2], # self.windowGeo[3], # ) + self.switch_to_shooting_scene() self.show() def initMarker(self): - scene = QGraphicsScene(self) - view = QGraphicsView(scene) - view.setMouseTracking(True) - self.setCentralWidget(view) + self.shooting_scene = QGraphicsScene(self) + self.shooting_view = QGraphicsView(self.shooting_scene) + self.shooting_view.setMouseTracking(True) + self.setCentralWidget(self.shooting_view) self.setMouseTracking(True) self.marker = QGraphicsSimpleTextItem('X') self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) - scene.addItem(self.marker) + self.shooting_scene.addItem(self.marker) + + # Add the view to the QStackedWidget + self.stacked_widget.addWidget(self.shooting_view) + + def init_tetragon( + self, tetragon_coords: list = [(100, 100), (200, 100), (200, 200), (100, 200)] + ): + self.calibration_scene = QGraphicsScene(self) + self.calibration_view = QGraphicsView(self.calibration_scene) + self.calibration_view.setMouseTracking(True) + self.setCentralWidget(self.calibration_view) + self.setMouseTracking(True) + self.vertices = [] + for x, y in tetragon_coords: + vertex = QGraphicsEllipseItem(x - 5, y - 5, 10, 10) + vertex.setBrush(Qt.red) + vertex.setFlag(QGraphicsEllipseItem.ItemIsMovable) + self.vertices.append(vertex) + self.calibration_scene.addItem(vertex) + + # Add the view to the QStackedWidget + self.stacked_widget.addWidget(self.calibration_view) + + def switch_to_shooting_scene(self): + self.stacked_widget.setCurrentWidget(self.shooting_view) + + def switch_to_calibration_scene(self): + self.stacked_widget.setCurrentWidget(self.calibration_view) + + def getCoordinates(self): + return [vertex.pos() for vertex in self.vertices] + + def updateVertices(self, new_coordinates): + for vertex, (x, y) in zip(self.vertices, new_coordinates): + vertex.setPos(x, y) def recordinate(self, rawcord): return -self.scale * (rawcord - (self.windowGeo[2] / 2)) / 50 @@ -251,7 +342,7 @@ def mousePressEvent(self, event): def mouseReleaseEvent(self, event): print('Mouse release coords: ( %f : %f )' % (self.mouseX, self.mouseY)) -class Laser + if __name__ == '__main__': import os From 3c7b185334b0b9cad69587f36fe5ce063c6930c7 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Tue, 7 Nov 2023 16:53:48 -0800 Subject: [PATCH 07/60] add mock laser positions --- .../assemblies/photom/demo/demo_yml_gui.py | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_yml_gui.py b/copylot/assemblies/photom/demo/demo_yml_gui.py index 124bb6f9..62d1b833 100644 --- a/copylot/assemblies/photom/demo/demo_yml_gui.py +++ b/copylot/assemblies/photom/demo/demo_yml_gui.py @@ -19,6 +19,8 @@ ) from PyQt5.QtGui import QColor, QPen +DEMO_MODE = True + class Laser: def __init__(self, name, power=0): @@ -244,9 +246,10 @@ def display_rectangle(self): class LaserMarkerWindow(QMainWindow): - def __init__(self): + def __init__(self, name='Laser Marker', window_size=(100, 100, 100, 100)): super().__init__() - self.windowGeo = (300, 300, 1000, 1000) + self.window_name = name + self.windowGeo = window_size self.setMouseTracking(True) self.mouseX = None self.mouseY = None @@ -272,11 +275,13 @@ def initUI(self): self.windowGeo[2], self.windowGeo[3], ) - self.setWindowTitle('Mouse Tracker') - # self.setFixedSize( - # self.windowGeo[2], - # self.windowGeo[3], - # ) + self.setWindowTitle(self.window_name) + + # Fix the size of the window + self.setFixedSize( + self.windowGeo[2], + self.windowGeo[3], + ) self.switch_to_shooting_scene() self.show() @@ -374,13 +379,32 @@ def mouseReleaseEvent(self, event): photom_window_width = screen_width // 3 # Adjust the width as needed photom_window_height = screen_width // 3 # Adjust the width as needed - photom_window = LaserMarkerWindow() - photom_window.setGeometry( - ctrl_window_width, 0, photom_window_width, photom_window_height + photom_window_size = ( + ctrl_window_width, + 0, + photom_window_width, + photom_window_height, ) + photom_window = LaserMarkerWindow(window_size=photom_window_size) # Set the positions of the windows ctrl_window = LaserApp(lasers, mirror, photom_window) ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_height) + if DEMO_MODE: + camera_window = LaserMarkerWindow( + name='Mock laser dots', window_size=photom_window_size + ) + camera_window.switch_to_calibration_scene() + rectangle_scaling = 0.5 + window_size = (camera_window.width(), camera_window.height()) + rectangle_size = ( + (window_size[0] * rectangle_scaling), + (window_size[1] * rectangle_scaling), + ) + rectangle_coords = ctrl_window.calculate_rectangle_corners(rectangle_size) + # translate each coordinate by the offset + rectangle_coords = [(x + 30, y) for x, y in rectangle_coords] + camera_window.updateVertices(rectangle_coords) + sys.exit(app.exec_()) From 57f83fa90c7861fb006345a18893e70756eceecf Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Tue, 7 Nov 2023 18:18:06 -0800 Subject: [PATCH 08/60] calibration procedure with demo --- .../assemblies/photom/demo/demo_yml_gui.py | 54 ++++++++++++++++--- .../photom/utils/affine_transform.py | 44 ++++++++------- 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_yml_gui.py b/copylot/assemblies/photom/demo/demo_yml_gui.py index 62d1b833..28bc0275 100644 --- a/copylot/assemblies/photom/demo/demo_yml_gui.py +++ b/copylot/assemblies/photom/demo/demo_yml_gui.py @@ -18,6 +18,8 @@ QStackedWidget, ) from PyQt5.QtGui import QColor, QPen +from copylot.assemblies.photom.utils.affine_transform import AffineTransform +import numpy as np DEMO_MODE = True @@ -128,11 +130,17 @@ def update_mirror_y(self, value): class LaserApp(QMainWindow): - def __init__(self, lasers, mirror, photom_window): + def __init__( + self, lasers, mirror, photom_window, affine_trans_obj, demo_window=None + ): super().__init__() self.photom_window = photom_window self.lasers = lasers self.mirror = mirror + self.affine_trans_obj = affine_trans_obj + if DEMO_MODE: + self.demo_window = demo_window + self.initUI() def initUI(self): @@ -200,18 +208,37 @@ def calibrate(self): # Hide the 'X' marker in photom_window # self.photom_window.marker.hide() self.display_rectangle() + self.source_pts = self.photom_window.get_coordinates() # Show the "Done Calibration" button self.done_calibration_button.show() def done_calibration(self): # Perform any necessary actions after calibration is done - print("Calibration done") self.photom_window.switch_to_shooting_scene() self.photom_window.marker.show() + self.target_pts = self.photom_window.get_coordinates() + origin = np.array( + [[pt.x(), pt.y()] for pt in self.source_pts], dtype=np.float32 + ) + dest = np.array([[pt.x(), pt.y()] for pt in self.target_pts], dtype=np.float32) + T_affine = self.affine_trans_obj.get_affine_matrix(dest, origin) + self.affine_trans_obj.save_matrix() + print(T_affine) # Hide the "Done Calibration" button self.done_calibration_button.hide() + if DEMO_MODE: + print(origin) + print(dest) + transformed_coords = self.affine_trans_obj.apply_affine(dest) + print(transformed_coords) + coords_list = self.affine_trans_obj.trans_pointwise(transformed_coords) + print(coords_list) + self.demo_window.updateVertices(coords_list) + + print("Calibration done") + def update_transparency(self, value): transparency_percent = value self.transparency_label.setText(f'Transparency: {transparency_percent}%') @@ -240,7 +267,6 @@ def display_rectangle(self): (window_size[1] * rectangle_scaling), ) rectangle_coords = self.calculate_rectangle_corners(rectangle_size) - print(rectangle_coords) self.photom_window.updateVertices(rectangle_coords) self.photom_window.switch_to_calibration_scene() @@ -323,7 +349,7 @@ def switch_to_shooting_scene(self): def switch_to_calibration_scene(self): self.stacked_widget.setCurrentWidget(self.calibration_view) - def getCoordinates(self): + def get_coordinates(self): return [vertex.pos() for vertex in self.vertices] def updateVertices(self, new_coordinates): @@ -387,16 +413,19 @@ def mouseReleaseEvent(self, event): ) photom_window = LaserMarkerWindow(window_size=photom_window_size) - # Set the positions of the windows - ctrl_window = LaserApp(lasers, mirror, photom_window) - ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_height) + # TODO: expose this path to user? + affine_trans_obj = AffineTransform(config_file='./affine_transform.yml') if DEMO_MODE: camera_window = LaserMarkerWindow( name='Mock laser dots', window_size=photom_window_size + ) # Set the positions of the windows + ctrl_window = LaserApp( + lasers, mirror, photom_window, affine_trans_obj, camera_window ) + ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_height) camera_window.switch_to_calibration_scene() - rectangle_scaling = 0.5 + rectangle_scaling = 0.2 window_size = (camera_window.width(), camera_window.height()) rectangle_size = ( (window_size[0] * rectangle_scaling), @@ -406,5 +435,14 @@ def mouseReleaseEvent(self, event): # translate each coordinate by the offset rectangle_coords = [(x + 30, y) for x, y in rectangle_coords] camera_window.updateVertices(rectangle_coords) + else: + # Set the positions of the windows + ctrl_window = LaserApp( + lasers, + mirror, + photom_window, + affine_trans_obj, + ) + ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_height) sys.exit(app.exec_()) diff --git a/copylot/assemblies/photom/utils/affine_transform.py b/copylot/assemblies/photom/utils/affine_transform.py index a5af85b6..b2d9269b 100644 --- a/copylot/assemblies/photom/utils/affine_transform.py +++ b/copylot/assemblies/photom/utils/affine_transform.py @@ -13,8 +13,16 @@ class AffineTransform: A class object for handling affine transformation. """ - def __init__(self, config_file='affine_transform.yml'): + def __init__(self, config_file='./affine_transform.yml'): self.config_file = config_file + if not Path(self.config_file).exists(): + Path(self.config_file).touch() + self.reset_T_affine() + model = AffineTransformationSettings( + affine_transform_yx=self.T_affine.tolist() + ) + model_to_yaml(model, self.config_file) + settings = yaml_to_model(self.config_file, AffineTransformationSettings) self.T_affine = np.array(settings.affine_transform_yx) @@ -40,47 +48,47 @@ def get_affine_matrix(self, origin, dest): ) return self.T_affine - def apply_affine(self, cord_list): + def apply_affine(self, coord_list): """ Perform affine transformation. - :param cord_list: a list of origin coordinate (e.g. [[x,y], ...] or [[list for ch0], [list for ch1]]) + :param coord_list: a list of origin coordinate (e.g. [[x,y], ...] or [[list for ch0], [list for ch1]]) :return: destination coordinate """ - cord_array = np.stack(cord_list, axis=0) - if len(cord_list) <= 2: - cord_array = cord_array.reshape(2, -1) + coord_array = np.stack(coord_list, axis=0) + if len(coord_list) <= 2: + coord_array = coord_array.reshape(2, -1) else: - cord_array = cord_array.T + coord_array = coord_array.T if self.T_affine is None: warn( - 'Affine matrix has not been determined yet. \ncord_list is returned without transformation.' + 'Affine matrix has not been determined yet. \ncoord_list is returned without transformation.' ) - dest_list = cord_array + dest_list = coord_array else: - cord_array = np.vstack((cord_array, np.ones((1, cord_array.shape[1])))) - dest_list = self.T_affine @ cord_array + coord_array = np.vstack((coord_array, np.ones((1, coord_array.shape[1])))) + dest_list = self.T_affine @ coord_array return [list(i) for i in dest_list] # will be used for non numpy & opencv version - def trans_pointwise(self, cord_list): + def trans_pointwise(self, coord_list): """ Convert coordinate list from channel wise to point wise. (e.g. [[1, 2, 4, .... ], [4, 2, 5, ... ] to [[1, 4], [2, 2], ...]) - :param cord_list: coordinate list in channel wise + :param coord_list: coordinate list in channel wise :return: coordinate list in point wise """ - return list(map(lambda x, y: [x, y], cord_list[0], cord_list[1])) + return list(map(lambda x, y: [x, y], coord_list[0], coord_list[1])) # will be used for non numpy & opencv version - def trans_chwise(self, cord_list): + def trans_chwise(self, coord_list): """ Convert coordinate list from channel wise to point wise. (e.g. [[1, 4], [2, 2], ...] to [[1, 2, 4, .... ], [4, 2, 5, ... ]) - :param cord_list: coordinate list in point wise + :param coord_list: coordinate list in point wise :return: coordinate list in channel wise """ - chwise1 = list(map(lambda x: x[0], cord_list)) - chwise2 = list(map(lambda x: x[1], cord_list)) + chwise1 = list(map(lambda x: x[0], coord_list)) + chwise2 = list(map(lambda x: x[1], coord_list)) return [chwise1, chwise2] def save_matrix(self, matrix: np.array = None, config_file: Path = None) -> None: From 76e5429650857cf89bad603539efb7a6669e2b8d Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Wed, 29 Nov 2023 22:16:49 -0800 Subject: [PATCH 09/60] demo calibration with mock mirror --- copylot/assemblies/photom/demo/config.yml | 10 +- .../assemblies/photom/demo/demo_yml_gui.py | 68 +-- .../photom/demo/photom_calibration.py | 537 ++++++++++++++++++ copylot/assemblies/photom/photom.py | 67 ++- .../photom/utils/affine_transform.py | 53 +- copylot/assemblies/photom/utils/settings.py | 1 + copylot/hardware/daqs/abstract_daq.py | 83 +++ copylot/hardware/lasers/abstract_laser.py | 115 +++- copylot/hardware/mirrors/abstract_mirror.py | 91 +++ copylot/hardware/mirrors/optotune/mirror.py | 3 +- copylot/hardware/stages/abstract_stage.py | 58 ++ 11 files changed, 1020 insertions(+), 66 deletions(-) create mode 100644 copylot/assemblies/photom/demo/photom_calibration.py create mode 100644 copylot/hardware/daqs/abstract_daq.py create mode 100644 copylot/hardware/mirrors/abstract_mirror.py create mode 100644 copylot/hardware/stages/abstract_stage.py diff --git a/copylot/assemblies/photom/demo/config.yml b/copylot/assemblies/photom/demo/config.yml index df27eb8d..0307801b 100644 --- a/copylot/assemblies/photom/demo/config.yml +++ b/copylot/assemblies/photom/demo/config.yml @@ -8,14 +8,8 @@ mirrors: - name: mirror_1_VIS x_position: 0 y_position: 0 - affine_matrix: - - [1, 0, 0] - - [0, 1, 0] - - [0, 0, 1] + affine_matrix_path: /home/eduardo.hirata/repos/coPylot/copylot/assemblies/photom/demo/affine_T.yml - name: mirror_2_IR x_position: 0 y_position: 0 - affine_matrix: - - [1, 0, 0] - - [0, 1, 0] - - [0, 0, 1] + affine_matrix_path: /home/eduardo.hirata/repos/coPylot/copylot/assemblies/photom/demo/affine_T_2.yml diff --git a/copylot/assemblies/photom/demo/demo_yml_gui.py b/copylot/assemblies/photom/demo/demo_yml_gui.py index 28bc0275..85805f61 100644 --- a/copylot/assemblies/photom/demo/demo_yml_gui.py +++ b/copylot/assemblies/photom/demo/demo_yml_gui.py @@ -63,10 +63,10 @@ def initUI(self): layout.addWidget(laser_power_slider) # Add a QLabel to display the power value - self.power_label = QLabel(f'Power: {self.laser.power}') + self.power_label = QLabel(f"Power: {self.laser.power}") layout.addWidget(self.power_label) - laser_toggle_button = QPushButton('Toggle') + laser_toggle_button = QPushButton("Toggle") laser_toggle_button.clicked.connect(self.toggle_laser) layout.addWidget(laser_toggle_button) @@ -78,7 +78,7 @@ def toggle_laser(self): def update_power(self, value): self.laser.set_power(value) # Update the QLabel with the new power value - self.power_label.setText(f'Power: {value}') + self.power_label.setText(f"Power: {value}") class MirrorWidget(QWidget): @@ -90,7 +90,7 @@ def __init__(self, mirror): def initUI(self): layout = QVBoxLayout() - mirror_x_label = QLabel('Mirror X Position') + mirror_x_label = QLabel("Mirror X Position") layout.addWidget(mirror_x_label) self.mirror_x_slider = QSlider(Qt.Horizontal) @@ -100,10 +100,10 @@ def initUI(self): layout.addWidget(self.mirror_x_slider) # Add a QLabel to display the mirror X value - self.mirror_x_label = QLabel(f'X: {self.mirror.x}') + self.mirror_x_label = QLabel(f"X: {self.mirror.x}") layout.addWidget(self.mirror_x_label) - mirror_y_label = QLabel('Mirror Y Position') + mirror_y_label = QLabel("Mirror Y Position") layout.addWidget(mirror_y_label) self.mirror_y_slider = QSlider(Qt.Horizontal) @@ -113,7 +113,7 @@ def initUI(self): layout.addWidget(self.mirror_y_slider) # Add a QLabel to display the mirror Y value - self.mirror_y_label = QLabel(f'Y: {self.mirror.y}') + self.mirror_y_label = QLabel(f"Y: {self.mirror.y}") layout.addWidget(self.mirror_y_label) self.setLayout(layout) @@ -121,15 +121,15 @@ def initUI(self): def update_mirror_x(self, value): self.mirror.x = value # Update the QLabel with the new X value - self.mirror_x_label.setText(f'X: {value}') + self.mirror_x_label.setText(f"X: {value}") def update_mirror_y(self, value): self.mirror.y = value # Update the QLabel with the new Y value - self.mirror_y_label.setText(f'Y: {value}') + self.mirror_y_label.setText(f"Y: {value}") -class LaserApp(QMainWindow): +class PhotomApp(QMainWindow): def __init__( self, lasers, mirror, photom_window, affine_trans_obj, demo_window=None ): @@ -145,10 +145,10 @@ def __init__( def initUI(self): self.setGeometry(100, 100, 400, 500) - self.setWindowTitle('Laser and Mirror Control App') + self.setWindowTitle("Laser and Mirror Control App") # Adding slider to adjust transparency - transparency_group = QGroupBox('Photom Transparency') + transparency_group = QGroupBox("Photom Transparency") transparency_layout = QVBoxLayout() # Create a slider to adjust the transparency self.transparency_slider = QSlider(Qt.Horizontal) @@ -159,12 +159,12 @@ def initUI(self): transparency_layout.addWidget(self.transparency_slider) # Add a QLabel to display the current percent transparency value - self.transparency_label = QLabel(f'Transparency: 100%') + self.transparency_label = QLabel(f"Transparency: 100%") transparency_layout.addWidget(self.transparency_label) transparency_group.setLayout(transparency_layout) # Adding a group box for the lasers - laser_group = QGroupBox('Lasers') + laser_group = QGroupBox("Lasers") laser_layout = QVBoxLayout() for laser in self.lasers: laser_widget = LaserWidget(laser) @@ -172,7 +172,7 @@ def initUI(self): laser_group.setLayout(laser_layout) # Adding a group box for the mirror - mirror_group = QGroupBox('Mirror') + mirror_group = QGroupBox("Mirror") mirror_layout = QVBoxLayout() for mirror in self.mirror: mirror_widget = MirrorWidget(mirror) @@ -186,12 +186,12 @@ def initUI(self): main_layout.addWidget(mirror_group) # Add a button to calibrate the mirror - self.calibrate_button = QPushButton('Calibrate') + self.calibrate_button = QPushButton("Calibrate") self.calibrate_button.clicked.connect(self.calibrate) main_layout.addWidget(self.calibrate_button) # Add a "Done Calibration" button (initially hidden) - self.done_calibration_button = QPushButton('Done Calibration') + self.done_calibration_button = QPushButton("Done Calibration") self.done_calibration_button.clicked.connect(self.done_calibration) self.done_calibration_button.hide() main_layout.addWidget(self.done_calibration_button) @@ -241,7 +241,7 @@ def done_calibration(self): def update_transparency(self, value): transparency_percent = value - self.transparency_label.setText(f'Transparency: {transparency_percent}%') + self.transparency_label.setText(f"Transparency: {transparency_percent}%") opacity = 1.0 - (transparency_percent / 100.0) # Calculate opacity (0.0 to 1.0) self.photom_window.setWindowOpacity(opacity) # Update photom_window opacity @@ -272,7 +272,7 @@ def display_rectangle(self): class LaserMarkerWindow(QMainWindow): - def __init__(self, name='Laser Marker', window_size=(100, 100, 100, 100)): + def __init__(self, name="Laser Marker", window_size=(100, 100, 100, 100)): super().__init__() self.window_name = name self.windowGeo = window_size @@ -317,7 +317,7 @@ def initMarker(self): self.shooting_view.setMouseTracking(True) self.setCentralWidget(self.shooting_view) self.setMouseTracking(True) - self.marker = QGraphicsSimpleTextItem('X') + self.marker = QGraphicsSimpleTextItem("X") self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) self.shooting_scene.addItem(self.marker) @@ -359,37 +359,37 @@ def updateVertices(self, new_coordinates): def recordinate(self, rawcord): return -self.scale * (rawcord - (self.windowGeo[2] / 2)) / 50 - def mouseMoveEvent(self, event: 'QGraphicsSceneMouseEvent'): + def mouseMoveEvent(self, event: "QGraphicsSceneMouseEvent"): new_cursor_position = event.screenPos() - print(f'current x: {new_cursor_position}') + print(f"current x: {new_cursor_position}") def mousePressEvent(self, event): marker_x = self.marker.pos().x() marker_y = self.marker.pos().y() - print(f'x position: {(marker_x, marker_y)}') + print(f"x position: {(marker_x, marker_y)}") # print('Mouse press coords: ( %f : %f )' % (self.mouseX, self.mouseY)) def mouseReleaseEvent(self, event): - print('Mouse release coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + print("Mouse release coords: ( %f : %f )" % (self.mouseX, self.mouseY)) -if __name__ == '__main__': +if __name__ == "__main__": import os os.environ["DISPLAY"] = ":1005" config_path = ( "/home/eduardo.hirata/repos/coPylot/copylot/assemblies/photom/demo/config.yml" ) - with open(config_path, 'r') as config_file: + with open(config_path, "r") as config_file: config = yaml.load(config_file, Loader=yaml.FullLoader) - lasers = [Laser(**laser_data) for laser_data in config['lasers']] + lasers = [Laser(**laser_data) for laser_data in config["lasers"]] mirror = [ Mirror( - x_position=mirror_data['x_position'], - y_position=mirror_data['y_position'], + x_position=mirror_data["x_position"], + y_position=mirror_data["y_position"], ) - for mirror_data in config['mirrors'] + for mirror_data in config["mirrors"] ] # Initial mirror position app = QApplication(sys.argv) @@ -414,13 +414,13 @@ def mouseReleaseEvent(self, event): photom_window = LaserMarkerWindow(window_size=photom_window_size) # TODO: expose this path to user? - affine_trans_obj = AffineTransform(config_file='./affine_transform.yml') + affine_trans_obj = AffineTransform(config_file="./affine_transform.yml") if DEMO_MODE: camera_window = LaserMarkerWindow( - name='Mock laser dots', window_size=photom_window_size + name="Mock laser dots", window_size=photom_window_size ) # Set the positions of the windows - ctrl_window = LaserApp( + ctrl_window = PhotomApp( lasers, mirror, photom_window, affine_trans_obj, camera_window ) ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_height) @@ -437,7 +437,7 @@ def mouseReleaseEvent(self, event): camera_window.updateVertices(rectangle_coords) else: # Set the positions of the windows - ctrl_window = LaserApp( + ctrl_window = PhotomApp( lasers, mirror, photom_window, diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py new file mode 100644 index 00000000..9c14f030 --- /dev/null +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -0,0 +1,537 @@ +import sys +import yaml +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QApplication, + QMainWindow, + QWidget, + QPushButton, + QLabel, + QSlider, + QVBoxLayout, + QGraphicsView, + QGroupBox, + QGraphicsScene, + QGraphicsSimpleTextItem, + QGraphicsItem, + QGraphicsEllipseItem, + QStackedWidget, + QComboBox, + QFileDialog, +) +from PyQt5.QtGui import QColor, QPen +from copylot.assemblies.photom.utils.affine_transform import AffineTransform +import numpy as np +from copylot.assemblies.photom.photom import PhotomAssembly +from pathlib import Path + +DEMO_MODE = True + +# TODO: deal with the logic when clicking calibrate. Mirror dropdown +# TODO: check that the calibration step is implemented properly. +# TODO: remove the self.affine_transform_obj from this file. This is now part of photom-assembly + + +class LaserWidget(QWidget): + def __init__(self, laser): + super().__init__() + self.laser = laser + self.initUI() + + def initUI(self): + layout = QVBoxLayout() + + laser_label = QLabel(self.laser.name) + layout.addWidget(laser_label) + + laser_power_slider = QSlider(Qt.Horizontal) + laser_power_slider.setMinimum(0) + laser_power_slider.setMaximum(100) + laser_power_slider.setValue(self.laser.power) + laser_power_slider.valueChanged.connect(self.update_power) + layout.addWidget(laser_power_slider) + + # Add a QLabel to display the power value + self.power_label = QLabel(f"Power: {self.laser.power}") + layout.addWidget(self.power_label) + + laser_toggle_button = QPushButton("Toggle") + laser_toggle_button.clicked.connect(self.toggle_laser) + layout.addWidget(laser_toggle_button) + + self.setLayout(layout) + + def toggle_laser(self): + self.laser.toggle() + + def update_power(self, value): + self.laser.set_power(value) + # Update the QLabel with the new power value + self.power_label.setText(f"Power: {value}") + + +# TODO: connect widget to actual abstract mirror calls +class MirrorWidget(QWidget): + def __init__(self, mirror): + super().__init__() + self.mirror = mirror + self.initUI() + + def initUI(self): + layout = QVBoxLayout() + + mirror_x_label = QLabel("Mirror X Position") + layout.addWidget(mirror_x_label) + + self.mirror_x_slider = QSlider(Qt.Horizontal) + self.mirror_x_slider.setMinimum(0) + self.mirror_x_slider.setMaximum(100) + self.mirror_x_slider.valueChanged.connect(self.update_mirror_x) + layout.addWidget(self.mirror_x_slider) + + # Add a QLabel to display the mirror X value + self.mirror_x_label = QLabel(f"X: {self.mirror.x}") + layout.addWidget(self.mirror_x_label) + + mirror_y_label = QLabel("Mirror Y Position") + layout.addWidget(mirror_y_label) + + self.mirror_y_slider = QSlider(Qt.Horizontal) + self.mirror_y_slider.setMinimum(0) + self.mirror_y_slider.setMaximum(100) + self.mirror_y_slider.valueChanged.connect(self.update_mirror_y) + layout.addWidget(self.mirror_y_slider) + + # Add a QLabel to display the mirror Y value + self.mirror_y_label = QLabel(f"Y: {self.mirror.y}") + layout.addWidget(self.mirror_y_label) + + self.setLayout(layout) + + def update_mirror_x(self, value): + self.mirror.x = value + # Update the QLabel with the new X value + self.mirror_x_label.setText(f"X: {value}") + + def update_mirror_y(self, value): + self.mirror.y = value + # Update the QLabel with the new Y value + self.mirror_y_label.setText(f"Y: {value}") + + +class PhotomApp(QMainWindow): + def __init__( + self, + photom_assembly: PhotomAssembly, + photom_window: QMainWindow, + config_file: Path = None, + demo_window=None, + ): + super().__init__() + + self.photom_window = photom_window + self.photom_assembly = photom_assembly + self.lasers = self.photom_assembly.laser + self.mirrors = self.photom_assembly.mirror + + self._calibrating_mirror_idx = None + + if DEMO_MODE: + self.demo_window = demo_window + + self.initUI() + + def initUI(self): + """ + Initialize the UI. + + """ + self.setGeometry(100, 100, 400, 500) + self.setWindowTitle("Laser and Mirror Control App") + + # Adding slider to adjust transparency + transparency_group = QGroupBox("Photom Transparency") + transparency_layout = QVBoxLayout() + # Create a slider to adjust the transparency + self.transparency_slider = QSlider(Qt.Horizontal) + self.transparency_slider.setMinimum(0) + self.transparency_slider.setMaximum(100) + self.transparency_slider.setValue(100) # Initial value is fully opaque + self.transparency_slider.valueChanged.connect(self.update_transparency) + transparency_layout.addWidget(self.transparency_slider) + + # Add a QLabel to display the current percent transparency value + self.transparency_label = QLabel(f"Transparency: 100%") + transparency_layout.addWidget(self.transparency_label) + transparency_group.setLayout(transparency_layout) + + # Adding a group box for the lasers + laser_group = QGroupBox("Lasers") + laser_layout = QVBoxLayout() + for laser in self.lasers: + laser_widget = LaserWidget(laser) + laser_layout.addWidget(laser_widget) + laser_group.setLayout(laser_layout) + + # Adding a group box for the mirror + mirror_group = QGroupBox("Mirror") + mirror_layout = QVBoxLayout() + for mirror in self.mirrors: + mirror_widget = MirrorWidget(mirror) + mirror_layout.addWidget(mirror_widget) + mirror_group.setLayout(mirror_layout) + + # Add the laser and mirror group boxes to the main layout + main_layout = QVBoxLayout() + main_layout.addWidget(transparency_group) + main_layout.addWidget(laser_group) + main_layout.addWidget(mirror_group) + + self.mirror_dropdown = QComboBox() + self.mirror_dropdown.addItems([mirror.name for mirror in self.mirrors]) + main_layout.addWidget(self.mirror_dropdown) + + self.calibrate_button = QPushButton("Calibrate") + self.calibrate_button.clicked.connect(self.calibrate) + main_layout.addWidget(self.calibrate_button) + + # Add a "Done Calibration" button (initially hidden) + self.done_calibration_button = QPushButton("Done Calibration") + self.done_calibration_button.clicked.connect(self.done_calibration) + self.done_calibration_button.hide() + main_layout.addWidget(self.done_calibration_button) + + main_widget = QWidget(self) + main_widget.setLayout(main_layout) + + self.setCentralWidget(main_widget) + self.show() + + def calibrate(self): + # Implement your calibration function here + print("Calibrating...") + # Hide the 'X' marker in photom_window + # self.photom_window.marker.hide() + self.display_rectangle() + self.source_pts = self.photom_window.get_coordinates() + # Show the "Done Calibration" button + self.done_calibration_button.show() + + selected_mirror_name = self.mirror_dropdown.currentText() + self._calibrating_mirror_idx = next( + i + for i, mirror in enumerate(self.mirrors) + if mirror.name == selected_mirror_name + ) + if not DEMO_MODE: + # TODO: move in the pattern for calibration + self.photom_assembly.calibrate(self._calibrating_mirror_idx) + else: + print(f'Calibrating mirror: {self._calibrating_mirror_idx}') + + def done_calibration(self): + # Perform any necessary actions after calibration is done + self.photom_window.switch_to_shooting_scene() + self.photom_window.marker.show() + self.target_pts = self.photom_window.get_coordinates() + origin = np.array( + [[pt.x(), pt.y()] for pt in self.source_pts], dtype=np.float32 + ) + dest = np.array([[pt.x(), pt.y()] for pt in self.target_pts], dtype=np.float32) + + T_affine = self.photom_assembly.mirror[ + self._calibrating_mirror_idx + ].affine_transform_obj.get_affine_matrix(dest, origin) + # logger.debug(f"Affine matrix: {T_affine}") + print(f"Affine matrix: {T_affine}") + + typed_filename, _ = QFileDialog.getSaveFileName( + self, "Save File", "", "YAML Files (*.yml)" + ) + if typed_filename: + if not typed_filename.endswith(".yml"): + typed_filename += ".yml" + print("Selected file:", typed_filename) + + # Save the matrix + self.photom_assembly.mirror[ + self._calibrating_mirror_idx + ].affine_transform_obj.save_matrix(matrix=T_affine, config_file=typed_filename) + + # Hide the "Done Calibration" button + self.done_calibration_button.hide() + + if DEMO_MODE: + print(f'origin: {origin}') + print(f'dest: {dest}') + # transformed_coords = self.affine_trans_obj.apply_affine(dest) + transformed_coords = self.photom_assembly.mirror[ + self._calibrating_mirror_idx + ].affine_transform_obj.apply_affine(dest) + print(transformed_coords) + coords_list = self.photom_assembly.mirror[ + self._calibrating_mirror_idx + ].affine_transform_obj.trans_pointwise(transformed_coords) + print(coords_list) + self.demo_window.updateVertices(coords_list) + + print("Calibration done") + + def update_transparency(self, value): + transparency_percent = value + self.transparency_label.setText(f"Transparency: {transparency_percent}%") + opacity = 1.0 - (transparency_percent / 100.0) # Calculate opacity (0.0 to 1.0) + self.photom_window.setWindowOpacity(opacity) # Update photom_window opacity + + def calculate_rectangle_corners(self, window_size): + # window_size is a tuple of (width, height) + + # Calculate the coordinates of the rectangle corners + x0y0 = ( + -window_size[0] / 2, + -window_size[1] / 2, + ) + x1y0 = (x0y0[0] + window_size[0], x0y0[1]) + x1y1 = (x0y0[0] + window_size[0], x0y0[1] + window_size[1]) + x0y1 = (x0y0[0], x0y0[1] + window_size[1]) + return x0y0, x1y0, x1y1, x0y1 + + def display_rectangle(self): + # Calculate the coordinates of the rectangle corners + rectangle_scaling = 0.5 + window_size = (self.photom_window.width(), self.photom_window.height()) + rectangle_size = ( + (window_size[0] * rectangle_scaling), + (window_size[1] * rectangle_scaling), + ) + rectangle_coords = self.calculate_rectangle_corners(rectangle_size) + self.photom_window.updateVertices(rectangle_coords) + self.photom_window.switch_to_calibration_scene() + + +class LaserMarkerWindow(QMainWindow): + def __init__(self, name="Laser Marker", window_size=(100, 100, 100, 100)): + super().__init__() + self.window_name = name + self.windowGeo = window_size + self.setMouseTracking(True) + self.mouseX = None + self.mouseY = None + self.setWindowOpacity(0.7) + self.scale = 0.025 + # self.offset = (-0.032000, -0.046200) + + # Create a QStackedWidget + self.stacked_widget = QStackedWidget() + # Set the QStackedWidget as the central widget + + self.initMarker() + self.init_tetragon() + + self.initUI() + + self.setCentralWidget(self.stacked_widget) + + def initUI(self): + self.setGeometry( + self.windowGeo[0], + self.windowGeo[1], + self.windowGeo[2], + self.windowGeo[3], + ) + self.setWindowTitle(self.window_name) + + # Fix the size of the window + self.setFixedSize( + self.windowGeo[2], + self.windowGeo[3], + ) + self.switch_to_shooting_scene() + self.show() + + def initMarker(self): + self.shooting_scene = QGraphicsScene(self) + self.shooting_view = QGraphicsView(self.shooting_scene) + self.shooting_view.setMouseTracking(True) + self.setCentralWidget(self.shooting_view) + self.setMouseTracking(True) + self.marker = QGraphicsSimpleTextItem("X") + self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) + self.shooting_scene.addItem(self.marker) + + # Add the view to the QStackedWidget + self.stacked_widget.addWidget(self.shooting_view) + + def init_tetragon( + self, tetragon_coords: list = [(100, 100), (200, 100), (200, 200), (100, 200)] + ): + self.calibration_scene = QGraphicsScene(self) + self.calibration_view = QGraphicsView(self.calibration_scene) + self.calibration_view.setMouseTracking(True) + self.setCentralWidget(self.calibration_view) + self.setMouseTracking(True) + self.vertices = [] + for x, y in tetragon_coords: + vertex = QGraphicsEllipseItem(x - 5, y - 5, 10, 10) + vertex.setBrush(Qt.red) + vertex.setFlag(QGraphicsEllipseItem.ItemIsMovable) + self.vertices.append(vertex) + self.calibration_scene.addItem(vertex) + + # Add the view to the QStackedWidget + self.stacked_widget.addWidget(self.calibration_view) + + def switch_to_shooting_scene(self): + self.stacked_widget.setCurrentWidget(self.shooting_view) + + def switch_to_calibration_scene(self): + self.stacked_widget.setCurrentWidget(self.calibration_view) + + def get_coordinates(self): + return [vertex.pos() for vertex in self.vertices] + + def updateVertices(self, new_coordinates): + for vertex, (x, y) in zip(self.vertices, new_coordinates): + vertex.setPos(x, y) + + def recordinate(self, rawcord): + return -self.scale * (rawcord - (self.windowGeo[2] / 2)) / 50 + + def mouseMoveEvent(self, event: "QGraphicsSceneMouseEvent"): + new_cursor_position = event.screenPos() + + print(f"current x: {new_cursor_position}") + + def mousePressEvent(self, event): + marker_x = self.marker.pos().x() + marker_y = self.marker.pos().y() + print(f"x position: {(marker_x, marker_y)}") + # print('Mouse press coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + + def mouseReleaseEvent(self, event): + print("Mouse release coords: ( %f : %f )" % (self.mouseX, self.mouseY)) + + +if __name__ == "__main__": + import os + + if DEMO_MODE: + + class MockLaser: + def __init__(self, name, power=0, **kwargs): + # Initialize the mock laser + self.name = name + self.power = power + self.laser_on = False + + def toggle(self): + self.laser_on = not self.laser_on + + def set_power(self, power): + self.power = power + + class MockMirror: + def __init__(self, name, x_position=0, y_position=0, **kwargs): + # Initialize the mock mirror with the given x and y positions + self.name = name + self.x = x_position + self.y = y_position + + def move(self, x_position, y_position): + # Move the mock mirror to the specified x and y positions + pass + + Laser = MockLaser + Mirror = MockMirror + + else: + # NOTE: These are the actual classes that will be used in the photom assembly + from copylot.hardware.lasers.vortran import VortranLaser as Laser + from copylot.hardware.mirrors.optotune.mirror import OptoMirror as Mirror + + try: + os.environ["DISPLAY"] = ":1003" + except: + raise Exception("DISPLAY environment variable not set") + + config_path = ( + "/home/eduardo.hirata/repos/coPylot/copylot/assemblies/photom/demo/config.yml" + ) + + # TODO: this should be a function that parses the config_file and returns the photom_assembly + # Load the config file and parse it + with open(config_path, "r") as config_file: + config = yaml.load(config_file, Loader=yaml.FullLoader) + lasers = [Laser(**laser_data) for laser_data in config["lasers"]] + mirrors = [ + Mirror( + name=mirror_data["name"], + x_position=mirror_data["x_position"], + y_position=mirror_data["y_position"], + ) + for mirror_data in config["mirrors"] + ] # Initial mirror position + affine_matrix_paths = [ + mirror['affine_matrix_path'] for mirror in config['mirrors'] + ] + # Check that the number of mirrors and affine matrices match + assert len(mirrors) == len(affine_matrix_paths) + + # Load photom assembly + photom_assembly = PhotomAssembly( + laser=lasers, mirror=mirrors, affine_matrix_path=affine_matrix_paths + ) + + # QT APP + app = QApplication(sys.argv) + + # Define the positions and sizes for the windows + screen_width = app.desktop().screenGeometry().width() + screen_height = app.desktop().screenGeometry().height() + + ctrl_window_width = screen_width // 3 # Adjust the width as needed + ctrl_window_height = screen_height // 3 # Use the full screen height + + # Making the photom_window a square + photom_window_width = screen_width // 3 # Adjust the width as needed + photom_window_height = screen_width // 3 # Adjust the width as needed + + photom_window_size = ( + ctrl_window_width, + 0, + photom_window_width, + photom_window_height, + ) + photom_window = LaserMarkerWindow(window_size=photom_window_size) + + if DEMO_MODE: + camera_window = LaserMarkerWindow( + name="Mock laser dots", window_size=photom_window_size + ) # Set the positions of the windows + ctrl_window = PhotomApp( + photom_assembly=photom_assembly, + photom_window=photom_window, + demo_window=camera_window, + ) + ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_height) + + # Set the camera window to the calibration scene + camera_window.switch_to_calibration_scene() + rectangle_scaling = 0.2 + window_size = (camera_window.width(), camera_window.height()) + rectangle_size = ( + (window_size[0] * rectangle_scaling), + (window_size[1] * rectangle_scaling), + ) + rectangle_coords = ctrl_window.calculate_rectangle_corners(rectangle_size) + # translate each coordinate by the offset + rectangle_coords = [(x + 30, y) for x, y in rectangle_coords] + camera_window.updateVertices(rectangle_coords) + else: + # Set the positions of the windows + ctrl_window = PhotomApp( + photom_assembly=photom_assembly, photom_window=photom_window + ) + ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_height) + + sys.exit(app.exec_()) diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index ad52d920..9274bdbc 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -1,21 +1,72 @@ from dataclasses import dataclass +from copylot.hardware.cameras.abstract_camera import AbstractCamera +from copylot.hardware.mirrors.abstract_mirror import AbstractMirror +from copylot.hardware.lasers.abstract_laser import AbstractLaser +from copylot.hardware.stages.abstract_stage import AbstractStage +from copylot.hardware.daqs.abstract_daq import AbstractDAQ +from copylot.assemblies.photom.utils.affine_transform import AffineTransform +from pathlib import Path -class photom: - def __init__(self, camera, laser, mirror, dac): +class PhotomAssembly: + def __init__( + self, + laser: list[AbstractLaser], + mirror: list[AbstractMirror], + affine_matrix_path: list[Path], + camera: list[AbstractCamera] = None, + dac: list[AbstractDAQ] = None, + ): + # hardware self.camera = camera - self.laser = laser + self.laser = laser # list of lasers self.mirror = mirror self.DAC = dac + assert len(self.mirror) == len(affine_matrix_path) + + # Apply AffineTransform to each mirror + for i, tx_path in enumerate(affine_matrix_path): + self.mirror[i].affine_transform_obj = AffineTransform(config_file=tx_path) + + def calibrate(self, mirror_index: int): + if mirror_index < len(self.mirror): + pass + else: + pass + ## Camera Functions def capture(self): pass ## Mirror Functions - def move_mirror(self): - pass + @property + def position(self, mirror_index: int, position: list[float]): + if mirror_index < len(self.mirror): + if self.DAC is None: + NotImplementedError("No DAC found.") + else: + return self.mirror[mirror_index].position + else: + raise IndexError("Mirror index out of range.") - ## LASER Fucntions - def change_laser_power(self): - pass + @position.setter + def position(self, mirror_index: int, position: list[float]): + if mirror_index < len(self.mirror): + if self.DAC is None: + NotImplementedError("No DAC found.") + else: + # TODO: logic for applying the affine transform to the position + new_position = self.mirror.affine_transform_obj.apply_affine(position) + self.mirror[mirror_index].position = new_position + else: + raise IndexError("Mirror index out of range.") + + ## LASER Fuctions + @property + def laser_power(self, laser_index: int, power: float): + return self.laser[laser_index].power + + @laser_power.setter + def change_laser_power(self, laser_index: int, power: float): + self.laser[laser_index].power = power diff --git a/copylot/assemblies/photom/utils/affine_transform.py b/copylot/assemblies/photom/utils/affine_transform.py index b2d9269b..87bcbc56 100644 --- a/copylot/assemblies/photom/utils/affine_transform.py +++ b/copylot/assemblies/photom/utils/affine_transform.py @@ -13,18 +13,46 @@ class AffineTransform: A class object for handling affine transformation. """ - def __init__(self, config_file='./affine_transform.yml'): + def __init__(self, tx_matrix: np.array = None, config_file: Path = None): + """ + Initialize the affine transformation object. + :param tx_matrix: affine transformation matrix + """ + self.T_affine = tx_matrix self.config_file = config_file - if not Path(self.config_file).exists(): - Path(self.config_file).touch() + + if self.T_affine is None: self.reset_T_affine() + else: + tx_matrix = np.array(tx_matrix) + assert tx_matrix.shape == (3, 3) + self.T_affine = tx_matrix + + if self.config_file is None: + self.make_config() + else: + settings = yaml_to_model(self.config_file, AffineTransformationSettings) + self.T_affine = np.array(settings.affine_transform_yx) + + def make_config(self, config_file="./affine_transform.yml"): + if not Path(self.config_file).exists(): + i = 1 + # Generate the first filename + filename = Path(self.config_file) + # While a file with the current filename exists, increment the number + while Path(filename).exists(): + i += 1 + self.config_file = f"{filename.parent}_{i}{filename.suffix}" + self.config_file.mkdir(parents=True, exist_ok=True) + # Make model and save to file model = AffineTransformationSettings( affine_transform_yx=self.T_affine.tolist() ) model_to_yaml(model, self.config_file) - settings = yaml_to_model(self.config_file, AffineTransformationSettings) - self.T_affine = np.array(settings.affine_transform_yx) + # # Load the config file + # settings = yaml_to_model(self.config_file, AffineTransformationSettings) + # self.T_affine = np.array(settings.affine_transform_yx) def reset_T_affine(self): """ @@ -40,15 +68,15 @@ def get_affine_matrix(self, origin, dest): :return: affine matrix """ if not (isinstance(origin, Iterable) and len(origin) >= 3): - raise ValueError('origin needs 3 coordinates.') + raise ValueError("origin needs 3 coordinates.") if not (isinstance(dest, Iterable) and len(dest) >= 3): - raise ValueError('dest needs 3 coordinates.') + raise ValueError("dest needs 3 coordinates.") self.T_affine = transform.estimate_transform( - 'affine', np.float32(origin), np.float32(dest) + "affine", np.float32(origin), np.float32(dest) ) return self.T_affine - def apply_affine(self, coord_list): + def apply_affine(self, coord_list: list): """ Perform affine transformation. :param coord_list: a list of origin coordinate (e.g. [[x,y], ...] or [[list for ch0], [list for ch1]]) @@ -61,7 +89,7 @@ def apply_affine(self, coord_list): coord_array = coord_array.T if self.T_affine is None: warn( - 'Affine matrix has not been determined yet. \ncoord_list is returned without transformation.' + "Affine matrix has not been determined yet. \ncoord_list is returned without transformation." ) dest_list = coord_array else: @@ -110,16 +138,17 @@ def save_matrix(self, matrix: np.array = None, config_file: Path = None) -> None if matrix is None: if self.T_affine is None: - raise ValueError('provided matrix is not defined') + raise ValueError("provided matrix is not defined") else: matrix = self.T_affine else: + matrix = np.array(matrix) assert matrix.shape == (3, 3) if config_file is not None: self.config_file = config_file model = AffineTransformationSettings( - affine_transform_yx=np.array(matrix).tolist(), + affine_transform_yx=matrix.tolist(), ) model_to_yaml(model, self.config_file) diff --git a/copylot/assemblies/photom/utils/settings.py b/copylot/assemblies/photom/utils/settings.py index 336d991c..2166af71 100644 --- a/copylot/assemblies/photom/utils/settings.py +++ b/copylot/assemblies/photom/utils/settings.py @@ -19,3 +19,4 @@ class MyBaseModel(BaseModel, extra=Extra.forbid): class AffineTransformationSettings(MyBaseModel): affine_transform_yx: list[list[float]] + # TODO: validations for the affine transform matrix diff --git a/copylot/hardware/daqs/abstract_daq.py b/copylot/hardware/daqs/abstract_daq.py new file mode 100644 index 00000000..d279d71a --- /dev/null +++ b/copylot/hardware/daqs/abstract_daq.py @@ -0,0 +1,83 @@ +from abc import ABCMeta, abstractmethod + + +class AbstractDAQ(ABCMeta): + @staticmethod + @abstractmethod + def list_available_daqs(): + """List all DAQs that the driver discovers.""" + raise NotImplementedError() + + @property + @abstractmethod + def voltage(self): + """Method to get the voltage in volts.""" + raise NotImplementedError() + + @voltage.setter + @abstractmethod + def voltage(self, value): + """Method to set the voltage in volts.""" + raise NotImplementedError() + + @property + @abstractmethod + def voltage_range(self): + """ + Valid minimum and maximum voltage range values. + Returns + ------- + Tuple + (min_valid_voltage, max_valid_voltage) + """ + raise NotImplementedError() + + @voltage_range.setter + @abstractmethod + def voltage_range(self, value): + """ + Set the voltage range of the DAQ. + Parameters + ---------- + value: Tuple + (min_valid_voltage, max_valid_voltage) + """ + raise NotImplementedError() + + @property + @abstractmethod + def sample_rate(self): + """Get the sample rate in samples per second.""" + raise NotImplementedError() + + @sample_rate.setter + @abstractmethod + def sample_rate(self, value): + """Set the sample rate in samples per second.""" + raise NotImplementedError() + + @property + @abstractmethod + def available_channels(self): + """Get the list of available channels.""" + raise NotImplementedError() + + @abstractmethod + def start_acquisition(self): + """Start data acquisition.""" + raise NotImplementedError() + + @abstractmethod + def stop_acquisition(self): + """Stop data acquisition.""" + raise NotImplementedError() + + @abstractmethod + def read_data(self, num_points): + """Read a specified number of data points from the DAQ.""" + raise NotImplementedError() + + @abstractmethod + def calibrate(self): + """Calibrate the DAQ device.""" + raise NotImplementedError() diff --git a/copylot/hardware/lasers/abstract_laser.py b/copylot/hardware/lasers/abstract_laser.py index 5e0dc005..7eeca74d 100644 --- a/copylot/hardware/lasers/abstract_laser.py +++ b/copylot/hardware/lasers/abstract_laser.py @@ -1,5 +1,114 @@ -import abc +from abc import ABCMeta, abstractmethod +from typing import Tuple -class AbstractLaser(metaclass=abc.ABCMeta): - pass +class AbstractLaser(metaclass=ABCMeta): + """AbstractLaser + + This class includes only the members that known to be common + across different laser adapters. By no + means this class in a final state. We will be making additions + as needs rise. + + """ + + _device_id = None + + @property + def device_id(self): + """Returns device_id (serial number) of the current laser""" + return self._device_id + + @abstractmethod + def connect(self): + """Connect to the laser""" + pass + + @abstractmethod + def disconnect(self): + """Disconnect from the laser""" + pass + + @property + @abstractmethod + def drive_control_mode(self) -> str: + """Get the current laser drive control mode""" + pass + + @drive_control_mode.setter + @abstractmethod + def drive_control_mode(self, value: bool): + """Set the laser drive control mode""" + pass + + @property + @abstractmethod + def toggle_emission(self) -> bool: + """Toggle laser emission""" + pass + + @toggle_emission.setter + @abstractmethod + def toggle_emission(self, value: bool): + """Toggle laser emission""" + pass + + @property + @abstractmethod + def power(self) -> float: + """Get the current laser power""" + pass + + @power.setter + @abstractmethod + def power(self, value: float): + """Set the laser power""" + pass + + @property + @abstractmethod + def pulse_mode(self) -> bool: + """Get the current laser pulse mode""" + pass + + @pulse_mode.setter + @abstractmethod + def pulse_mode(self, value: bool): + """Set the laser pulse mode""" + pass + + @property + @abstractmethod + def maximum_power(self) -> float: + """Get the current laser maximum power""" + pass + + @property + @abstractmethod + def current_control_mode(self) -> str: + """Get the current laser current control mode""" + pass + + @current_control_mode.setter + @abstractmethod + def current_control_mode(self, value: str): + """Set the laser current control mode""" + pass + + @property + @abstractmethod + def external_power_control(self) -> bool: + """Get the current laser external power control""" + pass + + @external_power_control.setter + @abstractmethod + def external_power_control(self, value: bool): + """Set the laser external power control""" + pass + + @property + @abstractmethod + def status(self) -> str: + """Get the current laser status""" + pass diff --git a/copylot/hardware/mirrors/abstract_mirror.py b/copylot/hardware/mirrors/abstract_mirror.py new file mode 100644 index 00000000..1fbe95d4 --- /dev/null +++ b/copylot/hardware/mirrors/abstract_mirror.py @@ -0,0 +1,91 @@ +from abc import ABCMeta, abstractmethod +from typing import Tuple + + +class AbstractMirror(metaclass=ABCMeta): + """AbstractMirror + + This class includes only the members that known to be common + across different mirror adapters. By no + means this class in a final state. We will be making additions + as needs rise. + + """ + + def __init__(self): + self.affine_transform_obj = None + + @property + @abstractmethod + def position(self) -> Tuple[float, float]: + """Get the current mirror position""" + pass + + @position.setter + @abstractmethod + def position(self, value: Tuple[float, float]): + """Set the mirror position""" + pass + + @property + @abstractmethod + def relative_position(self) -> Tuple[float, float]: + """Get the current relative mirror position""" + pass + + @relative_position.setter + @abstractmethod + def relative_position(self, value: Tuple[float, float]): + """Set the relative mirror position""" + pass + + @property + @abstractmethod + def movement_limits(self) -> Tuple[float, float, float, float]: + """Get the current mirror movement limits""" + pass + + @movement_limits.setter + @abstractmethod + def movement_limits(self, value: Tuple[float, float, float, float]): + """Set the mirror movement limits""" + pass + + @property + @abstractmethod + def step_resolution(self) -> float: + """Get the current mirror step resolution""" + pass + + @step_resolution.setter + @abstractmethod + def step_resolution(self, value: float): + """Set the mirror step resolution""" + pass + + @abstractmethod + def set_home(self): + """Set the mirror home position""" + pass + + @abstractmethod + def set_origin(self, axis: str): + """Set the mirror origin for a specific axis""" + pass + + @property + @abstractmethod + def external_drive_control(self) -> str: + """Get the current mirror drive mode""" + pass + + @external_drive_control.setter + @abstractmethod + def external_drive_control(self, value: bool): + """Set the mirror drive mode""" + pass + + @abstractmethod + def voltage_to_position(self, voltage: Tuple[float, float]) -> Tuple[float, float]: + """Convert voltage to position""" + pass diff --git a/copylot/hardware/mirrors/optotune/mirror.py b/copylot/hardware/mirrors/optotune/mirror.py index 6ccba21b..86a2392c 100644 --- a/copylot/hardware/mirrors/optotune/mirror.py +++ b/copylot/hardware/mirrors/optotune/mirror.py @@ -8,9 +8,10 @@ """ from copylot import logger from copylot.hardware.mirrors.optotune import optoMDC +from copylot.hardware.mirrors.abstract_mirror import AbstractMirror -class OptoMirror: +class OptoMirror(AbstractMirror): def __init__(self, com_port: str = None): """ Wrapper for Optotune mirror controller MR-E-2. diff --git a/copylot/hardware/stages/abstract_stage.py b/copylot/hardware/stages/abstract_stage.py new file mode 100644 index 00000000..c367d94d --- /dev/null +++ b/copylot/hardware/stages/abstract_stage.py @@ -0,0 +1,58 @@ +from abc import ABCMeta, abstractmethod + + +class AbstractStage(metaclass=ABCMeta): + @staticmethod + @abstractmethod + def list_available_stages(): + """List all stages that driver discovers.""" + raise NotImplementedError() + + @property + @abstractmethod + def position(self): + "Method to get/set the position in um" + raise NotImplementedError() + + @position.setter + @abstractmethod + def position(self, value): + raise NotImplementedError() + + @property + @abstractmethod + def travel_range(self): + """ + Valid minimum and maximum travel range values. + Returns + ------- + Tuple + (min_valid_position, max_valid_position) + """ + raise NotImplementedError() + + @travel_range.setter + @abstractmethod + def travel_range(self, value): + """ + Set the travel range of the stage + ---- + Tuple + (min_valid_position, max_valid_position) + """ + raise NotImplementedError() + + @abstractmethod + def move_relative(self, value): + "Move a relative distance from current position" + raise NotImplementedError() + + @abstractmethod + def move_absolute(self, value): + "Move to an absolute position" + raise NotImplementedError() + + @abstractmethod + def zero_position(self): + "Move the stage to 0 position" + raise NotImplementedError() From a512171f759c3faec689a8c8eaf03f5ea595c3a9 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Thu, 30 Nov 2023 23:53:53 -0800 Subject: [PATCH 10/60] basic connections between laser control UI and laser marker --- .../photom/demo/photom_calibration.py | 158 ++++++++++++------ copylot/assemblies/photom/photom.py | 1 + 2 files changed, 107 insertions(+), 52 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 9c14f030..cd3c8dd5 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -24,21 +24,22 @@ import numpy as np from copylot.assemblies.photom.photom import PhotomAssembly from pathlib import Path +from typing import Tuple DEMO_MODE = True # TODO: deal with the logic when clicking calibrate. Mirror dropdown # TODO: check that the calibration step is implemented properly. -# TODO: remove the self.affine_transform_obj from this file. This is now part of photom-assembly +# TODO: connect marker to actual mirror position class LaserWidget(QWidget): def __init__(self, laser): super().__init__() self.laser = laser - self.initUI() + self.initialize_UI() - def initUI(self): + def initialize_UI(self): layout = QVBoxLayout() laser_label = QLabel(self.laser.name) @@ -70,22 +71,40 @@ def update_power(self, value): self.power_label.setText(f"Power: {value}") +class QDoubleSlider(QSlider): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._multiplier = 1000 + + def value(self): + return super().value() / self._multiplier + + def setValue(self, val): + super().setValue(int(val * self._multiplier)) + + def setMinimum(self, val): + super().setMinimum(int(val * self._multiplier)) + + def setMaximum(self, val): + super().setMaximum(int(val * self._multiplier)) + + # TODO: connect widget to actual abstract mirror calls class MirrorWidget(QWidget): def __init__(self, mirror): super().__init__() self.mirror = mirror - self.initUI() + self.initialize_UI() - def initUI(self): + def initialize_UI(self): layout = QVBoxLayout() mirror_x_label = QLabel("Mirror X Position") layout.addWidget(mirror_x_label) self.mirror_x_slider = QSlider(Qt.Horizontal) - self.mirror_x_slider.setMinimum(0) - self.mirror_x_slider.setMaximum(100) + self.mirror_x_slider.setMinimum(-500) + self.mirror_x_slider.setMaximum(500) self.mirror_x_slider.valueChanged.connect(self.update_mirror_x) layout.addWidget(self.mirror_x_slider) @@ -97,8 +116,8 @@ def initUI(self): layout.addWidget(mirror_y_label) self.mirror_y_slider = QSlider(Qt.Horizontal) - self.mirror_y_slider.setMinimum(0) - self.mirror_y_slider.setMaximum(100) + self.mirror_y_slider.setMinimum(-500) + self.mirror_y_slider.setMaximum(500) self.mirror_y_slider.valueChanged.connect(self.update_mirror_y) layout.addWidget(self.mirror_y_slider) @@ -123,25 +142,40 @@ class PhotomApp(QMainWindow): def __init__( self, photom_assembly: PhotomAssembly, - photom_window: QMainWindow, - config_file: Path = None, + photom_window_size: Tuple[int, int] = (100, 100), demo_window=None, ): super().__init__() - self.photom_window = photom_window + self.photom_window = None + self.photom_controls_window = None + self.photom_assembly = photom_assembly self.lasers = self.photom_assembly.laser self.mirrors = self.photom_assembly.mirror - - self._calibrating_mirror_idx = None + self.photom_window_size = photom_window_size + self._current_mirror_idx = 0 if DEMO_MODE: self.demo_window = demo_window - self.initUI() + self.initialize_UI() + self.initializer_laser_marker_window() + + def initializer_laser_marker_window(self): + # Making the photom_window a square + + window_size = ( + self.photom_window_size[0], + 0, + self.photom_window_size[0], + self.photom_window_size[1], + ) + self.photom_window = LaserMarkerWindow( + photom_controls=self, name='Laser Marker', window_size=window_size + ) - def initUI(self): + def initialize_UI(self): """ Initialize the UI. @@ -176,8 +210,11 @@ def initUI(self): # Adding a group box for the mirror mirror_group = QGroupBox("Mirror") mirror_layout = QVBoxLayout() + + self.mirror_widgets = [] for mirror in self.mirrors: mirror_widget = MirrorWidget(mirror) + self.mirror_widgets.append(mirror_widget) mirror_layout.addWidget(mirror_widget) mirror_group.setLayout(mirror_layout) @@ -190,6 +227,8 @@ def initUI(self): self.mirror_dropdown = QComboBox() self.mirror_dropdown.addItems([mirror.name for mirror in self.mirrors]) main_layout.addWidget(self.mirror_dropdown) + self.mirror_dropdown.setCurrentIndex(self._current_mirror_idx) + self.mirror_dropdown.currentIndexChanged.connect(self.mirror_dropdown_changed) self.calibrate_button = QPushButton("Calibrate") self.calibrate_button.clicked.connect(self.calibrate) @@ -207,6 +246,13 @@ def initUI(self): self.setCentralWidget(main_widget) self.show() + def mirror_dropdown_changed(self, index): + print(f"Mirror dropdown changed to index {index}") + self._current_mirror_idx = index + + # Reset to (0,0) position + self.photom_assembly.mirror[self._current_mirror_idx].position = [0, 0] + def calibrate(self): # Implement your calibration function here print("Calibrating...") @@ -218,16 +264,16 @@ def calibrate(self): self.done_calibration_button.show() selected_mirror_name = self.mirror_dropdown.currentText() - self._calibrating_mirror_idx = next( + self._current_mirror_idx = next( i for i, mirror in enumerate(self.mirrors) if mirror.name == selected_mirror_name ) if not DEMO_MODE: # TODO: move in the pattern for calibration - self.photom_assembly.calibrate(self._calibrating_mirror_idx) + self.photom_assembly.calibrate(self._current_mirror_idx) else: - print(f'Calibrating mirror: {self._calibrating_mirror_idx}') + print(f'Calibrating mirror: {self._current_mirror_idx}') def done_calibration(self): # Perform any necessary actions after calibration is done @@ -240,7 +286,7 @@ def done_calibration(self): dest = np.array([[pt.x(), pt.y()] for pt in self.target_pts], dtype=np.float32) T_affine = self.photom_assembly.mirror[ - self._calibrating_mirror_idx + self._current_mirror_idx ].affine_transform_obj.get_affine_matrix(dest, origin) # logger.debug(f"Affine matrix: {T_affine}") print(f"Affine matrix: {T_affine}") @@ -255,7 +301,7 @@ def done_calibration(self): # Save the matrix self.photom_assembly.mirror[ - self._calibrating_mirror_idx + self._current_mirror_idx ].affine_transform_obj.save_matrix(matrix=T_affine, config_file=typed_filename) # Hide the "Done Calibration" button @@ -266,11 +312,11 @@ def done_calibration(self): print(f'dest: {dest}') # transformed_coords = self.affine_trans_obj.apply_affine(dest) transformed_coords = self.photom_assembly.mirror[ - self._calibrating_mirror_idx + self._current_mirror_idx ].affine_transform_obj.apply_affine(dest) print(transformed_coords) coords_list = self.photom_assembly.mirror[ - self._calibrating_mirror_idx + self._current_mirror_idx ].affine_transform_obj.trans_pointwise(transformed_coords) print(coords_list) self.demo_window.updateVertices(coords_list) @@ -310,10 +356,16 @@ def display_rectangle(self): class LaserMarkerWindow(QMainWindow): - def __init__(self, name="Laser Marker", window_size=(100, 100, 100, 100)): + def __init__( + self, + photom_controls: QMainWindow = None, + name="Laser Marker", + window_size=(100, 100, 100, 100), + ): super().__init__() + self.photom_controls = photom_controls self.window_name = name - self.windowGeo = window_size + self.window_geometry = window_size self.setMouseTracking(True) self.mouseX = None self.mouseY = None @@ -328,23 +380,23 @@ def __init__(self, name="Laser Marker", window_size=(100, 100, 100, 100)): self.initMarker() self.init_tetragon() - self.initUI() + self.initialize_UI() self.setCentralWidget(self.stacked_widget) - def initUI(self): + def initialize_UI(self): self.setGeometry( - self.windowGeo[0], - self.windowGeo[1], - self.windowGeo[2], - self.windowGeo[3], + self.window_geometry[0], + self.window_geometry[1], + self.window_geometry[2], + self.window_geometry[3], ) self.setWindowTitle(self.window_name) # Fix the size of the window self.setFixedSize( - self.windowGeo[2], - self.windowGeo[3], + self.window_geometry[2], + self.window_geometry[3], ) self.switch_to_shooting_scene() self.show() @@ -395,7 +447,7 @@ def updateVertices(self, new_coordinates): vertex.setPos(x, y) def recordinate(self, rawcord): - return -self.scale * (rawcord - (self.windowGeo[2] / 2)) / 50 + return -self.scale * (rawcord - (self.window_geometry[2] / 2)) / 50 def mouseMoveEvent(self, event: "QGraphicsSceneMouseEvent"): new_cursor_position = event.screenPos() @@ -405,8 +457,21 @@ def mouseMoveEvent(self, event: "QGraphicsSceneMouseEvent"): def mousePressEvent(self, event): marker_x = self.marker.pos().x() marker_y = self.marker.pos().y() - print(f"x position: {(marker_x, marker_y)}") + print(f"x position (x,y): {(marker_x, marker_y)}") # print('Mouse press coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + # Update the mirror slider values + self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror_x_slider.setValue(int(self.marker.pos().x())) + self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror_y_slider.setValue(int(self.marker.pos().y())) + + # Update the photom_assembly mirror position + # self.photom_controls.mirror[self._current_mirror_idx].position = [ + # self.marker.pos().x(), + # self.marker.pos().y(), + # ] def mouseReleaseEvent(self, event): print("Mouse release coords: ( %f : %f )" % (self.mouseX, self.mouseY)) @@ -488,32 +553,20 @@ def move(self, x_position, y_position): # Define the positions and sizes for the windows screen_width = app.desktop().screenGeometry().width() screen_height = app.desktop().screenGeometry().height() - ctrl_window_width = screen_width // 3 # Adjust the width as needed ctrl_window_height = screen_height // 3 # Use the full screen height - # Making the photom_window a square - photom_window_width = screen_width // 3 # Adjust the width as needed - photom_window_height = screen_width // 3 # Adjust the width as needed - - photom_window_size = ( - ctrl_window_width, - 0, - photom_window_width, - photom_window_height, - ) - photom_window = LaserMarkerWindow(window_size=photom_window_size) - if DEMO_MODE: camera_window = LaserMarkerWindow( - name="Mock laser dots", window_size=photom_window_size + name="Mock laser dots", + window_size=(ctrl_window_width, 0, ctrl_window_width, ctrl_window_width), ) # Set the positions of the windows ctrl_window = PhotomApp( photom_assembly=photom_assembly, - photom_window=photom_window, + photom_window_size=(ctrl_window_width, ctrl_window_width), demo_window=camera_window, ) - ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_height) + ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_width) # Set the camera window to the calibration scene camera_window.switch_to_calibration_scene() @@ -530,7 +583,8 @@ def move(self, x_position, y_position): else: # Set the positions of the windows ctrl_window = PhotomApp( - photom_assembly=photom_assembly, photom_window=photom_window + photom_assembly=photom_assembly, + photom_window_size=(ctrl_window_width, ctrl_window_height), ) ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_height) diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index 9274bdbc..4820b861 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -35,6 +35,7 @@ def calibrate(self, mirror_index: int): else: pass + # TODO probably will replace the camera with zyx or yx image array input ## Camera Functions def capture(self): pass From 7f17fd4ea23eb7977f68dbc219d8636f105989b6 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Fri, 1 Dec 2023 09:37:56 -0800 Subject: [PATCH 11/60] add cancel calibration button and fix saving of matrix --- .../photom/demo/photom_calibration.py | 92 ++++++++++++------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index cd3c8dd5..77f5e9a2 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -202,8 +202,10 @@ def initialize_UI(self): # Adding a group box for the lasers laser_group = QGroupBox("Lasers") laser_layout = QVBoxLayout() + self.laser_widgets = [] for laser in self.lasers: laser_widget = LaserWidget(laser) + self.laser_widgets.append(laser_widget) laser_layout.addWidget(laser_widget) laser_group.setLayout(laser_layout) @@ -240,6 +242,11 @@ def initialize_UI(self): self.done_calibration_button.hide() main_layout.addWidget(self.done_calibration_button) + # Add a "Cancel Calibration" button (initially hidden) + self.cancel_calibration_button = QPushButton("Cancel Calibration") + self.cancel_calibration_button.clicked.connect(self.cancel_calibration) + self.cancel_calibration_button.hide() + main_layout.addWidget(self.cancel_calibration_button) main_widget = QWidget(self) main_widget.setLayout(main_layout) @@ -258,6 +265,11 @@ def calibrate(self): print("Calibrating...") # Hide the 'X' marker in photom_window # self.photom_window.marker.hide() + # Hide the calibrate button + self.calibrate_button.hide() + # Show the "Cancel Calibration" button + self.cancel_calibration_button.show() + # Display the rectangle self.display_rectangle() self.source_pts = self.photom_window.get_coordinates() # Show the "Done Calibration" button @@ -275,10 +287,22 @@ def calibrate(self): else: print(f'Calibrating mirror: {self._current_mirror_idx}') + def cancel_calibration(self): + # Implement your cancel calibration function here + print("Canceling calibration...") + # Hide the "Done Calibration" button + self.done_calibration_button.hide() + # Show the "Calibrate" button + self.calibrate_button.show() + # Show the "X" marker in photom_window + self.photom_window.marker.show() + + self.cancel_calibration_button.hide() + # Switch back to the shooting scene + self.photom_window.switch_to_shooting_scene() + def done_calibration(self): # Perform any necessary actions after calibration is done - self.photom_window.switch_to_shooting_scene() - self.photom_window.marker.show() self.target_pts = self.photom_window.get_coordinates() origin = np.array( [[pt.x(), pt.y()] for pt in self.source_pts], dtype=np.float32 @@ -291,6 +315,7 @@ def done_calibration(self): # logger.debug(f"Affine matrix: {T_affine}") print(f"Affine matrix: {T_affine}") + # Save the affine matrix to a file typed_filename, _ = QFileDialog.getSaveFileName( self, "Save File", "", "YAML Files (*.yml)" ) @@ -298,29 +323,34 @@ def done_calibration(self): if not typed_filename.endswith(".yml"): typed_filename += ".yml" print("Selected file:", typed_filename) - - # Save the matrix - self.photom_assembly.mirror[ - self._current_mirror_idx - ].affine_transform_obj.save_matrix(matrix=T_affine, config_file=typed_filename) - - # Hide the "Done Calibration" button - self.done_calibration_button.hide() - - if DEMO_MODE: - print(f'origin: {origin}') - print(f'dest: {dest}') - # transformed_coords = self.affine_trans_obj.apply_affine(dest) - transformed_coords = self.photom_assembly.mirror[ + # Save the matrix + self.photom_assembly.mirror[ self._current_mirror_idx - ].affine_transform_obj.apply_affine(dest) - print(transformed_coords) - coords_list = self.photom_assembly.mirror[ - self._current_mirror_idx - ].affine_transform_obj.trans_pointwise(transformed_coords) - print(coords_list) - self.demo_window.updateVertices(coords_list) - + ].affine_transform_obj.save_matrix( + matrix=T_affine, config_file=typed_filename + ) + self.photom_window.switch_to_shooting_scene() + self.photom_window.marker.show() + + # Hide the "Done Calibration" button + self.done_calibration_button.hide() + if DEMO_MODE: + print(f'origin: {origin}') + print(f'dest: {dest}') + # transformed_coords = self.affine_trans_obj.apply_affine(dest) + transformed_coords = self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.apply_affine(dest) + print(transformed_coords) + coords_list = self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.trans_pointwise(transformed_coords) + print(coords_list) + self.demo_window.updateVertices(coords_list) + return + else: + print("No file selected") + # Show dialog box saying no file selected print("Calibration done") def update_transparency(self, value): @@ -451,7 +481,6 @@ def recordinate(self, rawcord): def mouseMoveEvent(self, event: "QGraphicsSceneMouseEvent"): new_cursor_position = event.screenPos() - print(f"current x: {new_cursor_position}") def mousePressEvent(self, event): @@ -460,12 +489,13 @@ def mousePressEvent(self, event): print(f"x position (x,y): {(marker_x, marker_y)}") # print('Mouse press coords: ( %f : %f )' % (self.mouseX, self.mouseY)) # Update the mirror slider values - self.photom_controls.mirror_widgets[ - self.photom_controls._current_mirror_idx - ].mirror_x_slider.setValue(int(self.marker.pos().x())) - self.photom_controls.mirror_widgets[ - self.photom_controls._current_mirror_idx - ].mirror_y_slider.setValue(int(self.marker.pos().y())) + if self.photom_controls is not None: + self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror_x_slider.setValue(int(self.marker.pos().x())) + self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror_y_slider.setValue(int(self.marker.pos().y())) # Update the photom_assembly mirror position # self.photom_controls.mirror[self._current_mirror_idx].position = [ From 60071cd299527e97c1ad0af8e7b5b775f887b2bb Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Fri, 1 Dec 2023 10:44:45 -0800 Subject: [PATCH 12/60] updating mock laser and mirror for debugging to react to UI --- .../photom/demo/photom_calibration.py | 127 ++++++++++++++---- copylot/hardware/mirrors/abstract_mirror.py | 28 +++- copylot/hardware/mirrors/optotune/mirror.py | 20 ++- 3 files changed, 143 insertions(+), 32 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 77f5e9a2..16c758b0 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -37,36 +37,51 @@ class LaserWidget(QWidget): def __init__(self, laser): super().__init__() self.laser = laser + + self.emission_state = 0 # 0 = off, 1 = on + + self.initializer_laser self.initialize_UI() + def initializer_laser(self): + self.laser.toggle_emission = self.emission_state + def initialize_UI(self): layout = QVBoxLayout() - laser_label = QLabel(self.laser.name) - layout.addWidget(laser_label) + self.laser_label = QLabel(self.laser.name) + layout.addWidget(self.laser_label) - laser_power_slider = QSlider(Qt.Horizontal) - laser_power_slider.setMinimum(0) - laser_power_slider.setMaximum(100) - laser_power_slider.setValue(self.laser.power) - laser_power_slider.valueChanged.connect(self.update_power) - layout.addWidget(laser_power_slider) + self.laser_power_slider = QSlider(Qt.Horizontal) + self.laser_power_slider.setMinimum(0) + self.laser_power_slider.setMaximum(100) + self.laser_power_slider.setValue(self.laser.power) + self.laser_power_slider.valueChanged.connect(self.update_power) + layout.addWidget(self.laser_power_slider) # Add a QLabel to display the power value self.power_label = QLabel(f"Power: {self.laser.power}") layout.addWidget(self.power_label) - laser_toggle_button = QPushButton("Toggle") - laser_toggle_button.clicked.connect(self.toggle_laser) - layout.addWidget(laser_toggle_button) + self.laser_toggle_button = QPushButton("Toggle") + self.laser_toggle_button.clicked.connect(self.toggle_laser) + # make it background red if laser is off + if self.emission_state == 0: + self.laser_toggle_button.setStyleSheet("background-color: magenta") + layout.addWidget(self.laser_toggle_button) self.setLayout(layout) def toggle_laser(self): - self.laser.toggle() + self.emission_state = self.emission_state ^ 1 + self.laser.toggle_emission = self.emission_state + if self.emission_state == 0: + self.laser_toggle_button.setStyleSheet("background-color: magenta") + else: + self.laser_toggle_button.setStyleSheet("background-color: green") def update_power(self, value): - self.laser.set_power(value) + self.laser.laser_power = value # Update the QLabel with the new power value self.power_label.setText(f"Power: {value}") @@ -109,7 +124,7 @@ def initialize_UI(self): layout.addWidget(self.mirror_x_slider) # Add a QLabel to display the mirror X value - self.mirror_x_label = QLabel(f"X: {self.mirror.x}") + self.mirror_x_label = QLabel(f"X: {self.mirror.position_x}") layout.addWidget(self.mirror_x_label) mirror_y_label = QLabel("Mirror Y Position") @@ -122,18 +137,18 @@ def initialize_UI(self): layout.addWidget(self.mirror_y_slider) # Add a QLabel to display the mirror Y value - self.mirror_y_label = QLabel(f"Y: {self.mirror.y}") + self.mirror_y_label = QLabel(f"Y: {self.mirror.position_y}") layout.addWidget(self.mirror_y_label) self.setLayout(layout) def update_mirror_x(self, value): - self.mirror.x = value + self.mirror.position_x = value # Update the QLabel with the new X value self.mirror_x_label.setText(f"X: {value}") def update_mirror_y(self, value): - self.mirror.y = value + self.mirror.position_y = value # Update the QLabel with the new Y value self.mirror_y_label.setText(f"Y: {value}") @@ -519,22 +534,76 @@ def __init__(self, name, power=0, **kwargs): self.power = power self.laser_on = False - def toggle(self): - self.laser_on = not self.laser_on - - def set_power(self, power): + @property + def toggle_emission(self): + """ + Toggles Laser Emission On and Off + (1 = On, 0 = Off) + """ + print('Toggling laser emission') + return self._toggle_emission + + @toggle_emission.setter + def toggle_emission(self, value): + """ + Toggles Laser Emission On and Off + (1 = On, 0 = Off) + """ + self._toggle_emission = value + print(f'Laser emission set to {value}') + + @property + def laser_power(self): + return self.power + + @laser_power.setter + def laser_power(self, power): self.power = power + print(f'Laser power set to {power}') class MockMirror: - def __init__(self, name, x_position=0, y_position=0, **kwargs): + def __init__(self, name, pos_x=0, pos_y=0, **kwargs): # Initialize the mock mirror with the given x and y positions self.name = name - self.x = x_position - self.y = y_position - def move(self, x_position, y_position): - # Move the mock mirror to the specified x and y positions - pass + self.pos_x = pos_x + self.pos_y = pos_y + + self.position = (self.pos_x, self.pos_y) + + @property + def position(self): + print(f'Getting mirror position ({self.pos_x}, {self.pos_y})') + return self.position_x, self.position_y + + @position.setter + def position(self, value: Tuple[float, float]): + self.position_x = value[0] + self.position_y = value[1] + print(f'Mirror position set to {value}') + + @property + def position_x(self) -> float: + """Get the current mirror position X""" + print(f'Position_X {self.pos_x}') + return self.pos_x + + @position_x.setter + def position_x(self, value: float): + """Set the mirror position X""" + self.pos_x = value + print(f'Position_X {self.pos_x}') + + @property + def position_y(self) -> float: + """Get the current mirror position Y""" + return self.pos_y + + @position_y.setter + def position_y(self, value: float): + """Set the mirror position Y""" + self.pos_y = value + print(f'Position_Y {self.pos_y}') Laser = MockLaser Mirror = MockMirror @@ -561,8 +630,8 @@ def move(self, x_position, y_position): mirrors = [ Mirror( name=mirror_data["name"], - x_position=mirror_data["x_position"], - y_position=mirror_data["y_position"], + pos_x=mirror_data["x_position"], + pos_y=mirror_data["y_position"], ) for mirror_data in config["mirrors"] ] # Initial mirror position diff --git a/copylot/hardware/mirrors/abstract_mirror.py b/copylot/hardware/mirrors/abstract_mirror.py index 1fbe95d4..d3cd9665 100644 --- a/copylot/hardware/mirrors/abstract_mirror.py +++ b/copylot/hardware/mirrors/abstract_mirror.py @@ -18,13 +18,37 @@ def __init__(self): @property @abstractmethod def position(self) -> Tuple[float, float]: - """Get the current mirror position""" + """Get the current mirror position XY""" pass @position.setter @abstractmethod def position(self, value: Tuple[float, float]): - """Set the mirror position""" + """Set the mirror position XY""" + pass + + @property + @abstractmethod + def position_x(self) -> float: + """Get the current mirror position X""" + pass + + @position_x.setter + @abstractmethod + def position_x(self, value: float): + """Set the mirror position X""" + pass + + @property + @abstractmethod + def position_y(self) -> float: + """Get the current mirror position Y""" + pass + + @position_y.setter + @abstractmethod + def position_y(self, value: float): + """Set the mirror position Y""" pass @property diff --git a/copylot/hardware/mirrors/optotune/mirror.py b/copylot/hardware/mirrors/optotune/mirror.py index 86a2392c..8c3445b7 100644 --- a/copylot/hardware/mirrors/optotune/mirror.py +++ b/copylot/hardware/mirrors/optotune/mirror.py @@ -9,6 +9,7 @@ from copylot import logger from copylot.hardware.mirrors.optotune import optoMDC from copylot.hardware.mirrors.abstract_mirror import AbstractMirror +from typing import Tuple class OptoMirror(AbstractMirror): @@ -40,7 +41,7 @@ def __del__(self): logger.info("mirror disconnected") @property - def positions(self): + def position(self): """ Returns ------- @@ -51,6 +52,23 @@ def positions(self): """ return self.position_x, self.position_y + @position.setter + def position(self, value: Tuple[float, float]): + """ + Parameters + ---------- + value:Tuple[float,float] + The normalized angular value, value = theta/tan(50degree) + 50 degree is the maximum optical deflection angle for each direction. + Here x has a range limits of [-1,1] , The combination of value for x-axis and y-axis should be less than 1 + (ex. x^2+y^1<1) + when |x|<0.7 and |y| <0.7 any combination works. otherwise, one will be reduced to value to + nearest edge of the unit circle + """ + self.position_x = value[0] + self.position_y = value[1] + logger.info(f"Position set to: {value}") + @property def position_x(self): """ From 95853f0d4a4aa1f2b6faa397d3a8ef7c83e9865b Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Fri, 1 Dec 2023 14:32:19 -0800 Subject: [PATCH 13/60] mock laser initializiation bug and calibratoin buttons fix to not appearing --- .../photom/demo/photom_calibration.py | 28 +++++++++++-------- copylot/assemblies/photom/photom.py | 4 ++- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 16c758b0..cff1f7ca 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -40,7 +40,7 @@ def __init__(self, laser): self.emission_state = 0 # 0 = off, 1 = on - self.initializer_laser + self.initializer_laser() self.initialize_UI() def initializer_laser(self): @@ -55,12 +55,12 @@ def initialize_UI(self): self.laser_power_slider = QSlider(Qt.Horizontal) self.laser_power_slider.setMinimum(0) self.laser_power_slider.setMaximum(100) - self.laser_power_slider.setValue(self.laser.power) + self.laser_power_slider.setValue(self.laser.laser_power) self.laser_power_slider.valueChanged.connect(self.update_power) layout.addWidget(self.laser_power_slider) # Add a QLabel to display the power value - self.power_label = QLabel(f"Power: {self.laser.power}") + self.power_label = QLabel(f"Power: {self.laser.laser_power}") layout.addWidget(self.power_label) self.laser_toggle_button = QPushButton("Toggle") @@ -349,6 +349,9 @@ def done_calibration(self): # Hide the "Done Calibration" button self.done_calibration_button.hide() + self.calibrate_button.show() + self.cancel_calibration_button.hide() + if DEMO_MODE: print(f'origin: {origin}') print(f'dest: {dest}') @@ -531,16 +534,18 @@ class MockLaser: def __init__(self, name, power=0, **kwargs): # Initialize the mock laser self.name = name - self.power = power self.laser_on = False + self.toggle_emission = 0 + self.laser_power = power + @property def toggle_emission(self): """ Toggles Laser Emission On and Off (1 = On, 0 = Off) """ - print('Toggling laser emission') + print(f'Toggling laser {self.name} emission') return self._toggle_emission @toggle_emission.setter @@ -549,17 +554,18 @@ def toggle_emission(self, value): Toggles Laser Emission On and Off (1 = On, 0 = Off) """ + print(f'Laser {self.name} emission set to {value}') self._toggle_emission = value - print(f'Laser emission set to {value}') @property def laser_power(self): + print(f'Laser {self.name} power: {self.power}') return self.power @laser_power.setter def laser_power(self, power): self.power = power - print(f'Laser power set to {power}') + print(f'Laser {self.name} power set to {power}') class MockMirror: def __init__(self, name, pos_x=0, pos_y=0, **kwargs): @@ -580,19 +586,19 @@ def position(self): def position(self, value: Tuple[float, float]): self.position_x = value[0] self.position_y = value[1] - print(f'Mirror position set to {value}') + print(f'Mirror {self.name} position set to {value}') @property def position_x(self) -> float: """Get the current mirror position X""" - print(f'Position_X {self.pos_x}') + print(f'Mirror {self.name} Position_X {self.pos_x}') return self.pos_x @position_x.setter def position_x(self, value: float): """Set the mirror position X""" self.pos_x = value - print(f'Position_X {self.pos_x}') + print(f'Mirror {self.name} Position_X {self.pos_x}') @property def position_y(self) -> float: @@ -603,7 +609,7 @@ def position_y(self) -> float: def position_y(self, value: float): """Set the mirror position Y""" self.pos_y = value - print(f'Position_Y {self.pos_y}') + print(f'Mirror {self.name} Position_Y {self.pos_y}') Laser = MockLaser Mirror = MockMirror diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index 4820b861..b08f9904 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -31,9 +31,11 @@ def __init__( def calibrate(self, mirror_index: int): if mirror_index < len(self.mirror): + # Logic for calibrating the mirror pass + # moving the mirror in a rectangle else: - pass + raise IndexError("Mirror index out of range.") # TODO probably will replace the camera with zyx or yx image array input ## Camera Functions From 012b03953497869c1e123292a90b7383dcd5e048 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Fri, 1 Dec 2023 15:49:19 -0800 Subject: [PATCH 14/60] add demo config for lasers --- copylot/assemblies/photom/demo/affine_T.yml | 4 ++++ copylot/assemblies/photom/demo/affine_T_2.yml | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 copylot/assemblies/photom/demo/affine_T.yml create mode 100644 copylot/assemblies/photom/demo/affine_T_2.yml diff --git a/copylot/assemblies/photom/demo/affine_T.yml b/copylot/assemblies/photom/demo/affine_T.yml new file mode 100644 index 00000000..6145c36c --- /dev/null +++ b/copylot/assemblies/photom/demo/affine_T.yml @@ -0,0 +1,4 @@ +affine_transform_yx: +- [1.0, 0.0, 0.0] +- [0.0, 1.0, 0.0] +- [0.0, 0.0, 1.0] \ No newline at end of file diff --git a/copylot/assemblies/photom/demo/affine_T_2.yml b/copylot/assemblies/photom/demo/affine_T_2.yml new file mode 100644 index 00000000..99e22a83 --- /dev/null +++ b/copylot/assemblies/photom/demo/affine_T_2.yml @@ -0,0 +1,4 @@ +affine_transform_yx: +- [2.0, 0.0, 0.0] +- [0.0, 2.0, 0.0] +- [0.0, 0.0, 1.0] \ No newline at end of file From ac7f824d0fa23493c22c6e2b7c7534c61dca3361 Mon Sep 17 00:00:00 2001 From: Rachel Banks Date: Wed, 6 Dec 2023 17:48:13 -0800 Subject: [PATCH 15/60] initial fixes to in-person test --- .../photom/demo/demo_photom_assembly.py | 33 ++++++++ .../photom/demo/photom_VIS_config.yml | 12 +++ copylot/assemblies/photom/photom.py | 18 ++-- .../assemblies/photom/photom_mock_devices.py | 83 +++++++++++++++++++ copylot/hardware/mirrors/optotune/mirror.py | 55 ++++++++++++ setup.cfg | 1 + 6 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 copylot/assemblies/photom/demo/demo_photom_assembly.py create mode 100644 copylot/assemblies/photom/demo/photom_VIS_config.yml create mode 100644 copylot/assemblies/photom/photom_mock_devices.py diff --git a/copylot/assemblies/photom/demo/demo_photom_assembly.py b/copylot/assemblies/photom/demo/demo_photom_assembly.py new file mode 100644 index 00000000..8a0bba58 --- /dev/null +++ b/copylot/assemblies/photom/demo/demo_photom_assembly.py @@ -0,0 +1,33 @@ + +#%% +from copylot.assemblies.photom.photom import PhotomAssembly +from copylot.hardware.mirrors.optotune.mirror import OptoMirror +from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror + +#%% + +laser = MockLaser('Mock Laser', power=0) +mirror = OptoMirror(com_port='COM8') + +#%% +mirror.position = (0.009,0.0090) +# %% +mirror.position = (0.000,0.000) + +# %% +photom_device = PhotomAssembly(laser=[laser], mirror=[mirror], + affine_matrix_path=[r'C:\Users\ZebraPhysics\Documents\GitHub\coPylot\copylot\assemblies\photom\demo\affine_T.yml'], + ) +# %% +photom_device? +# %% + +curr_pos = photom_device.get_position(mirror_index=0) + +print(curr_pos) +#%% +photom_device.set_position(mirror_index=0,position=[0.009,0.009]) +curr_pos = photom_device.get_position(mirror_index=0) +print(curr_pos) + +# %% diff --git a/copylot/assemblies/photom/demo/photom_VIS_config.yml b/copylot/assemblies/photom/demo/photom_VIS_config.yml new file mode 100644 index 00000000..ea096d2c --- /dev/null +++ b/copylot/assemblies/photom/demo/photom_VIS_config.yml @@ -0,0 +1,12 @@ +lasers: + - name: laser_1 + power: 50 + - name: laser_2 + power: 30 + +mirrors: + - name: mirror_1_VIS + COM_port: COM8 + x_position: 0 + y_position: 0 + affine_matrix_path: C:\Users\ZebraPhysics\Documents\GitHub\coPylot\copylot\assemblies\photom\demo\affine_T.yml \ No newline at end of file diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index b08f9904..9ab9e562 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from re import T from copylot.hardware.cameras.abstract_camera import AbstractCamera from copylot.hardware.mirrors.abstract_mirror import AbstractMirror from copylot.hardware.lasers.abstract_laser import AbstractLaser @@ -6,7 +7,8 @@ from copylot.hardware.daqs.abstract_daq import AbstractDAQ from copylot.assemblies.photom.utils.affine_transform import AffineTransform from pathlib import Path - +from copylot import logger +from typing import Tuple class PhotomAssembly: def __init__( @@ -43,18 +45,16 @@ def capture(self): pass ## Mirror Functions - @property - def position(self, mirror_index: int, position: list[float]): + def get_position(self, mirror_index: int)->list[float, float]: if mirror_index < len(self.mirror): if self.DAC is None: NotImplementedError("No DAC found.") else: - return self.mirror[mirror_index].position + return list(self.mirror[mirror_index].position) else: raise IndexError("Mirror index out of range.") - @position.setter - def position(self, mirror_index: int, position: list[float]): + def set_position(self, mirror_index: int, position: list[float]): if mirror_index < len(self.mirror): if self.DAC is None: NotImplementedError("No DAC found.") @@ -66,10 +66,8 @@ def position(self, mirror_index: int, position: list[float]): raise IndexError("Mirror index out of range.") ## LASER Fuctions - @property - def laser_power(self, laser_index: int, power: float): + def get_laser_power(self, laser_index: int, power: float): return self.laser[laser_index].power - @laser_power.setter - def change_laser_power(self, laser_index: int, power: float): + def set_laser_power(self, laser_index: int, power: float): self.laser[laser_index].power = power diff --git a/copylot/assemblies/photom/photom_mock_devices.py b/copylot/assemblies/photom/photom_mock_devices.py new file mode 100644 index 00000000..9743dc67 --- /dev/null +++ b/copylot/assemblies/photom/photom_mock_devices.py @@ -0,0 +1,83 @@ +from typing import Tuple + +class MockLaser: + def __init__(self, name, power=0, **kwargs): + # Initialize the mock laser + self.name = name + self.laser_on = False + + self.toggle_emission = 0 + self.laser_power = power + + @property + def toggle_emission(self): + """ + Toggles Laser Emission On and Off + (1 = On, 0 = Off) + """ + print(f'Toggling laser {self.name} emission') + return self._toggle_emission + + @toggle_emission.setter + def toggle_emission(self, value): + """ + Toggles Laser Emission On and Off + (1 = On, 0 = Off) + """ + print(f'Laser {self.name} emission set to {value}') + self._toggle_emission = value + + @property + def laser_power(self): + print(f'Laser {self.name} power: {self.power}') + return self.power + + @laser_power.setter + def laser_power(self, power): + self.power = power + print(f'Laser {self.name} power set to {power}') + +class MockMirror: + def __init__(self, name, pos_x=0, pos_y=0, **kwargs): + # Initialize the mock mirror with the given x and y positions + self.name = name + + self.pos_x = pos_x + self.pos_y = pos_y + + self.position = (self.pos_x, self.pos_y) + + @property + def position(self): + print(f'Getting mirror position ({self.pos_x}, {self.pos_y})') + return self.position_x, self.position_y + + @position.setter + def position(self, value: Tuple[float, float]): + self.position_x = value[0] + self.position_y = value[1] + print(f'Mirror {self.name} position set to {value}') + + @property + def position_x(self) -> float: + """Get the current mirror position X""" + print(f'Mirror {self.name} Position_X {self.pos_x}') + return self.pos_x + + @position_x.setter + def position_x(self, value: float): + """Set the mirror position X""" + self.pos_x = value + print(f'Mirror {self.name} Position_X {self.pos_x}') + + @property + def position_y(self) -> float: + """Get the current mirror position Y""" + return self.pos_y + + @position_y.setter + def position_y(self, value: float): + """Set the mirror position Y""" + self.pos_y = value + print(f'Mirror {self.name} Position_Y {self.pos_y}') + diff --git a/copylot/hardware/mirrors/optotune/mirror.py b/copylot/hardware/mirrors/optotune/mirror.py index 8c3445b7..6c3b45dc 100644 --- a/copylot/hardware/mirrors/optotune/mirror.py +++ b/copylot/hardware/mirrors/optotune/mirror.py @@ -126,3 +126,58 @@ def position_y(self, value): """ self.channel_y.StaticInput.SetXY(value) logger.info(f"position_y set to: {value}") + + @property + def relative_position(self) -> Tuple[float, float]: + """Get the current relative mirror position""" + pass + + @relative_position.setter + def relative_position(self, value: Tuple[float, float]): + """Set the relative mirror position""" + pass + + @property + def movement_limits(self) -> Tuple[float, float, float, float]: + """Get the current mirror movement limits""" + pass + + @movement_limits.setter + def movement_limits(self, value: Tuple[float, float, float, float]): + """Set the mirror movement limits""" + pass + + @property + def step_resolution(self) -> float: + """Get the current mirror step resolution""" + pass + + @step_resolution.setter + def step_resolution(self, value: float): + """Set the mirror step resolution""" + pass + + + def set_home(self): + """Set the mirror home position""" + pass + + + def set_origin(self, axis: str): + """Set the mirror origin for a specific axis""" + pass + + @property + def external_drive_control(self) -> str: + """Get the current mirror drive mode""" + pass + + @external_drive_control.setter + def external_drive_control(self, value: bool): + """Set the mirror drive mode""" + pass + + + def voltage_to_position(self, voltage: Tuple[float, float]) -> Tuple[float, float]: + """Convert voltage to position""" + pass diff --git a/setup.cfg b/setup.cfg index 477b5030..de511fe2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,3 +35,4 @@ install_requires = qtpy>=1.11.2 scikit-image>=0.19.3 vispy>=0.11.0 + pydantic From a8249a644e881ed29313140379e525425df38ded Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Thu, 7 Dec 2023 16:43:16 -0800 Subject: [PATCH 16/60] adding calibration and movable demo --- copylot/assemblies/photom/photom.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index 9ab9e562..931accf3 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -6,9 +6,14 @@ from copylot.hardware.stages.abstract_stage import AbstractStage from copylot.hardware.daqs.abstract_daq import AbstractDAQ from copylot.assemblies.photom.utils.affine_transform import AffineTransform +from copylot.assemblies.photom.utils.scanning_algorithms import ( + calculate_rectangle_corners, +) from pathlib import Path from copylot import logger from typing import Tuple +import time + class PhotomAssembly: def __init__( @@ -25,16 +30,27 @@ def __init__( self.mirror = mirror self.DAC = dac + self._calibrating = False + assert len(self.mirror) == len(affine_matrix_path) # Apply AffineTransform to each mirror for i, tx_path in enumerate(affine_matrix_path): self.mirror[i].affine_transform_obj = AffineTransform(config_file=tx_path) - def calibrate(self, mirror_index: int): + def calibrate(self, mirror_index: int, rectangle_size_xy: tuple[int, int]): if mirror_index < len(self.mirror): - # Logic for calibrating the mirror - pass + print("Calibrating mirror...") + rectangle_coords = calculate_rectangle_corners(rectangle_size_xy) + # iterate over each corner and move the mirror + i = 0 + while self._calibrating: + # Logic for calibrating the mirror + self.position(mirror_index, rectangle_coords[i]) + time.sleep(1) + i += 1 + if i == 3: + i = 0 # moving the mirror in a rectangle else: raise IndexError("Mirror index out of range.") @@ -45,7 +61,7 @@ def capture(self): pass ## Mirror Functions - def get_position(self, mirror_index: int)->list[float, float]: + def get_position(self, mirror_index: int) -> list[float, float]: if mirror_index < len(self.mirror): if self.DAC is None: NotImplementedError("No DAC found.") From b6cf5e03ec7b783cf31094c4737485409930b90c Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Tue, 12 Dec 2023 10:23:34 -0800 Subject: [PATCH 17/60] moving calculating_rectangle to scanning utils --- .../photom/demo/demo_photom_assembly.py | 2 +- .../photom/demo/photom_calibration.py | 107 ++---------------- copylot/assemblies/photom/photom.py | 16 ++- .../photom/utils/scanning_algorithms.py | 14 +++ 4 files changed, 35 insertions(+), 104 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_photom_assembly.py b/copylot/assemblies/photom/demo/demo_photom_assembly.py index 8a0bba58..3b1379fd 100644 --- a/copylot/assemblies/photom/demo/demo_photom_assembly.py +++ b/copylot/assemblies/photom/demo/demo_photom_assembly.py @@ -23,7 +23,6 @@ # %% curr_pos = photom_device.get_position(mirror_index=0) - print(curr_pos) #%% photom_device.set_position(mirror_index=0,position=[0.009,0.009]) @@ -31,3 +30,4 @@ print(curr_pos) # %% +photom_device.calibrate(mirror_index=0, rectangle_size_xy=(0.01, 0.01)) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index cff1f7ca..9d628d41 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -20,17 +20,18 @@ QFileDialog, ) from PyQt5.QtGui import QColor, QPen -from copylot.assemblies.photom.utils.affine_transform import AffineTransform +from copylot.assemblies.photom.utils.scanning_algorithms import ( + calculate_rectangle_corners, +) import numpy as np from copylot.assemblies.photom.photom import PhotomAssembly -from pathlib import Path from typing import Tuple DEMO_MODE = True # TODO: deal with the logic when clicking calibrate. Mirror dropdown # TODO: check that the calibration step is implemented properly. -# TODO: connect marker to actual mirror position +# TODO: connect marker to actual mirror position. Unclear why it's not working. class LaserWidget(QWidget): @@ -377,19 +378,6 @@ def update_transparency(self, value): opacity = 1.0 - (transparency_percent / 100.0) # Calculate opacity (0.0 to 1.0) self.photom_window.setWindowOpacity(opacity) # Update photom_window opacity - def calculate_rectangle_corners(self, window_size): - # window_size is a tuple of (width, height) - - # Calculate the coordinates of the rectangle corners - x0y0 = ( - -window_size[0] / 2, - -window_size[1] / 2, - ) - x1y0 = (x0y0[0] + window_size[0], x0y0[1]) - x1y1 = (x0y0[0] + window_size[0], x0y0[1] + window_size[1]) - x0y1 = (x0y0[0], x0y0[1] + window_size[1]) - return x0y0, x1y0, x1y1, x0y1 - def display_rectangle(self): # Calculate the coordinates of the rectangle corners rectangle_scaling = 0.5 @@ -398,7 +386,7 @@ def display_rectangle(self): (window_size[0] * rectangle_scaling), (window_size[1] * rectangle_scaling), ) - rectangle_coords = self.calculate_rectangle_corners(rectangle_size) + rectangle_coords = calculate_rectangle_corners(rectangle_size) self.photom_window.updateVertices(rectangle_coords) self.photom_window.switch_to_calibration_scene() @@ -529,87 +517,7 @@ def mouseReleaseEvent(self, event): import os if DEMO_MODE: - - class MockLaser: - def __init__(self, name, power=0, **kwargs): - # Initialize the mock laser - self.name = name - self.laser_on = False - - self.toggle_emission = 0 - self.laser_power = power - - @property - def toggle_emission(self): - """ - Toggles Laser Emission On and Off - (1 = On, 0 = Off) - """ - print(f'Toggling laser {self.name} emission') - return self._toggle_emission - - @toggle_emission.setter - def toggle_emission(self, value): - """ - Toggles Laser Emission On and Off - (1 = On, 0 = Off) - """ - print(f'Laser {self.name} emission set to {value}') - self._toggle_emission = value - - @property - def laser_power(self): - print(f'Laser {self.name} power: {self.power}') - return self.power - - @laser_power.setter - def laser_power(self, power): - self.power = power - print(f'Laser {self.name} power set to {power}') - - class MockMirror: - def __init__(self, name, pos_x=0, pos_y=0, **kwargs): - # Initialize the mock mirror with the given x and y positions - self.name = name - - self.pos_x = pos_x - self.pos_y = pos_y - - self.position = (self.pos_x, self.pos_y) - - @property - def position(self): - print(f'Getting mirror position ({self.pos_x}, {self.pos_y})') - return self.position_x, self.position_y - - @position.setter - def position(self, value: Tuple[float, float]): - self.position_x = value[0] - self.position_y = value[1] - print(f'Mirror {self.name} position set to {value}') - - @property - def position_x(self) -> float: - """Get the current mirror position X""" - print(f'Mirror {self.name} Position_X {self.pos_x}') - return self.pos_x - - @position_x.setter - def position_x(self, value: float): - """Set the mirror position X""" - self.pos_x = value - print(f'Mirror {self.name} Position_X {self.pos_x}') - - @property - def position_y(self) -> float: - """Get the current mirror position Y""" - return self.pos_y - - @position_y.setter - def position_y(self, value: float): - """Set the mirror position Y""" - self.pos_y = value - print(f'Mirror {self.name} Position_Y {self.pos_y}') + from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror Laser = MockLaser Mirror = MockMirror @@ -621,6 +529,7 @@ def position_y(self, value: float): try: os.environ["DISPLAY"] = ":1003" + except: raise Exception("DISPLAY environment variable not set") @@ -681,7 +590,7 @@ def position_y(self, value: float): (window_size[0] * rectangle_scaling), (window_size[1] * rectangle_scaling), ) - rectangle_coords = ctrl_window.calculate_rectangle_corners(rectangle_size) + rectangle_coords = calculate_rectangle_corners(rectangle_size) # translate each coordinate by the offset rectangle_coords = [(x + 30, y) for x, y in rectangle_coords] camera_window.updateVertices(rectangle_coords) diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index 931accf3..030468f1 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -46,7 +46,7 @@ def calibrate(self, mirror_index: int, rectangle_size_xy: tuple[int, int]): i = 0 while self._calibrating: # Logic for calibrating the mirror - self.position(mirror_index, rectangle_coords[i]) + self.set_position(mirror_index, rectangle_coords[i]) time.sleep(1) i += 1 if i == 3: @@ -55,6 +55,12 @@ def calibrate(self, mirror_index: int, rectangle_size_xy: tuple[int, int]): else: raise IndexError("Mirror index out of range.") + def stop_mirror(self, mirror_index: int): + if mirror_index < len(self.mirror): + self._calibrating = False + else: + raise IndexError("Mirror index out of range.") + # TODO probably will replace the camera with zyx or yx image array input ## Camera Functions def capture(self): @@ -66,7 +72,8 @@ def get_position(self, mirror_index: int) -> list[float, float]: if self.DAC is None: NotImplementedError("No DAC found.") else: - return list(self.mirror[mirror_index].position) + position = self.mirror[mirror_index].position + return list(position) else: raise IndexError("Mirror index out of range.") @@ -82,8 +89,9 @@ def set_position(self, mirror_index: int, position: list[float]): raise IndexError("Mirror index out of range.") ## LASER Fuctions - def get_laser_power(self, laser_index: int, power: float): - return self.laser[laser_index].power + def get_laser_power(self, laser_index: int) -> float: + power = self.laser[laser_index].power + return power def set_laser_power(self, laser_index: int, power: float): self.laser[laser_index].power = power diff --git a/copylot/assemblies/photom/utils/scanning_algorithms.py b/copylot/assemblies/photom/utils/scanning_algorithms.py index 4145eae7..0407a4a4 100644 --- a/copylot/assemblies/photom/utils/scanning_algorithms.py +++ b/copylot/assemblies/photom/utils/scanning_algorithms.py @@ -194,3 +194,17 @@ def generate_rect(self): cord_x.append(x) cord_y.append(y) return cord_x, cord_y + + +def calculate_rectangle_corners(window_size): + # window_size is a tuple of (width, height) + + # Calculate the coordinates of the rectangle corners + x0y0 = ( + -window_size[0] / 2, + -window_size[1] / 2, + ) + x1y0 = (x0y0[0] + window_size[0], x0y0[1]) + x1y1 = (x0y0[0] + window_size[0], x0y0[1] + window_size[1]) + x0y1 = (x0y0[0], x0y0[1] + window_size[1]) + return x0y0, x1y0, x1y1, x0y1 From 76b6dabcfdb3c19fb78788a7e91e09228c03607d Mon Sep 17 00:00:00 2001 From: Rachel Banks Date: Tue, 12 Dec 2023 13:37:16 -0800 Subject: [PATCH 18/60] fixing mirror and position mapping. --- .../photom/demo/demo_photom_assembly.py | 2 ++ copylot/assemblies/photom/photom.py | 22 +++++++++++-------- .../photom/utils/affine_transform.py | 2 +- copylot/hardware/mirrors/abstract_mirror.py | 16 ++++++-------- copylot/hardware/mirrors/optotune/mirror.py | 16 ++++++-------- 5 files changed, 30 insertions(+), 28 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_photom_assembly.py b/copylot/assemblies/photom/demo/demo_photom_assembly.py index 3b1379fd..84ab0c2e 100644 --- a/copylot/assemblies/photom/demo/demo_photom_assembly.py +++ b/copylot/assemblies/photom/demo/demo_photom_assembly.py @@ -31,3 +31,5 @@ # %% photom_device.calibrate(mirror_index=0, rectangle_size_xy=(0.01, 0.01)) + +# %% diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index 030468f1..f56ba952 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -13,7 +13,9 @@ from copylot import logger from typing import Tuple import time +from typing import Optional +#TODO: add the logger from copylot class PhotomAssembly: def __init__( @@ -21,8 +23,8 @@ def __init__( laser: list[AbstractLaser], mirror: list[AbstractMirror], affine_matrix_path: list[Path], - camera: list[AbstractCamera] = None, - dac: list[AbstractDAQ] = None, + camera: Optional[list[AbstractCamera]] = None, + dac: Optional[list[AbstractDAQ]]= None, ): # hardware self.camera = camera @@ -67,10 +69,10 @@ def capture(self): pass ## Mirror Functions - def get_position(self, mirror_index: int) -> list[float, float]: + def get_position(self, mirror_index: int) -> list[float]: if mirror_index < len(self.mirror): - if self.DAC is None: - NotImplementedError("No DAC found.") + if self.DAC is not None: + raise NotImplementedError("No DAC found.") else: position = self.mirror[mirror_index].position return list(position) @@ -79,12 +81,14 @@ def get_position(self, mirror_index: int) -> list[float, float]: def set_position(self, mirror_index: int, position: list[float]): if mirror_index < len(self.mirror): - if self.DAC is None: - NotImplementedError("No DAC found.") + if self.DAC is not None: + raise NotImplementedError("No DAC found.") else: # TODO: logic for applying the affine transform to the position - new_position = self.mirror.affine_transform_obj.apply_affine(position) - self.mirror[mirror_index].position = new_position + print(f'postion before affine transform: {position}') + new_position = self.mirror[mirror_index].affine_transform_obj.apply_affine(position) + print(f'postion after affine transform: {new_position[0]}{new_position[1]}') + self.mirror[mirror_index].position = [new_position[0][0], new_position[1][0]] else: raise IndexError("Mirror index out of range.") diff --git a/copylot/assemblies/photom/utils/affine_transform.py b/copylot/assemblies/photom/utils/affine_transform.py index 87bcbc56..d958e733 100644 --- a/copylot/assemblies/photom/utils/affine_transform.py +++ b/copylot/assemblies/photom/utils/affine_transform.py @@ -76,7 +76,7 @@ def get_affine_matrix(self, origin, dest): ) return self.T_affine - def apply_affine(self, coord_list: list): + def apply_affine(self, coord_list: list)->list: """ Perform affine transformation. :param coord_list: a list of origin coordinate (e.g. [[x,y], ...] or [[list for ch0], [list for ch1]]) diff --git a/copylot/hardware/mirrors/abstract_mirror.py b/copylot/hardware/mirrors/abstract_mirror.py index d3cd9665..5163abb7 100644 --- a/copylot/hardware/mirrors/abstract_mirror.py +++ b/copylot/hardware/mirrors/abstract_mirror.py @@ -1,6 +1,4 @@ from abc import ABCMeta, abstractmethod -from typing import Tuple - class AbstractMirror(metaclass=ABCMeta): """AbstractMirror @@ -17,13 +15,13 @@ def __init__(self): @property @abstractmethod - def position(self) -> Tuple[float, float]: + def position(self) -> list[float,float]: """Get the current mirror position XY""" pass @position.setter @abstractmethod - def position(self, value: Tuple[float, float]): + def position(self, value: list[float,float]): """Set the mirror position XY""" pass @@ -53,25 +51,25 @@ def position_y(self, value: float): @property @abstractmethod - def relative_position(self) -> Tuple[float, float]: + def relative_position(self) -> list[float, float]: """Get the current relative mirror position""" pass @relative_position.setter @abstractmethod - def relative_position(self, value: Tuple[float, float]): + def relative_position(self, value: list[float, float]): """Set the relative mirror position""" pass @property @abstractmethod - def movement_limits(self) -> Tuple[float, float, float, float]: + def movement_limits(self) -> list[float, float, float, float]: """Get the current mirror movement limits""" pass @movement_limits.setter @abstractmethod - def movement_limits(self, value: Tuple[float, float, float, float]): + def movement_limits(self, value: list[float, float, float, float]): """Set the mirror movement limits""" pass @@ -110,6 +108,6 @@ def external_drive_control(self, value: bool): pass @abstractmethod - def voltage_to_position(self, voltage: Tuple[float, float]) -> Tuple[float, float]: + def voltage_to_position(self, voltage: list[float, float]) -> list[float, float]: """Convert voltage to position""" pass diff --git a/copylot/hardware/mirrors/optotune/mirror.py b/copylot/hardware/mirrors/optotune/mirror.py index 6c3b45dc..2de99f02 100644 --- a/copylot/hardware/mirrors/optotune/mirror.py +++ b/copylot/hardware/mirrors/optotune/mirror.py @@ -9,8 +9,6 @@ from copylot import logger from copylot.hardware.mirrors.optotune import optoMDC from copylot.hardware.mirrors.abstract_mirror import AbstractMirror -from typing import Tuple - class OptoMirror(AbstractMirror): def __init__(self, com_port: str = None): @@ -53,11 +51,11 @@ def position(self): return self.position_x, self.position_y @position.setter - def position(self, value: Tuple[float, float]): + def position(self, value: list[float, float]): """ Parameters ---------- - value:Tuple[float,float] + value:list[float,float] The normalized angular value, value = theta/tan(50degree) 50 degree is the maximum optical deflection angle for each direction. Here x has a range limits of [-1,1] , The combination of value for x-axis and y-axis should be less than 1 @@ -128,22 +126,22 @@ def position_y(self, value): logger.info(f"position_y set to: {value}") @property - def relative_position(self) -> Tuple[float, float]: + def relative_position(self) -> list[float, float]: """Get the current relative mirror position""" pass @relative_position.setter - def relative_position(self, value: Tuple[float, float]): + def relative_position(self, value: list[float, float]): """Set the relative mirror position""" pass @property - def movement_limits(self) -> Tuple[float, float, float, float]: + def movement_limits(self) -> list[float, float, float, float]: """Get the current mirror movement limits""" pass @movement_limits.setter - def movement_limits(self, value: Tuple[float, float, float, float]): + def movement_limits(self, value: list[float, float, float, float]): """Set the mirror movement limits""" pass @@ -178,6 +176,6 @@ def external_drive_control(self, value: bool): pass - def voltage_to_position(self, voltage: Tuple[float, float]) -> Tuple[float, float]: + def voltage_to_position(self, voltage: list[float, float]) -> list[float, float]: """Convert voltage to position""" pass From 3cccddd1283852eb9a322eecfdc2c03e5c838e9f Mon Sep 17 00:00:00 2001 From: Rachel Banks Date: Wed, 13 Dec 2023 10:34:31 -0800 Subject: [PATCH 19/60] added a center offset for the calibration --- .../photom/demo/demo_photom_assembly.py | 13 +++++++++--- copylot/assemblies/photom/photom.py | 7 ++++--- .../photom/utils/scanning_algorithms.py | 20 ++++++++++--------- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_photom_assembly.py b/copylot/assemblies/photom/demo/demo_photom_assembly.py index 84ab0c2e..3c68c399 100644 --- a/copylot/assemblies/photom/demo/demo_photom_assembly.py +++ b/copylot/assemblies/photom/demo/demo_photom_assembly.py @@ -30,6 +30,13 @@ print(curr_pos) # %% -photom_device.calibrate(mirror_index=0, rectangle_size_xy=(0.01, 0.01)) - -# %% +import time +start_time = time.time() +center = 0.009 +photom_device._calibrating = True +while time.time() - start_time < 5: + # Your code here + elapsed_time = time.time() - start_time + print(f'starttime: {start_time} elapsed_time: {elapsed_time}') + photom_device.calibrate(mirror_index=0, rectangle_size_xy=[0.005+center, 0.005+center]) +photom_device._calibrating = False diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index f56ba952..20d6543e 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -40,10 +40,11 @@ def __init__( for i, tx_path in enumerate(affine_matrix_path): self.mirror[i].affine_transform_obj = AffineTransform(config_file=tx_path) - def calibrate(self, mirror_index: int, rectangle_size_xy: tuple[int, int]): + def calibrate(self, mirror_index: int, rectangle_size_xy: tuple[int, int],center=[0.009,0.009]): if mirror_index < len(self.mirror): print("Calibrating mirror...") - rectangle_coords = calculate_rectangle_corners(rectangle_size_xy) + rectangle_coords = calculate_rectangle_corners(rectangle_size_xy,center) + #offset the rectangle coords by the center # iterate over each corner and move the mirror i = 0 while self._calibrating: @@ -51,7 +52,7 @@ def calibrate(self, mirror_index: int, rectangle_size_xy: tuple[int, int]): self.set_position(mirror_index, rectangle_coords[i]) time.sleep(1) i += 1 - if i == 3: + if i == 4: i = 0 # moving the mirror in a rectangle else: diff --git a/copylot/assemblies/photom/utils/scanning_algorithms.py b/copylot/assemblies/photom/utils/scanning_algorithms.py index 0407a4a4..d4eb6a71 100644 --- a/copylot/assemblies/photom/utils/scanning_algorithms.py +++ b/copylot/assemblies/photom/utils/scanning_algorithms.py @@ -196,15 +196,17 @@ def generate_rect(self): return cord_x, cord_y -def calculate_rectangle_corners(window_size): +def calculate_rectangle_corners(window_size: tuple[int, int],center=[0.0,0.0]): # window_size is a tuple of (width, height) # Calculate the coordinates of the rectangle corners - x0y0 = ( - -window_size[0] / 2, - -window_size[1] / 2, - ) - x1y0 = (x0y0[0] + window_size[0], x0y0[1]) - x1y1 = (x0y0[0] + window_size[0], x0y0[1] + window_size[1]) - x0y1 = (x0y0[0], x0y0[1] + window_size[1]) - return x0y0, x1y0, x1y1, x0y1 + x0y0 = [ + -window_size[0] / 2 + center[0], + -window_size[1] / 2 + center[1], + ] + x1y0 = [x0y0[0] + window_size[0], x0y0[1]] + x1y1 = [x0y0[0] + window_size[0], x0y0[1] + window_size[1]] + x0y1 = [x0y0[0], x0y0[1] + window_size[1]] + + + return [x0y0, x1y0, x1y1, x0y1] From 8d25d46cf453f3e843b9109a0663af6781741739 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Wed, 13 Dec 2023 10:36:52 -0800 Subject: [PATCH 20/60] added a center offset for the calibration --- .../photom/demo/demo_photom_assembly.py | 13 +++++++++--- copylot/assemblies/photom/photom.py | 7 ++++--- .../photom/utils/scanning_algorithms.py | 20 ++++++++++--------- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_photom_assembly.py b/copylot/assemblies/photom/demo/demo_photom_assembly.py index 84ab0c2e..3c68c399 100644 --- a/copylot/assemblies/photom/demo/demo_photom_assembly.py +++ b/copylot/assemblies/photom/demo/demo_photom_assembly.py @@ -30,6 +30,13 @@ print(curr_pos) # %% -photom_device.calibrate(mirror_index=0, rectangle_size_xy=(0.01, 0.01)) - -# %% +import time +start_time = time.time() +center = 0.009 +photom_device._calibrating = True +while time.time() - start_time < 5: + # Your code here + elapsed_time = time.time() - start_time + print(f'starttime: {start_time} elapsed_time: {elapsed_time}') + photom_device.calibrate(mirror_index=0, rectangle_size_xy=[0.005+center, 0.005+center]) +photom_device._calibrating = False diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index f56ba952..20d6543e 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -40,10 +40,11 @@ def __init__( for i, tx_path in enumerate(affine_matrix_path): self.mirror[i].affine_transform_obj = AffineTransform(config_file=tx_path) - def calibrate(self, mirror_index: int, rectangle_size_xy: tuple[int, int]): + def calibrate(self, mirror_index: int, rectangle_size_xy: tuple[int, int],center=[0.009,0.009]): if mirror_index < len(self.mirror): print("Calibrating mirror...") - rectangle_coords = calculate_rectangle_corners(rectangle_size_xy) + rectangle_coords = calculate_rectangle_corners(rectangle_size_xy,center) + #offset the rectangle coords by the center # iterate over each corner and move the mirror i = 0 while self._calibrating: @@ -51,7 +52,7 @@ def calibrate(self, mirror_index: int, rectangle_size_xy: tuple[int, int]): self.set_position(mirror_index, rectangle_coords[i]) time.sleep(1) i += 1 - if i == 3: + if i == 4: i = 0 # moving the mirror in a rectangle else: diff --git a/copylot/assemblies/photom/utils/scanning_algorithms.py b/copylot/assemblies/photom/utils/scanning_algorithms.py index 0407a4a4..d4eb6a71 100644 --- a/copylot/assemblies/photom/utils/scanning_algorithms.py +++ b/copylot/assemblies/photom/utils/scanning_algorithms.py @@ -196,15 +196,17 @@ def generate_rect(self): return cord_x, cord_y -def calculate_rectangle_corners(window_size): +def calculate_rectangle_corners(window_size: tuple[int, int],center=[0.0,0.0]): # window_size is a tuple of (width, height) # Calculate the coordinates of the rectangle corners - x0y0 = ( - -window_size[0] / 2, - -window_size[1] / 2, - ) - x1y0 = (x0y0[0] + window_size[0], x0y0[1]) - x1y1 = (x0y0[0] + window_size[0], x0y0[1] + window_size[1]) - x0y1 = (x0y0[0], x0y0[1] + window_size[1]) - return x0y0, x1y0, x1y1, x0y1 + x0y0 = [ + -window_size[0] / 2 + center[0], + -window_size[1] / 2 + center[1], + ] + x1y0 = [x0y0[0] + window_size[0], x0y0[1]] + x1y1 = [x0y0[0] + window_size[0], x0y0[1] + window_size[1]] + x0y1 = [x0y0[0], x0y0[1] + window_size[1]] + + + return [x0y0, x1y0, x1y1, x0y1] From dce6e3222dc5970c5d23a988f529e0482e4c8fe3 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Wed, 13 Dec 2023 10:45:12 -0800 Subject: [PATCH 21/60] reverting comming and adding proper centering --- copylot/assemblies/photom/demo/demo_photom_assembly.py | 4 +++- copylot/assemblies/photom/photom.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_photom_assembly.py b/copylot/assemblies/photom/demo/demo_photom_assembly.py index 3c68c399..aeb45fe8 100644 --- a/copylot/assemblies/photom/demo/demo_photom_assembly.py +++ b/copylot/assemblies/photom/demo/demo_photom_assembly.py @@ -38,5 +38,7 @@ # Your code here elapsed_time = time.time() - start_time print(f'starttime: {start_time} elapsed_time: {elapsed_time}') - photom_device.calibrate(mirror_index=0, rectangle_size_xy=[0.005+center, 0.005+center]) + photom_device.calibrate(mirror_index=0, rectangle_size_xy=[0.002, 0.002], center=[0.000,0.000]) photom_device._calibrating = False + +# %% diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index 20d6543e..eac22ddb 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -40,7 +40,7 @@ def __init__( for i, tx_path in enumerate(affine_matrix_path): self.mirror[i].affine_transform_obj = AffineTransform(config_file=tx_path) - def calibrate(self, mirror_index: int, rectangle_size_xy: tuple[int, int],center=[0.009,0.009]): + def calibrate(self, mirror_index: int, rectangle_size_xy: tuple[int, int],center=[0.0,0.0]): if mirror_index < len(self.mirror): print("Calibrating mirror...") rectangle_coords = calculate_rectangle_corners(rectangle_size_xy,center) From 858b5e7473f5d8e37adca4e496bdb9b7dcb3da8a Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Wed, 13 Dec 2023 15:03:30 -0800 Subject: [PATCH 22/60] connected the mirror to the GUI --- copylot/assemblies/photom/demo/config.yml | 6 +- .../photom/demo/demo_photom_calibration.py | 605 ++++++++++++++++++ .../photom/demo/photom_VIS_config.yml | 2 +- .../photom/demo/photom_calibration.py | 132 ++-- .../assemblies/photom/photom_mock_devices.py | 12 + copylot/assemblies/photom/utils/qt_utils.py | 36 ++ copylot/hardware/mirrors/optotune/mirror.py | 21 +- 7 files changed, 743 insertions(+), 71 deletions(-) create mode 100644 copylot/assemblies/photom/demo/demo_photom_calibration.py create mode 100644 copylot/assemblies/photom/utils/qt_utils.py diff --git a/copylot/assemblies/photom/demo/config.yml b/copylot/assemblies/photom/demo/config.yml index 0307801b..e1e119a7 100644 --- a/copylot/assemblies/photom/demo/config.yml +++ b/copylot/assemblies/photom/demo/config.yml @@ -6,10 +6,12 @@ lasers: mirrors: - name: mirror_1_VIS + COM_port: COM8 x_position: 0 y_position: 0 - affine_matrix_path: /home/eduardo.hirata/repos/coPylot/copylot/assemblies/photom/demo/affine_T.yml + affine_matrix_path: ./copylot/assemblies/photom/demo/affine_T.yml - name: mirror_2_IR + COM_port: COM9 x_position: 0 y_position: 0 - affine_matrix_path: /home/eduardo.hirata/repos/coPylot/copylot/assemblies/photom/demo/affine_T_2.yml + affine_matrix_path: ./copylot/assemblies/photom/demo/affine_T_2.yml diff --git a/copylot/assemblies/photom/demo/demo_photom_calibration.py b/copylot/assemblies/photom/demo/demo_photom_calibration.py new file mode 100644 index 00000000..9d628d41 --- /dev/null +++ b/copylot/assemblies/photom/demo/demo_photom_calibration.py @@ -0,0 +1,605 @@ +import sys +import yaml +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QApplication, + QMainWindow, + QWidget, + QPushButton, + QLabel, + QSlider, + QVBoxLayout, + QGraphicsView, + QGroupBox, + QGraphicsScene, + QGraphicsSimpleTextItem, + QGraphicsItem, + QGraphicsEllipseItem, + QStackedWidget, + QComboBox, + QFileDialog, +) +from PyQt5.QtGui import QColor, QPen +from copylot.assemblies.photom.utils.scanning_algorithms import ( + calculate_rectangle_corners, +) +import numpy as np +from copylot.assemblies.photom.photom import PhotomAssembly +from typing import Tuple + +DEMO_MODE = True + +# TODO: deal with the logic when clicking calibrate. Mirror dropdown +# TODO: check that the calibration step is implemented properly. +# TODO: connect marker to actual mirror position. Unclear why it's not working. + + +class LaserWidget(QWidget): + def __init__(self, laser): + super().__init__() + self.laser = laser + + self.emission_state = 0 # 0 = off, 1 = on + + self.initializer_laser() + self.initialize_UI() + + def initializer_laser(self): + self.laser.toggle_emission = self.emission_state + + def initialize_UI(self): + layout = QVBoxLayout() + + self.laser_label = QLabel(self.laser.name) + layout.addWidget(self.laser_label) + + self.laser_power_slider = QSlider(Qt.Horizontal) + self.laser_power_slider.setMinimum(0) + self.laser_power_slider.setMaximum(100) + self.laser_power_slider.setValue(self.laser.laser_power) + self.laser_power_slider.valueChanged.connect(self.update_power) + layout.addWidget(self.laser_power_slider) + + # Add a QLabel to display the power value + self.power_label = QLabel(f"Power: {self.laser.laser_power}") + layout.addWidget(self.power_label) + + self.laser_toggle_button = QPushButton("Toggle") + self.laser_toggle_button.clicked.connect(self.toggle_laser) + # make it background red if laser is off + if self.emission_state == 0: + self.laser_toggle_button.setStyleSheet("background-color: magenta") + layout.addWidget(self.laser_toggle_button) + + self.setLayout(layout) + + def toggle_laser(self): + self.emission_state = self.emission_state ^ 1 + self.laser.toggle_emission = self.emission_state + if self.emission_state == 0: + self.laser_toggle_button.setStyleSheet("background-color: magenta") + else: + self.laser_toggle_button.setStyleSheet("background-color: green") + + def update_power(self, value): + self.laser.laser_power = value + # Update the QLabel with the new power value + self.power_label.setText(f"Power: {value}") + + +class QDoubleSlider(QSlider): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._multiplier = 1000 + + def value(self): + return super().value() / self._multiplier + + def setValue(self, val): + super().setValue(int(val * self._multiplier)) + + def setMinimum(self, val): + super().setMinimum(int(val * self._multiplier)) + + def setMaximum(self, val): + super().setMaximum(int(val * self._multiplier)) + + +# TODO: connect widget to actual abstract mirror calls +class MirrorWidget(QWidget): + def __init__(self, mirror): + super().__init__() + self.mirror = mirror + self.initialize_UI() + + def initialize_UI(self): + layout = QVBoxLayout() + + mirror_x_label = QLabel("Mirror X Position") + layout.addWidget(mirror_x_label) + + self.mirror_x_slider = QSlider(Qt.Horizontal) + self.mirror_x_slider.setMinimum(-500) + self.mirror_x_slider.setMaximum(500) + self.mirror_x_slider.valueChanged.connect(self.update_mirror_x) + layout.addWidget(self.mirror_x_slider) + + # Add a QLabel to display the mirror X value + self.mirror_x_label = QLabel(f"X: {self.mirror.position_x}") + layout.addWidget(self.mirror_x_label) + + mirror_y_label = QLabel("Mirror Y Position") + layout.addWidget(mirror_y_label) + + self.mirror_y_slider = QSlider(Qt.Horizontal) + self.mirror_y_slider.setMinimum(-500) + self.mirror_y_slider.setMaximum(500) + self.mirror_y_slider.valueChanged.connect(self.update_mirror_y) + layout.addWidget(self.mirror_y_slider) + + # Add a QLabel to display the mirror Y value + self.mirror_y_label = QLabel(f"Y: {self.mirror.position_y}") + layout.addWidget(self.mirror_y_label) + + self.setLayout(layout) + + def update_mirror_x(self, value): + self.mirror.position_x = value + # Update the QLabel with the new X value + self.mirror_x_label.setText(f"X: {value}") + + def update_mirror_y(self, value): + self.mirror.position_y = value + # Update the QLabel with the new Y value + self.mirror_y_label.setText(f"Y: {value}") + + +class PhotomApp(QMainWindow): + def __init__( + self, + photom_assembly: PhotomAssembly, + photom_window_size: Tuple[int, int] = (100, 100), + demo_window=None, + ): + super().__init__() + + self.photom_window = None + self.photom_controls_window = None + + self.photom_assembly = photom_assembly + self.lasers = self.photom_assembly.laser + self.mirrors = self.photom_assembly.mirror + self.photom_window_size = photom_window_size + self._current_mirror_idx = 0 + + if DEMO_MODE: + self.demo_window = demo_window + + self.initialize_UI() + self.initializer_laser_marker_window() + + def initializer_laser_marker_window(self): + # Making the photom_window a square + + window_size = ( + self.photom_window_size[0], + 0, + self.photom_window_size[0], + self.photom_window_size[1], + ) + self.photom_window = LaserMarkerWindow( + photom_controls=self, name='Laser Marker', window_size=window_size + ) + + def initialize_UI(self): + """ + Initialize the UI. + + """ + self.setGeometry(100, 100, 400, 500) + self.setWindowTitle("Laser and Mirror Control App") + + # Adding slider to adjust transparency + transparency_group = QGroupBox("Photom Transparency") + transparency_layout = QVBoxLayout() + # Create a slider to adjust the transparency + self.transparency_slider = QSlider(Qt.Horizontal) + self.transparency_slider.setMinimum(0) + self.transparency_slider.setMaximum(100) + self.transparency_slider.setValue(100) # Initial value is fully opaque + self.transparency_slider.valueChanged.connect(self.update_transparency) + transparency_layout.addWidget(self.transparency_slider) + + # Add a QLabel to display the current percent transparency value + self.transparency_label = QLabel(f"Transparency: 100%") + transparency_layout.addWidget(self.transparency_label) + transparency_group.setLayout(transparency_layout) + + # Adding a group box for the lasers + laser_group = QGroupBox("Lasers") + laser_layout = QVBoxLayout() + self.laser_widgets = [] + for laser in self.lasers: + laser_widget = LaserWidget(laser) + self.laser_widgets.append(laser_widget) + laser_layout.addWidget(laser_widget) + laser_group.setLayout(laser_layout) + + # Adding a group box for the mirror + mirror_group = QGroupBox("Mirror") + mirror_layout = QVBoxLayout() + + self.mirror_widgets = [] + for mirror in self.mirrors: + mirror_widget = MirrorWidget(mirror) + self.mirror_widgets.append(mirror_widget) + mirror_layout.addWidget(mirror_widget) + mirror_group.setLayout(mirror_layout) + + # Add the laser and mirror group boxes to the main layout + main_layout = QVBoxLayout() + main_layout.addWidget(transparency_group) + main_layout.addWidget(laser_group) + main_layout.addWidget(mirror_group) + + self.mirror_dropdown = QComboBox() + self.mirror_dropdown.addItems([mirror.name for mirror in self.mirrors]) + main_layout.addWidget(self.mirror_dropdown) + self.mirror_dropdown.setCurrentIndex(self._current_mirror_idx) + self.mirror_dropdown.currentIndexChanged.connect(self.mirror_dropdown_changed) + + self.calibrate_button = QPushButton("Calibrate") + self.calibrate_button.clicked.connect(self.calibrate) + main_layout.addWidget(self.calibrate_button) + + # Add a "Done Calibration" button (initially hidden) + self.done_calibration_button = QPushButton("Done Calibration") + self.done_calibration_button.clicked.connect(self.done_calibration) + self.done_calibration_button.hide() + main_layout.addWidget(self.done_calibration_button) + + # Add a "Cancel Calibration" button (initially hidden) + self.cancel_calibration_button = QPushButton("Cancel Calibration") + self.cancel_calibration_button.clicked.connect(self.cancel_calibration) + self.cancel_calibration_button.hide() + main_layout.addWidget(self.cancel_calibration_button) + main_widget = QWidget(self) + main_widget.setLayout(main_layout) + + self.setCentralWidget(main_widget) + self.show() + + def mirror_dropdown_changed(self, index): + print(f"Mirror dropdown changed to index {index}") + self._current_mirror_idx = index + + # Reset to (0,0) position + self.photom_assembly.mirror[self._current_mirror_idx].position = [0, 0] + + def calibrate(self): + # Implement your calibration function here + print("Calibrating...") + # Hide the 'X' marker in photom_window + # self.photom_window.marker.hide() + # Hide the calibrate button + self.calibrate_button.hide() + # Show the "Cancel Calibration" button + self.cancel_calibration_button.show() + # Display the rectangle + self.display_rectangle() + self.source_pts = self.photom_window.get_coordinates() + # Show the "Done Calibration" button + self.done_calibration_button.show() + + selected_mirror_name = self.mirror_dropdown.currentText() + self._current_mirror_idx = next( + i + for i, mirror in enumerate(self.mirrors) + if mirror.name == selected_mirror_name + ) + if not DEMO_MODE: + # TODO: move in the pattern for calibration + self.photom_assembly.calibrate(self._current_mirror_idx) + else: + print(f'Calibrating mirror: {self._current_mirror_idx}') + + def cancel_calibration(self): + # Implement your cancel calibration function here + print("Canceling calibration...") + # Hide the "Done Calibration" button + self.done_calibration_button.hide() + # Show the "Calibrate" button + self.calibrate_button.show() + # Show the "X" marker in photom_window + self.photom_window.marker.show() + + self.cancel_calibration_button.hide() + # Switch back to the shooting scene + self.photom_window.switch_to_shooting_scene() + + def done_calibration(self): + # Perform any necessary actions after calibration is done + self.target_pts = self.photom_window.get_coordinates() + origin = np.array( + [[pt.x(), pt.y()] for pt in self.source_pts], dtype=np.float32 + ) + dest = np.array([[pt.x(), pt.y()] for pt in self.target_pts], dtype=np.float32) + + T_affine = self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.get_affine_matrix(dest, origin) + # logger.debug(f"Affine matrix: {T_affine}") + print(f"Affine matrix: {T_affine}") + + # Save the affine matrix to a file + typed_filename, _ = QFileDialog.getSaveFileName( + self, "Save File", "", "YAML Files (*.yml)" + ) + if typed_filename: + if not typed_filename.endswith(".yml"): + typed_filename += ".yml" + print("Selected file:", typed_filename) + # Save the matrix + self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.save_matrix( + matrix=T_affine, config_file=typed_filename + ) + self.photom_window.switch_to_shooting_scene() + self.photom_window.marker.show() + + # Hide the "Done Calibration" button + self.done_calibration_button.hide() + self.calibrate_button.show() + self.cancel_calibration_button.hide() + + if DEMO_MODE: + print(f'origin: {origin}') + print(f'dest: {dest}') + # transformed_coords = self.affine_trans_obj.apply_affine(dest) + transformed_coords = self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.apply_affine(dest) + print(transformed_coords) + coords_list = self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.trans_pointwise(transformed_coords) + print(coords_list) + self.demo_window.updateVertices(coords_list) + return + else: + print("No file selected") + # Show dialog box saying no file selected + print("Calibration done") + + def update_transparency(self, value): + transparency_percent = value + self.transparency_label.setText(f"Transparency: {transparency_percent}%") + opacity = 1.0 - (transparency_percent / 100.0) # Calculate opacity (0.0 to 1.0) + self.photom_window.setWindowOpacity(opacity) # Update photom_window opacity + + def display_rectangle(self): + # Calculate the coordinates of the rectangle corners + rectangle_scaling = 0.5 + window_size = (self.photom_window.width(), self.photom_window.height()) + rectangle_size = ( + (window_size[0] * rectangle_scaling), + (window_size[1] * rectangle_scaling), + ) + rectangle_coords = calculate_rectangle_corners(rectangle_size) + self.photom_window.updateVertices(rectangle_coords) + self.photom_window.switch_to_calibration_scene() + + +class LaserMarkerWindow(QMainWindow): + def __init__( + self, + photom_controls: QMainWindow = None, + name="Laser Marker", + window_size=(100, 100, 100, 100), + ): + super().__init__() + self.photom_controls = photom_controls + self.window_name = name + self.window_geometry = window_size + self.setMouseTracking(True) + self.mouseX = None + self.mouseY = None + self.setWindowOpacity(0.7) + self.scale = 0.025 + # self.offset = (-0.032000, -0.046200) + + # Create a QStackedWidget + self.stacked_widget = QStackedWidget() + # Set the QStackedWidget as the central widget + + self.initMarker() + self.init_tetragon() + + self.initialize_UI() + + self.setCentralWidget(self.stacked_widget) + + def initialize_UI(self): + self.setGeometry( + self.window_geometry[0], + self.window_geometry[1], + self.window_geometry[2], + self.window_geometry[3], + ) + self.setWindowTitle(self.window_name) + + # Fix the size of the window + self.setFixedSize( + self.window_geometry[2], + self.window_geometry[3], + ) + self.switch_to_shooting_scene() + self.show() + + def initMarker(self): + self.shooting_scene = QGraphicsScene(self) + self.shooting_view = QGraphicsView(self.shooting_scene) + self.shooting_view.setMouseTracking(True) + self.setCentralWidget(self.shooting_view) + self.setMouseTracking(True) + self.marker = QGraphicsSimpleTextItem("X") + self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) + self.shooting_scene.addItem(self.marker) + + # Add the view to the QStackedWidget + self.stacked_widget.addWidget(self.shooting_view) + + def init_tetragon( + self, tetragon_coords: list = [(100, 100), (200, 100), (200, 200), (100, 200)] + ): + self.calibration_scene = QGraphicsScene(self) + self.calibration_view = QGraphicsView(self.calibration_scene) + self.calibration_view.setMouseTracking(True) + self.setCentralWidget(self.calibration_view) + self.setMouseTracking(True) + self.vertices = [] + for x, y in tetragon_coords: + vertex = QGraphicsEllipseItem(x - 5, y - 5, 10, 10) + vertex.setBrush(Qt.red) + vertex.setFlag(QGraphicsEllipseItem.ItemIsMovable) + self.vertices.append(vertex) + self.calibration_scene.addItem(vertex) + + # Add the view to the QStackedWidget + self.stacked_widget.addWidget(self.calibration_view) + + def switch_to_shooting_scene(self): + self.stacked_widget.setCurrentWidget(self.shooting_view) + + def switch_to_calibration_scene(self): + self.stacked_widget.setCurrentWidget(self.calibration_view) + + def get_coordinates(self): + return [vertex.pos() for vertex in self.vertices] + + def updateVertices(self, new_coordinates): + for vertex, (x, y) in zip(self.vertices, new_coordinates): + vertex.setPos(x, y) + + def recordinate(self, rawcord): + return -self.scale * (rawcord - (self.window_geometry[2] / 2)) / 50 + + def mouseMoveEvent(self, event: "QGraphicsSceneMouseEvent"): + new_cursor_position = event.screenPos() + print(f"current x: {new_cursor_position}") + + def mousePressEvent(self, event): + marker_x = self.marker.pos().x() + marker_y = self.marker.pos().y() + print(f"x position (x,y): {(marker_x, marker_y)}") + # print('Mouse press coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + # Update the mirror slider values + if self.photom_controls is not None: + self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror_x_slider.setValue(int(self.marker.pos().x())) + self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror_y_slider.setValue(int(self.marker.pos().y())) + + # Update the photom_assembly mirror position + # self.photom_controls.mirror[self._current_mirror_idx].position = [ + # self.marker.pos().x(), + # self.marker.pos().y(), + # ] + + def mouseReleaseEvent(self, event): + print("Mouse release coords: ( %f : %f )" % (self.mouseX, self.mouseY)) + + +if __name__ == "__main__": + import os + + if DEMO_MODE: + from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror + + Laser = MockLaser + Mirror = MockMirror + + else: + # NOTE: These are the actual classes that will be used in the photom assembly + from copylot.hardware.lasers.vortran import VortranLaser as Laser + from copylot.hardware.mirrors.optotune.mirror import OptoMirror as Mirror + + try: + os.environ["DISPLAY"] = ":1003" + + except: + raise Exception("DISPLAY environment variable not set") + + config_path = ( + "/home/eduardo.hirata/repos/coPylot/copylot/assemblies/photom/demo/config.yml" + ) + + # TODO: this should be a function that parses the config_file and returns the photom_assembly + # Load the config file and parse it + with open(config_path, "r") as config_file: + config = yaml.load(config_file, Loader=yaml.FullLoader) + lasers = [Laser(**laser_data) for laser_data in config["lasers"]] + mirrors = [ + Mirror( + name=mirror_data["name"], + pos_x=mirror_data["x_position"], + pos_y=mirror_data["y_position"], + ) + for mirror_data in config["mirrors"] + ] # Initial mirror position + affine_matrix_paths = [ + mirror['affine_matrix_path'] for mirror in config['mirrors'] + ] + # Check that the number of mirrors and affine matrices match + assert len(mirrors) == len(affine_matrix_paths) + + # Load photom assembly + photom_assembly = PhotomAssembly( + laser=lasers, mirror=mirrors, affine_matrix_path=affine_matrix_paths + ) + + # QT APP + app = QApplication(sys.argv) + + # Define the positions and sizes for the windows + screen_width = app.desktop().screenGeometry().width() + screen_height = app.desktop().screenGeometry().height() + ctrl_window_width = screen_width // 3 # Adjust the width as needed + ctrl_window_height = screen_height // 3 # Use the full screen height + + if DEMO_MODE: + camera_window = LaserMarkerWindow( + name="Mock laser dots", + window_size=(ctrl_window_width, 0, ctrl_window_width, ctrl_window_width), + ) # Set the positions of the windows + ctrl_window = PhotomApp( + photom_assembly=photom_assembly, + photom_window_size=(ctrl_window_width, ctrl_window_width), + demo_window=camera_window, + ) + ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_width) + + # Set the camera window to the calibration scene + camera_window.switch_to_calibration_scene() + rectangle_scaling = 0.2 + window_size = (camera_window.width(), camera_window.height()) + rectangle_size = ( + (window_size[0] * rectangle_scaling), + (window_size[1] * rectangle_scaling), + ) + rectangle_coords = calculate_rectangle_corners(rectangle_size) + # translate each coordinate by the offset + rectangle_coords = [(x + 30, y) for x, y in rectangle_coords] + camera_window.updateVertices(rectangle_coords) + else: + # Set the positions of the windows + ctrl_window = PhotomApp( + photom_assembly=photom_assembly, + photom_window_size=(ctrl_window_width, ctrl_window_height), + ) + ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_height) + + sys.exit(app.exec_()) diff --git a/copylot/assemblies/photom/demo/photom_VIS_config.yml b/copylot/assemblies/photom/demo/photom_VIS_config.yml index ea096d2c..cb7c134a 100644 --- a/copylot/assemblies/photom/demo/photom_VIS_config.yml +++ b/copylot/assemblies/photom/demo/photom_VIS_config.yml @@ -9,4 +9,4 @@ mirrors: COM_port: COM8 x_position: 0 y_position: 0 - affine_matrix_path: C:\Users\ZebraPhysics\Documents\GitHub\coPylot\copylot\assemblies\photom\demo\affine_T.yml \ No newline at end of file + affine_matrix_path: ./copylot/assemblies/photom/demo/affine_T.yml \ No newline at end of file diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 9d628d41..f7126dbf 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -23,11 +23,14 @@ from copylot.assemblies.photom.utils.scanning_algorithms import ( calculate_rectangle_corners, ) +from copylot.assemblies.photom.utils.qt_utils import DoubleSlider import numpy as np from copylot.assemblies.photom.photom import PhotomAssembly from typing import Tuple -DEMO_MODE = True +# DEMO_MODE = True +DEMO_MODE = False + # TODO: deal with the logic when clicking calibrate. Mirror dropdown # TODO: check that the calibration step is implemented properly. @@ -87,29 +90,13 @@ def update_power(self, value): self.power_label.setText(f"Power: {value}") -class QDoubleSlider(QSlider): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._multiplier = 1000 - - def value(self): - return super().value() / self._multiplier - - def setValue(self, val): - super().setValue(int(val * self._multiplier)) - - def setMinimum(self, val): - super().setMinimum(int(val * self._multiplier)) - - def setMaximum(self, val): - super().setMaximum(int(val * self._multiplier)) - - # TODO: connect widget to actual abstract mirror calls class MirrorWidget(QWidget): def __init__(self, mirror): super().__init__() self.mirror = mirror + + self.check_mirror_limits() self.initialize_UI() def initialize_UI(self): @@ -118,10 +105,10 @@ def initialize_UI(self): mirror_x_label = QLabel("Mirror X Position") layout.addWidget(mirror_x_label) - self.mirror_x_slider = QSlider(Qt.Horizontal) - self.mirror_x_slider.setMinimum(-500) - self.mirror_x_slider.setMaximum(500) - self.mirror_x_slider.valueChanged.connect(self.update_mirror_x) + self.mirror_x_slider = DoubleSlider(orientation=Qt.Horizontal) + self.mirror_x_slider.setMinimum(self.movement_limits_x[0]) + self.mirror_x_slider.setMaximum(self.movement_limits_x[1]) + self.mirror_x_slider.doubleValueChanged.connect(self.update_mirror_x) layout.addWidget(self.mirror_x_slider) # Add a QLabel to display the mirror X value @@ -131,10 +118,10 @@ def initialize_UI(self): mirror_y_label = QLabel("Mirror Y Position") layout.addWidget(mirror_y_label) - self.mirror_y_slider = QSlider(Qt.Horizontal) - self.mirror_y_slider.setMinimum(-500) - self.mirror_y_slider.setMaximum(500) - self.mirror_y_slider.valueChanged.connect(self.update_mirror_y) + self.mirror_y_slider = DoubleSlider(orientation=Qt.Horizontal) + self.mirror_y_slider.setMinimum(self.movement_limits_y[0]) + self.mirror_y_slider.setMaximum(self.movement_limits_y[1]) + self.mirror_y_slider.doubleValueChanged.connect(self.update_mirror_y) layout.addWidget(self.mirror_y_slider) # Add a QLabel to display the mirror Y value @@ -144,6 +131,7 @@ def initialize_UI(self): self.setLayout(layout) def update_mirror_x(self, value): + print(f'updating mirror x to {value}') self.mirror.position_x = value # Update the QLabel with the new X value self.mirror_x_label.setText(f"X: {value}") @@ -153,12 +141,18 @@ def update_mirror_y(self, value): # Update the QLabel with the new Y value self.mirror_y_label.setText(f"Y: {value}") + def check_mirror_limits(self): + movement_limits = self.mirror.movement_limits + self.movement_limits_x = movement_limits[0:2] + self.movement_limits_y = movement_limits[2:4] + class PhotomApp(QMainWindow): def __init__( self, photom_assembly: PhotomAssembly, - photom_window_size: Tuple[int, int] = (100, 100), + photom_window_size: Tuple[int, int] = (400, 500), + photom_window_pos: Tuple[int, int] = (100, 100), demo_window=None, ): super().__init__() @@ -170,6 +164,7 @@ def __init__( self.lasers = self.photom_assembly.laser self.mirrors = self.photom_assembly.mirror self.photom_window_size = photom_window_size + self.photom_window_pos = photom_window_pos self._current_mirror_idx = 0 if DEMO_MODE: @@ -179,16 +174,17 @@ def __init__( self.initializer_laser_marker_window() def initializer_laser_marker_window(self): - # Making the photom_window a square - - window_size = ( - self.photom_window_size[0], - 0, - self.photom_window_size[0], - self.photom_window_size[1], + # Making the photom_window a square and display right besides the control UI + window_pos = ( + self.photom_window_size[0] + self.photom_window_pos[0], + self.photom_window_pos[1], ) + window_size = (self.photom_window_size[0], self.photom_window_size[1]) self.photom_window = LaserMarkerWindow( - photom_controls=self, name='Laser Marker', window_size=window_size + photom_controls=self, + name='Laser Marker', + window_size=window_size, + window_pos=window_pos, ) def initialize_UI(self): @@ -196,7 +192,12 @@ def initialize_UI(self): Initialize the UI. """ - self.setGeometry(100, 100, 400, 500) + self.setGeometry( + self.photom_window_pos[0], + self.photom_window_pos[1], + self.photom_window_size[0], + self.photom_window_size[1], + ) self.setWindowTitle("Laser and Mirror Control App") # Adding slider to adjust transparency @@ -297,13 +298,18 @@ def calibrate(self): for i, mirror in enumerate(self.mirrors) if mirror.name == selected_mirror_name ) - if not DEMO_MODE: - # TODO: move in the pattern for calibration - self.photom_assembly.calibrate(self._current_mirror_idx) - else: + if DEMO_MODE: print(f'Calibrating mirror: {self._current_mirror_idx}') + else: + self.photom_assembly._calibrating = True + self.photom_assembly.calibrate( + self._current_mirror_idx, + rectangle_size_xy=[0.002, 0.002], + center=[0.000, 0.000], + ) def cancel_calibration(self): + self.photom_assembly._calibrating = False # Implement your cancel calibration function here print("Canceling calibration...") # Hide the "Done Calibration" button @@ -318,6 +324,9 @@ def cancel_calibration(self): self.photom_window.switch_to_shooting_scene() def done_calibration(self): + self.photom_assembly._calibrating = False + # TODO: Logic to return to some position + # Perform any necessary actions after calibration is done self.target_pts = self.photom_window.get_coordinates() origin = np.array( @@ -396,12 +405,13 @@ def __init__( self, photom_controls: QMainWindow = None, name="Laser Marker", - window_size=(100, 100, 100, 100), + window_size: Tuple = (400, 500), + window_pos: Tuple = (100, 100), ): super().__init__() self.photom_controls = photom_controls self.window_name = name - self.window_geometry = window_size + self.window_geometry = window_pos + window_size self.setMouseTracking(True) self.mouseX = None self.mouseY = None @@ -421,6 +431,7 @@ def __init__( self.setCentralWidget(self.stacked_widget) def initialize_UI(self): + print(f'window geometry: {self.window_geometry}') self.setGeometry( self.window_geometry[0], self.window_geometry[1], @@ -503,12 +514,6 @@ def mousePressEvent(self, event): self.photom_controls._current_mirror_idx ].mirror_y_slider.setValue(int(self.marker.pos().y())) - # Update the photom_assembly mirror position - # self.photom_controls.mirror[self._current_mirror_idx].position = [ - # self.marker.pos().x(), - # self.marker.pos().y(), - # ] - def mouseReleaseEvent(self, event): print("Mouse release coords: ( %f : %f )" % (self.mouseX, self.mouseY)) @@ -524,18 +529,20 @@ def mouseReleaseEvent(self, event): else: # NOTE: These are the actual classes that will be used in the photom assembly - from copylot.hardware.lasers.vortran import VortranLaser as Laser + # from copylot.hardware.lasers.vortran import VortranLaser as Laser + # TODO: remove after testing + from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror + + Laser = MockLaser from copylot.hardware.mirrors.optotune.mirror import OptoMirror as Mirror - try: - os.environ["DISPLAY"] = ":1003" + # try: + # os.environ["DISPLAY"] = ":1003" - except: - raise Exception("DISPLAY environment variable not set") + # except: + # raise Exception("DISPLAY environment variable not set") - config_path = ( - "/home/eduardo.hirata/repos/coPylot/copylot/assemblies/photom/demo/config.yml" - ) + config_path = r"./copylot/assemblies/photom/demo/photom_VIS_config.yml" # TODO: this should be a function that parses the config_file and returns the photom_assembly # Load the config file and parse it @@ -545,6 +552,7 @@ def mouseReleaseEvent(self, event): mirrors = [ Mirror( name=mirror_data["name"], + com_port=mirror_data["COM_port"], pos_x=mirror_data["x_position"], pos_y=mirror_data["y_position"], ) @@ -573,15 +581,15 @@ def mouseReleaseEvent(self, event): if DEMO_MODE: camera_window = LaserMarkerWindow( name="Mock laser dots", - window_size=(ctrl_window_width, 0, ctrl_window_width, ctrl_window_width), + window_size=(ctrl_window_width, ctrl_window_width), + window_pos=(100, 100), ) # Set the positions of the windows ctrl_window = PhotomApp( photom_assembly=photom_assembly, photom_window_size=(ctrl_window_width, ctrl_window_width), + photom_window_pos=(100, 100), demo_window=camera_window, ) - ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_width) - # Set the camera window to the calibration scene camera_window.switch_to_calibration_scene() rectangle_scaling = 0.2 @@ -598,8 +606,8 @@ def mouseReleaseEvent(self, event): # Set the positions of the windows ctrl_window = PhotomApp( photom_assembly=photom_assembly, - photom_window_size=(ctrl_window_width, ctrl_window_height), + photom_window_size=(ctrl_window_width, ctrl_window_width), + photom_window_pos=(100, 100), ) - ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_height) sys.exit(app.exec_()) diff --git a/copylot/assemblies/photom/photom_mock_devices.py b/copylot/assemblies/photom/photom_mock_devices.py index 9743dc67..32ef0f89 100644 --- a/copylot/assemblies/photom/photom_mock_devices.py +++ b/copylot/assemblies/photom/photom_mock_devices.py @@ -1,5 +1,6 @@ from typing import Tuple + class MockLaser: def __init__(self, name, power=0, **kwargs): # Initialize the mock laser @@ -37,6 +38,7 @@ def laser_power(self, power): self.power = power print(f'Laser {self.name} power set to {power}') + class MockMirror: def __init__(self, name, pos_x=0, pos_y=0, **kwargs): # Initialize the mock mirror with the given x and y positions @@ -46,6 +48,7 @@ def __init__(self, name, pos_x=0, pos_y=0, **kwargs): self.pos_y = pos_y self.position = (self.pos_x, self.pos_y) + self.movement_limits = [-1, 1, -1, 1] @property def position(self): @@ -81,3 +84,12 @@ def position_y(self, value: float): self.pos_y = value print(f'Mirror {self.name} Position_Y {self.pos_y}') + @property + def movement_limits(self) -> list[float, float, float, float]: + """Get the current mirror movement limits""" + return self._movement_limits + + @movement_limits.setter + def movement_limits(self, value: list[float, float, float, float]): + """Set the mirror movement limits""" + self._movement_limits = value diff --git a/copylot/assemblies/photom/utils/qt_utils.py b/copylot/assemblies/photom/utils/qt_utils.py new file mode 100644 index 00000000..af828ccb --- /dev/null +++ b/copylot/assemblies/photom/utils/qt_utils.py @@ -0,0 +1,36 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QSlider, QWidget, QVBoxLayout, QDoubleSpinBox, QLabel +from PyQt5.QtCore import pyqtSignal + + +class DoubleSlider(QSlider): + # create our our signal that we can connect to if necessary + doubleValueChanged = pyqtSignal(float) + + def __init__(self, decimals=5, *args, **kargs): + super(DoubleSlider, self).__init__(*args, **kargs) + self._multi = 10**decimals + + self.valueChanged.connect(self.emitDoubleValueChanged) + + def emitDoubleValueChanged(self): + value = float(super(DoubleSlider, self).value()) / self._multi + self.doubleValueChanged.emit(value) + + def value(self): + return float(super(DoubleSlider, self).value()) / self._multi + + def setMinimum(self, value): + return super(DoubleSlider, self).setMinimum(int(value * self._multi)) + + def setMaximum(self, value): + return super(DoubleSlider, self).setMaximum(int(value * self._multi)) + + def setSingleStep(self, value): + return super(DoubleSlider, self).setSingleStep(value * self._multi) + + def singleStep(self): + return float(super(DoubleSlider, self).singleStep()) / self._multi + + def setValue(self, value): + super(DoubleSlider, self).setValue(int(value * self._multi)) diff --git a/copylot/hardware/mirrors/optotune/mirror.py b/copylot/hardware/mirrors/optotune/mirror.py index 2de99f02..1b25aafe 100644 --- a/copylot/hardware/mirrors/optotune/mirror.py +++ b/copylot/hardware/mirrors/optotune/mirror.py @@ -10,13 +10,21 @@ from copylot.hardware.mirrors.optotune import optoMDC from copylot.hardware.mirrors.abstract_mirror import AbstractMirror + class OptoMirror(AbstractMirror): - def __init__(self, com_port: str = None): + def __init__( + self, + name: str = "OPTOTUNE_MIRROR", + com_port: str = None, + pos_x: float = 0.0, + pos_y: float = 0.0, + ): """ Wrapper for Optotune mirror controller MR-E-2. establishes a connection through COM port """ + self.name = name self.mirror = optoMDC.connect( com_port if com_port is not None @@ -31,6 +39,10 @@ def __init__(self, com_port: str = None): self.channel_y.SetControlMode(optoMDC.Units.XY) self.channel_y.StaticInput.SetAsInput() logger.info("mirror connected") + self.position_x = pos_x + self.position_y = pos_y + + self._movement_limits = [-1.0, 1.0, -1.0, 1.0] def __del__(self): self.position_x = 0 @@ -138,12 +150,12 @@ def relative_position(self, value: list[float, float]): @property def movement_limits(self) -> list[float, float, float, float]: """Get the current mirror movement limits""" - pass + return self._movement_limits @movement_limits.setter def movement_limits(self, value: list[float, float, float, float]): """Set the mirror movement limits""" - pass + self._movement_limits = value @property def step_resolution(self) -> float: @@ -155,12 +167,10 @@ def step_resolution(self, value: float): """Set the mirror step resolution""" pass - def set_home(self): """Set the mirror home position""" pass - def set_origin(self, axis: str): """Set the mirror origin for a specific axis""" pass @@ -175,7 +185,6 @@ def external_drive_control(self, value: bool): """Set the mirror drive mode""" pass - def voltage_to_position(self, voltage: list[float, float]) -> list[float, float]: """Convert voltage to position""" pass From 74de070f0769295f1e45e16ae14c421144b3eb5d Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Fri, 5 Jan 2024 09:38:35 -0800 Subject: [PATCH 23/60] add qtthread --- .../photom/demo/photom_calibration.py | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index f7126dbf..3093356d 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -1,6 +1,6 @@ import sys import yaml -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtWidgets import ( QApplication, QMainWindow, @@ -26,7 +26,7 @@ from copylot.assemblies.photom.utils.qt_utils import DoubleSlider import numpy as np from copylot.assemblies.photom.photom import PhotomAssembly -from typing import Tuple +from typing import Any, Tuple # DEMO_MODE = True DEMO_MODE = False @@ -131,7 +131,6 @@ def initialize_UI(self): self.setLayout(layout) def update_mirror_x(self, value): - print(f'updating mirror x to {value}') self.mirror.position_x = value # Update the QLabel with the new X value self.mirror_x_label.setText(f"X: {value}") @@ -167,6 +166,10 @@ def __init__( self.photom_window_pos = photom_window_pos self._current_mirror_idx = 0 + self.calibration_thread = CalibrationThread( + self.photom_assembly, self._current_mirror_idx + ) + if DEMO_MODE: self.demo_window = demo_window @@ -302,14 +305,11 @@ def calibrate(self): print(f'Calibrating mirror: {self._current_mirror_idx}') else: self.photom_assembly._calibrating = True - self.photom_assembly.calibrate( - self._current_mirror_idx, - rectangle_size_xy=[0.002, 0.002], - center=[0.000, 0.000], - ) + self.calibration_thread.start() def cancel_calibration(self): self.photom_assembly._calibrating = False + # Implement your cancel calibration function here print("Canceling calibration...") # Hide the "Done Calibration" button @@ -400,6 +400,23 @@ def display_rectangle(self): self.photom_window.switch_to_calibration_scene() +class CalibrationThread(QThread): + finished = pyqtSignal() + + def __init__(self, photom_assembly, current_mirror_idx): + super().__init__() + self.photom_assembly = photom_assembly + self.current_mirror_idx = current_mirror_idx + + def run(self): + self.photom_assembly.calibrate( + self.current_mirror_idx, + rectangle_size_xy=[0.002, 0.002], + center=[0.000, 0.000], + ) + self.finished.emit() + + class LaserMarkerWindow(QMainWindow): def __init__( self, From ba311025506be7a93b7bbbe21372dd20bbbb02a2 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Fri, 5 Jan 2024 11:18:20 -0800 Subject: [PATCH 24/60] pseudocode for testing calibration --- .../photom/demo/photom_calibration.py | 14 +++++++-- copylot/assemblies/photom/photom.py | 30 ++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 3093356d..73827f73 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -35,6 +35,7 @@ # TODO: deal with the logic when clicking calibrate. Mirror dropdown # TODO: check that the calibration step is implemented properly. # TODO: connect marker to actual mirror position. Unclear why it's not working. +# TODO: verify that if you move the window, the markers frame of reference is still the same class LaserWidget(QWidget): @@ -327,11 +328,20 @@ def done_calibration(self): self.photom_assembly._calibrating = False # TODO: Logic to return to some position - # Perform any necessary actions after calibration is done + ## Perform any necessary actions after calibration is done + # Get the mirror (target) positions self.target_pts = self.photom_window.get_coordinates() origin = np.array( [[pt.x(), pt.y()] for pt in self.source_pts], dtype=np.float32 ) + + # Mirror calibration size + mirror_calib_size = self.photom_assembly._calibration_rectangle_size_xy + self.target_pts = [ + [pts[0] * mirror_calib_size[0], pts[1] * mirror_calib_size[1]] + for pts in self.target_pts + ] + dest = np.array([[pt.x(), pt.y()] for pt in self.target_pts], dtype=np.float32) T_affine = self.photom_assembly.mirror[ @@ -411,7 +421,7 @@ def __init__(self, photom_assembly, current_mirror_idx): def run(self): self.photom_assembly.calibrate( self.current_mirror_idx, - rectangle_size_xy=[0.002, 0.002], + rectangle_size_xy=self.photom_assembly._calibration_rectangle_size_xy, center=[0.000, 0.000], ) self.finished.emit() diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index eac22ddb..47073407 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -15,7 +15,8 @@ import time from typing import Optional -#TODO: add the logger from copylot +# TODO: add the logger from copylot + class PhotomAssembly: def __init__( @@ -24,7 +25,7 @@ def __init__( mirror: list[AbstractMirror], affine_matrix_path: list[Path], camera: Optional[list[AbstractCamera]] = None, - dac: Optional[list[AbstractDAQ]]= None, + dac: Optional[list[AbstractDAQ]] = None, ): # hardware self.camera = camera @@ -34,17 +35,23 @@ def __init__( self._calibrating = False + # TODO: replace these hardcoded values to mirror's scan steps given the magnification + # and the mirrors angles + self._calibration_rectangle_size_xy = [0.002, 0.002] + assert len(self.mirror) == len(affine_matrix_path) # Apply AffineTransform to each mirror for i, tx_path in enumerate(affine_matrix_path): self.mirror[i].affine_transform_obj = AffineTransform(config_file=tx_path) - def calibrate(self, mirror_index: int, rectangle_size_xy: tuple[int, int],center=[0.0,0.0]): + def calibrate( + self, mirror_index: int, rectangle_size_xy: tuple[int, int], center=[0.0, 0.0] + ): if mirror_index < len(self.mirror): print("Calibrating mirror...") - rectangle_coords = calculate_rectangle_corners(rectangle_size_xy,center) - #offset the rectangle coords by the center + rectangle_coords = calculate_rectangle_corners(rectangle_size_xy, center) + # offset the rectangle coords by the center # iterate over each corner and move the mirror i = 0 while self._calibrating: @@ -87,9 +94,16 @@ def set_position(self, mirror_index: int, position: list[float]): else: # TODO: logic for applying the affine transform to the position print(f'postion before affine transform: {position}') - new_position = self.mirror[mirror_index].affine_transform_obj.apply_affine(position) - print(f'postion after affine transform: {new_position[0]}{new_position[1]}') - self.mirror[mirror_index].position = [new_position[0][0], new_position[1][0]] + new_position = self.mirror[ + mirror_index + ].affine_transform_obj.apply_affine(position) + print( + f'postion after affine transform: {new_position[0]}{new_position[1]}' + ) + self.mirror[mirror_index].position = [ + new_position[0][0], + new_position[1][0], + ] else: raise IndexError("Mirror index out of range.") From a6edde2afbf84a76adb5084452791352cc67ac67 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Thu, 18 Jan 2024 15:43:19 -0800 Subject: [PATCH 25/60] mouse tracking fixed using qeventfilter --- .../photom/demo/demo_drawing_windows.py | 162 ++++++++++++++---- 1 file changed, 130 insertions(+), 32 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_drawing_windows.py b/copylot/assemblies/photom/demo/demo_drawing_windows.py index 5917445d..64c6c370 100644 --- a/copylot/assemblies/photom/demo/demo_drawing_windows.py +++ b/copylot/assemblies/photom/demo/demo_drawing_windows.py @@ -15,14 +15,14 @@ ) from PyQt5.QtCore import Qt, QPointF -from PyQt5.QtGui import QPainter, QPen, QMouseEvent, QCursor +from PyQt5.QtGui import QPainter, QPen, QMouseEvent, QCursor, QFont, QFontMetricsF from copylot import logger class CustomWindow(QMainWindow): def __init__(self): super().__init__() - self.windowGeo = (300, 300, 1000, 1000) + self.windowGeo = (300, 300, 500, 500) self.setMouseTracking(True) self.mouseX = None self.mouseY = None @@ -31,64 +31,150 @@ def __init__(self): self.scale = 0.025 self.offset = (-0.032000, -0.046200) + self.view = None + self.scene = None self.initMarker() self.initUI() + print( + f'liveView actual {self.frameGeometry().x(), self.frameGeometry().y(), self.width(), self.height()}' + ) + print(self.frameGeometry().height(), self.frameGeometry().width()) def initUI(self): - self.setGeometry( - self.windowGeo[0], - self.windowGeo[1], - self.windowGeo[2], - self.windowGeo[3], - ) - self.setWindowTitle('Mouse Tracker') - # self.setFixedSize( + # self.setGeometry( + # self.windowGeo[0], + # self.windowGeo[1], # self.windowGeo[2], # self.windowGeo[3], # ) + self.setWindowTitle('Mouse Tracker') + self.setFixedSize( + self.windowGeo[2], + self.windowGeo[3], + ) self.show() def initMarker(self): - scene = QGraphicsScene(self) - view = QGraphicsView(scene) - view.setMouseTracking(True) - self.setCentralWidget(view) + self.scene = QGraphicsScene(self) + self.view = QGraphicsView(self.scene) + self.view.setMouseTracking(True) + self.setCentralWidget(self.view) + self.view.setAlignment(Qt.AlignTop | Qt.AlignLeft) + self.view.viewport().installEventFilter(self) + self.setMouseTracking(True) self.marker = QGraphicsSimpleTextItem('X') self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) - scene.addItem(self.marker) + self.scene.addItem(self.marker) + self.view.setScene(self.scene) + # Disable scrollbars + self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + self.sidebar_size = self.frameGeometry().width() - self.windowGeo[2] + self.topbar_size = self.frameGeometry().height() - self.windowGeo[3] + self.canvas_width = self.view.frameGeometry().width() - self.sidebar_size + self.canvas_height = self.view.frameGeometry().height() - self.topbar_size + print(f'sidebar size: {self.sidebar_size}, topbar size: {self.topbar_size}') + print(f'canvas width: {self.canvas_width}, canvas height: {self.canvas_height}') + self.display_marker_center( + self.marker, (self.canvas_width / 2, -self.canvas_height / 2) + ) def recordinate(self, rawcord): return -self.scale * (rawcord - (self.windowGeo[2] / 2)) / 50 - def mouseMoveEvent(self, event: 'QGraphicsSceneMouseEvent'): - new_cursor_position = event.screenPos() - - print(f'current x: {new_cursor_position}') - - def mousePressEvent(self, event): - marker_x = self.marker.pos().x() - marker_y = self.marker.pos().y() - print(f'x position: {(marker_x, marker_y)}') - print('Mouse press coords: ( %f : %f )' % (self.mouseX, self.mouseY)) - - def mouseReleaseEvent(self, event): - print('Mouse release coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + # def mouseMoveEvent(self, event: 'QGraphicsSceneMouseEvent'): + # new_cursor_position = event.screenPos() + + # print(f'current x: {new_cursor_position}') + + # def mousePressEvent(self, event): + # # marker_x = self.marker.pos().x() + # # marker_y = self.marker.pos().y() + # marker_x, marker_y = self.get_marker_center(self.marker) + # print(f'x position: {(marker_x, marker_y)}') + + # # print('Mouse press coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + + # def mouseReleaseEvent(self, event): + # pass + # # print('Mouse release coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + + def get_marker_center(self, marker): + fm = QFontMetricsF(QFont()) + boundingRect = fm.tightBoundingRect(marker.text()) + mergintop = fm.ascent() + boundingRect.top() + x = marker.pos().x() + boundingRect.left() + boundingRect.width() / 2 + y = marker.pos().y() + mergintop + boundingRect.height() / 2 + return x, y + + def display_marker_center(self, marker, cord=None): + if cord is None: + cord = (marker.x(), marker.y()) + + # Get the font metrics for accurate measurement + fm = QFontMetricsF(marker.font()) + + # Calculate the bounding rectangle of the text + boundingRect = fm.boundingRect(marker.text()) + + # Calculate the correct position to move the marker + # so that its center is at the specified coordinates + newX = cord[0] - boundingRect.width() / 2 + newY = cord[1] - boundingRect.height() / 2 + + # Set the new position of the marker + marker.setPos(newX, newY) + return marker + + def eventFilter(self, source, event): + "The mouse movements do not work without this function" + if event.type() == QMouseEvent.MouseMove: + print('mouse move') + print(f'x: {event.screenPos().x()}, y: {event.screenPos().y()}') + # print(f'x: {event.posF().x()}, y: {event.posF().y()}') + # print(f'x: {event.localPosF().x()}, y: {event.localPosF().y()}') + # print(f'x: {event.windowPosF().x()}, y: {event.windowPosF().y()}') + # print(f'x: {event.screenPosF().x()}, y: {event.screenPosF().y()}') + # print(f'x: {event.globalPosF().x()}, y: {event.globalPosF().y()}') + print(f'x: {event.pos().x()}, y: {event.pos().y()}') + elif event.type() == QMouseEvent.MouseButtonPress: + print('mouse button pressed') + if event.buttons() == Qt.LeftButton: + print('left button pressed') + elif event.buttons() == Qt.RightButton: + print('right button pressed') + + elif event.type() == QMouseEvent.MouseButtonRelease: + print('mouse button released') + if event.buttons() == Qt.LeftButton: + print('left button released') + elif event.buttons() == Qt.RightButton: + print('right button released') + + return super(CustomWindow, self).eventFilter(source, event) class CtrlWindow(QMainWindow): - def __init__(self): + def __init__(self, mouse_tracker_window): super().__init__() # Get the number of screens and make the live view num_screens = QDesktopWidget().screenCount() logger.debug(f'num screens {num_screens}') - + self.mouse_tracker_window = mouse_tracker_window self.windowGeo = (1300, 300, 500, 1000) self.buttonSize = (200, 100) self.initUI() + # Set the layout on the central widget + def initUI(self): - self.layout = QVBoxLayout(self) + # Create a central widget for the main window + central_widget = QWidget() + self.setCentralWidget(central_widget) + layout = QVBoxLayout(central_widget) + self.setGeometry( self.windowGeo[0], self.windowGeo[1], @@ -120,9 +206,21 @@ def initUI(self): self.buttonSize[0], self.buttonSize[1], ) + self.button.clicked.connect(self.buttonClicked) + layout.addWidget(self.button) self.show() + def buttonClicked(self): + print('button clicked') + x, y = self.mouse_tracker_window.get_marker_center( + self.mouse_tracker_window.marker + ) + print(f'x: {x}, y: {y}') + self.mouse_tracker_window.marker.setPos(0, 0) + # self.mouse_tracker_windowdisplay_marker_center(self.mouse_tracker_window.marker, (0.0, 0.0)) + # self.mouse_tracker_window.marker.setPos(0, 0) + if __name__ == "__main__": import os @@ -130,5 +228,5 @@ def initUI(self): os.environ["DISPLAY"] = ":1005" app = QApplication(sys.argv) dac = CustomWindow() - ctrl = CtrlWindow() + ctrl = CtrlWindow(dac) sys.exit(app.exec_()) From 56a487e93372b7978ce312d2cd234bf76ad97d97 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Thu, 18 Jan 2024 15:58:46 -0800 Subject: [PATCH 26/60] recentering the marker and the coordinates fixed --- .../photom/demo/demo_drawing_windows.py | 95 ++++++++----------- 1 file changed, 38 insertions(+), 57 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_drawing_windows.py b/copylot/assemblies/photom/demo/demo_drawing_windows.py index 64c6c370..f6be8ddf 100644 --- a/copylot/assemblies/photom/demo/demo_drawing_windows.py +++ b/copylot/assemblies/photom/demo/demo_drawing_windows.py @@ -29,77 +29,56 @@ def __init__(self): self.board_num = 0 self.setWindowOpacity(0.7) self.scale = 0.025 - self.offset = (-0.032000, -0.046200) self.view = None self.scene = None - self.initMarker() self.initUI() + self.initMarker() + self.show() print( f'liveView actual {self.frameGeometry().x(), self.frameGeometry().y(), self.width(), self.height()}' ) print(self.frameGeometry().height(), self.frameGeometry().width()) def initUI(self): - # self.setGeometry( - # self.windowGeo[0], - # self.windowGeo[1], - # self.windowGeo[2], - # self.windowGeo[3], - # ) self.setWindowTitle('Mouse Tracker') self.setFixedSize( self.windowGeo[2], self.windowGeo[3], ) - self.show() + self.sidebar_size = self.frameGeometry().width() - self.windowGeo[2] + self.topbar_size = self.frameGeometry().height() - self.windowGeo[3] + self.canvas_width = self.frameGeometry().width() - self.sidebar_size + self.canvas_height = self.frameGeometry().height() - self.topbar_size + print(f'sidebar size: {self.sidebar_size}, topbar size: {self.topbar_size}') + print(f'canvas width: {self.canvas_width}, canvas height: {self.canvas_height}') def initMarker(self): self.scene = QGraphicsScene(self) + self.scene.setSceneRect(0, 0, self.canvas_width, self.canvas_width) + self.view = QGraphicsView(self.scene) self.view.setMouseTracking(True) self.setCentralWidget(self.view) self.view.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.view.viewport().installEventFilter(self) - self.setMouseTracking(True) + # Scene items self.marker = QGraphicsSimpleTextItem('X') self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) - self.scene.addItem(self.marker) - self.view.setScene(self.scene) + # Disable scrollbars self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.sidebar_size = self.frameGeometry().width() - self.windowGeo[2] - self.topbar_size = self.frameGeometry().height() - self.windowGeo[3] - self.canvas_width = self.view.frameGeometry().width() - self.sidebar_size - self.canvas_height = self.view.frameGeometry().height() - self.topbar_size - print(f'sidebar size: {self.sidebar_size}, topbar size: {self.topbar_size}') - print(f'canvas width: {self.canvas_width}, canvas height: {self.canvas_height}') + # Move the marker self.display_marker_center( - self.marker, (self.canvas_width / 2, -self.canvas_height / 2) + self.marker, (self.canvas_width / 2, self.canvas_height / 2) ) - def recordinate(self, rawcord): - return -self.scale * (rawcord - (self.windowGeo[2] / 2)) / 50 - - # def mouseMoveEvent(self, event: 'QGraphicsSceneMouseEvent'): - # new_cursor_position = event.screenPos() - - # print(f'current x: {new_cursor_position}') - - # def mousePressEvent(self, event): - # # marker_x = self.marker.pos().x() - # # marker_y = self.marker.pos().y() - # marker_x, marker_y = self.get_marker_center(self.marker) - # print(f'x position: {(marker_x, marker_y)}') - - # # print('Mouse press coords: ( %f : %f )' % (self.mouseX, self.mouseY)) - - # def mouseReleaseEvent(self, event): - # pass - # # print('Mouse release coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + # Add items and set the scene + self.scene.addItem(self.marker) + self.view.setScene(self.scene) def get_marker_center(self, marker): fm = QFontMetricsF(QFont()) @@ -109,36 +88,32 @@ def get_marker_center(self, marker): y = marker.pos().y() + mergintop + boundingRect.height() / 2 return x, y - def display_marker_center(self, marker, cord=None): - if cord is None: - cord = (marker.x(), marker.y()) + def display_marker_center(self, marker, coords=None): + if coords is None: + coords = (marker.x(), marker.y()) - # Get the font metrics for accurate measurement - fm = QFontMetricsF(marker.font()) - - # Calculate the bounding rectangle of the text - boundingRect = fm.boundingRect(marker.text()) - - # Calculate the correct position to move the marker - # so that its center is at the specified coordinates - newX = cord[0] - boundingRect.width() / 2 - newY = cord[1] - boundingRect.height() / 2 - - # Set the new position of the marker - marker.setPos(newX, newY) + if coords is None: + coords = (marker.x(), marker.y()) + fm = QFontMetricsF(QFont()) + boundingRect = fm.tightBoundingRect(marker.text()) + mergintop = fm.ascent() + boundingRect.top() + marker.setPos( + coords[0] - boundingRect.left() - boundingRect.width() / 2, + coords[1] - mergintop - boundingRect.height() / 2, + ) return marker def eventFilter(self, source, event): "The mouse movements do not work without this function" if event.type() == QMouseEvent.MouseMove: print('mouse move') - print(f'x: {event.screenPos().x()}, y: {event.screenPos().y()}') + print(f'x1: {event.screenPos().x()}, y1: {event.screenPos().y()}') # print(f'x: {event.posF().x()}, y: {event.posF().y()}') # print(f'x: {event.localPosF().x()}, y: {event.localPosF().y()}') # print(f'x: {event.windowPosF().x()}, y: {event.windowPosF().y()}') # print(f'x: {event.screenPosF().x()}, y: {event.screenPosF().y()}') # print(f'x: {event.globalPosF().x()}, y: {event.globalPosF().y()}') - print(f'x: {event.pos().x()}, y: {event.pos().y()}') + print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') elif event.type() == QMouseEvent.MouseButtonPress: print('mouse button pressed') if event.buttons() == Qt.LeftButton: @@ -217,7 +192,13 @@ def buttonClicked(self): self.mouse_tracker_window.marker ) print(f'x: {x}, y: {y}') - self.mouse_tracker_window.marker.setPos(0, 0) + self.mouse_tracker_window.display_marker_center( + self.mouse_tracker_window.marker, + ( + self.mouse_tracker_window.canvas_width / 2, + self.mouse_tracker_window.canvas_height / 2, + ), + ) # self.mouse_tracker_windowdisplay_marker_center(self.mouse_tracker_window.marker, (0.0, 0.0)) # self.mouse_tracker_window.marker.setPos(0, 0) From 2879bcebd91dac5dab2636744fbfc2fe98e9caf7 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Fri, 19 Jan 2024 12:11:55 -0800 Subject: [PATCH 27/60] calibration and mouse events fixed --- .../photom/demo/demo_mouse_events.py | 43 +++ .../photom/demo/demo_photom_assembly.py | 50 +-- .../photom/demo/photom_calibration.py | 307 +++++++++++++----- copylot/assemblies/photom/photom.py | 53 ++- .../photom/utils/affine_transform.py | 18 +- 5 files changed, 373 insertions(+), 98 deletions(-) create mode 100644 copylot/assemblies/photom/demo/demo_mouse_events.py diff --git a/copylot/assemblies/photom/demo/demo_mouse_events.py b/copylot/assemblies/photom/demo/demo_mouse_events.py new file mode 100644 index 00000000..9677fa52 --- /dev/null +++ b/copylot/assemblies/photom/demo/demo_mouse_events.py @@ -0,0 +1,43 @@ +from PyQt5.QtWidgets import QApplication, QMainWindow +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPainter, QPen +import sys + +class CustomWindow(QMainWindow): + def __init__(self): + super().__init__() + self.initUI() + self.setMouseTracking(True) + self.mouseX = None + self.mouseY = None + self.setWindowOpacity(0.7) + + def initUI(self): + self.setGeometry(300, 300, 300, 200) + self.setWindowTitle('Mouse Tracker') + + self.show() + + def mouseMoveEvent(self, event): + self.mouseX = event.x() / 100 + self.mouseY = event.y() / 100 + print('Mouse coords: ( %d : %d )' % (event.x(), event.y())) + + def mousePressEvent(self, event): + print('mouse pressed') + + def mouseReleaseEvent(self, event): + print('mouse released') + + def paintEvent(self, event=None): + painter = QPainter(self) + + # painter.setOpacity(0.5) + painter.setBrush(Qt.white) + painter.setPen(QPen(Qt.white)) + painter.drawRect(self.rect()) + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = CustomWindow() + app.exec_() diff --git a/copylot/assemblies/photom/demo/demo_photom_assembly.py b/copylot/assemblies/photom/demo/demo_photom_assembly.py index aeb45fe8..1cf4a9fc 100644 --- a/copylot/assemblies/photom/demo/demo_photom_assembly.py +++ b/copylot/assemblies/photom/demo/demo_photom_assembly.py @@ -1,44 +1,56 @@ - -#%% +# %% from copylot.assemblies.photom.photom import PhotomAssembly +from copylot.assemblies.photom.utils import affine_transform from copylot.hardware.mirrors.optotune.mirror import OptoMirror from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror +import time -#%% - +# %% +# Mock imports for the mirror and the lasers laser = MockLaser('Mock Laser', power=0) mirror = OptoMirror(com_port='COM8') -#%% -mirror.position = (0.009,0.0090) -# %% -mirror.position = (0.000,0.000) - -# %% -photom_device = PhotomAssembly(laser=[laser], mirror=[mirror], - affine_matrix_path=[r'C:\Users\ZebraPhysics\Documents\GitHub\coPylot\copylot\assemblies\photom\demo\affine_T.yml'], - ) -# %% -photom_device? # %% +# Test the moving of the mirrors +mirror.position = (0.009, 0.0090) +time.sleep(1) +mirror.position = (0.000, 0.000) curr_pos = photom_device.get_position(mirror_index=0) print(curr_pos) -#%% -photom_device.set_position(mirror_index=0,position=[0.009,0.009]) +assert curr_pos == (0.000, 0.000) +# %% +## Test using the photom_device +camera_sensor_width = 1280 +camera_sensor_height = 1280 + +photom_device = PhotomAssembly( + laser=[laser], + mirror=[mirror], + affine_matrix_path=[ + r'C:\Users\ZebraPhysics\Documents\GitHub\coPylot\copylot\assemblies\photom\demo\test_tmp.yml' + ], +) +photom_device.set_position( + mirror_index=0, position=[camera_sensor_width // 2, camera_sensor_height // 2] +) curr_pos = photom_device.get_position(mirror_index=0) print(curr_pos) + # %% +# TODO: Test the calibration without GUI import time + start_time = time.time() -center = 0.009 photom_device._calibrating = True while time.time() - start_time < 5: # Your code here elapsed_time = time.time() - start_time print(f'starttime: {start_time} elapsed_time: {elapsed_time}') - photom_device.calibrate(mirror_index=0, rectangle_size_xy=[0.002, 0.002], center=[0.000,0.000]) + photom_device.calibrate( + mirror_index=0, rectangle_size_xy=[0.002, 0.002], center=[0.000, 0.000] + ) photom_device._calibrating = False # %% diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 73827f73..9b42428a 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -19,7 +19,7 @@ QComboBox, QFileDialog, ) -from PyQt5.QtGui import QColor, QPen +from PyQt5.QtGui import QColor, QPen, QFont, QFontMetricsF, QMouseEvent from copylot.assemblies.photom.utils.scanning_algorithms import ( calculate_rectangle_corners, ) @@ -33,9 +33,6 @@ # TODO: deal with the logic when clicking calibrate. Mirror dropdown -# TODO: check that the calibration step is implemented properly. -# TODO: connect marker to actual mirror position. Unclear why it's not working. -# TODO: verify that if you move the window, the markers frame of reference is still the same class LaserWidget(QWidget): @@ -166,6 +163,7 @@ def __init__( self.photom_window_size = photom_window_size self.photom_window_pos = photom_window_pos self._current_mirror_idx = 0 + self._laser_window_transparency = 0.7 self.calibration_thread = CalibrationThread( self.photom_assembly, self._current_mirror_idx @@ -211,7 +209,9 @@ def initialize_UI(self): self.transparency_slider = QSlider(Qt.Horizontal) self.transparency_slider.setMinimum(0) self.transparency_slider.setMaximum(100) - self.transparency_slider.setValue(100) # Initial value is fully opaque + self.transparency_slider.setValue( + int(self._laser_window_transparency * 100) + ) # Initial value is fully opaque self.transparency_slider.valueChanged.connect(self.update_transparency) transparency_layout.addWidget(self.transparency_slider) @@ -235,7 +235,7 @@ def initialize_UI(self): mirror_layout = QVBoxLayout() self.mirror_widgets = [] - for mirror in self.mirrors: + for idx, mirror in enumerate(self.mirrors): mirror_widget = MirrorWidget(mirror) self.mirror_widgets.append(mirror_widget) mirror_layout.addWidget(mirror_widget) @@ -253,10 +253,18 @@ def initialize_UI(self): self.mirror_dropdown.setCurrentIndex(self._current_mirror_idx) self.mirror_dropdown.currentIndexChanged.connect(self.mirror_dropdown_changed) + self.recenter_marker_button = QPushButton("Recenter Marker") + self.recenter_marker_button.clicked.connect(self.recenter_marker) + main_layout.addWidget(self.recenter_marker_button) + self.calibrate_button = QPushButton("Calibrate") self.calibrate_button.clicked.connect(self.calibrate) main_layout.addWidget(self.calibrate_button) + self.load_calibration_button = QPushButton("Load Calibration") + self.load_calibration_button.clicked.connect(self.load_calibration) + main_layout.addWidget(self.load_calibration_button) + # Add a "Done Calibration" button (initially hidden) self.done_calibration_button = QPushButton("Done Calibration") self.done_calibration_button.clicked.connect(self.done_calibration) @@ -281,21 +289,25 @@ def mirror_dropdown_changed(self, index): # Reset to (0,0) position self.photom_assembly.mirror[self._current_mirror_idx].position = [0, 0] + def recenter_marker(self): + self.photom_window.display_marker_center( + self.photom_window.marker, + (self.photom_window.canvas_width / 2, self.photom_window.canvas_height / 2), + ) + def calibrate(self): # Implement your calibration function here print("Calibrating...") - # Hide the 'X' marker in photom_window - # self.photom_window.marker.hide() # Hide the calibrate button self.calibrate_button.hide() + self.load_calibration_button.hide() # Show the "Cancel Calibration" button self.cancel_calibration_button.show() # Display the rectangle self.display_rectangle() - self.source_pts = self.photom_window.get_coordinates() # Show the "Done Calibration" button self.done_calibration_button.show() - + # Get the mirror idx selected_mirror_name = self.mirror_dropdown.currentText() self._current_mirror_idx = next( i @@ -307,6 +319,29 @@ def calibrate(self): else: self.photom_assembly._calibrating = True self.calibration_thread.start() + self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.reset_T_affine() + + def load_calibration(self): + self.photom_assembly._calibrating = False + print("Loading calibration...") + # Prompt the user to select a file + typed_filename, _ = QFileDialog.getOpenFileName( + self, "Open Calibration File", "", "YAML Files (*.yml)" + ) + if typed_filename: + assert typed_filename.endswith(".yml") + print("Selected file:", typed_filename) + # Load the matrix + self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.load_matrix(config_file=typed_filename) + print( + f'Loaded matrix:{self.photom_assembly.mirror[self._current_mirror_idx].affine_transform_obj.T_affine}' + ) + self.photom_window.switch_to_shooting_scene() + self.photom_window.marker.show() def cancel_calibration(self): self.photom_assembly._calibrating = False @@ -317,6 +352,7 @@ def cancel_calibration(self): self.done_calibration_button.hide() # Show the "Calibrate" button self.calibrate_button.show() + self.load_calibration_button.show() # Show the "X" marker in photom_window self.photom_window.marker.show() @@ -331,22 +367,27 @@ def done_calibration(self): ## Perform any necessary actions after calibration is done # Get the mirror (target) positions self.target_pts = self.photom_window.get_coordinates() - origin = np.array( - [[pt.x(), pt.y()] for pt in self.source_pts], dtype=np.float32 - ) # Mirror calibration size mirror_calib_size = self.photom_assembly._calibration_rectangle_size_xy - self.target_pts = [ - [pts[0] * mirror_calib_size[0], pts[1] * mirror_calib_size[1]] - for pts in self.target_pts - ] - - dest = np.array([[pt.x(), pt.y()] for pt in self.target_pts], dtype=np.float32) - + origin = np.array( + [[pt.x(), pt.y()] for pt in self.target_pts], + dtype=np.float32, + ) + # TODO make the dest points from the mirror calibration size + mirror_x = mirror_calib_size[0] / 2 + mirror_y = mirror_calib_size[1] / 2 + dest = np.array( + [ + [-mirror_x, -mirror_y], + [mirror_x, -mirror_y], + [mirror_x, mirror_y], + [-mirror_x, mirror_y], + ] + ) T_affine = self.photom_assembly.mirror[ self._current_mirror_idx - ].affine_transform_obj.get_affine_matrix(dest, origin) + ].affine_transform_obj.get_affine_matrix(origin, dest) # logger.debug(f"Affine matrix: {T_affine}") print(f"Affine matrix: {T_affine}") @@ -398,15 +439,6 @@ def update_transparency(self, value): self.photom_window.setWindowOpacity(opacity) # Update photom_window opacity def display_rectangle(self): - # Calculate the coordinates of the rectangle corners - rectangle_scaling = 0.5 - window_size = (self.photom_window.width(), self.photom_window.height()) - rectangle_size = ( - (window_size[0] * rectangle_scaling), - (window_size[1] * rectangle_scaling), - ) - rectangle_coords = calculate_rectangle_corners(rectangle_size) - self.photom_window.updateVertices(rectangle_coords) self.photom_window.switch_to_calibration_scene() @@ -440,31 +472,38 @@ def __init__( self.window_name = name self.window_geometry = window_pos + window_size self.setMouseTracking(True) - self.mouseX = None - self.mouseY = None - self.setWindowOpacity(0.7) - self.scale = 0.025 - # self.offset = (-0.032000, -0.046200) + self.setWindowOpacity(self.photom_controls._laser_window_transparency) # Create a QStackedWidget + # TODO: do we need the stacked widget? self.stacked_widget = QStackedWidget() # Set the QStackedWidget as the central widget - + self.initialize_UI() self.initMarker() - self.init_tetragon() - self.initialize_UI() + tetragon_coords = calculate_rectangle_corners( + [self.canvas_width / 5, self.canvas_height / 5], + center=[self.canvas_width / 2, self.canvas_height / 2], + ) + self.init_tetragon(tetragon_coords=tetragon_coords) self.setCentralWidget(self.stacked_widget) + self.switch_to_shooting_scene() + + # Flags for mouse tracking + # NOTE: these are variables inherited from the photom_controls + self.calibration_mode = self.photom_controls.photom_assembly._calibrating + + # show the window + self.show() + + # FLAGS + self._right_click_hold = False + self._left_click_hold = False + def initialize_UI(self): print(f'window geometry: {self.window_geometry}') - self.setGeometry( - self.window_geometry[0], - self.window_geometry[1], - self.window_geometry[2], - self.window_geometry[3], - ) self.setWindowTitle(self.window_name) # Fix the size of the window @@ -472,37 +511,78 @@ def initialize_UI(self): self.window_geometry[2], self.window_geometry[3], ) - self.switch_to_shooting_scene() - self.show() + self.sidebar_size = self.frameGeometry().width() - self.window_geometry[2] + self.topbar_size = self.frameGeometry().height() - self.window_geometry[3] + self.canvas_width = self.frameGeometry().width() - self.sidebar_size + self.canvas_height = self.frameGeometry().height() - self.topbar_size + + print(f'sidebar size: {self.sidebar_size}, topbar size: {self.topbar_size}') + print(f'canvas width: {self.canvas_width}, canvas height: {self.canvas_height}') def initMarker(self): + # Generate the shooting scene self.shooting_scene = QGraphicsScene(self) + self.shooting_scene.setSceneRect(0, 0, self.canvas_width, self.canvas_height) + + # Generate the shooting view self.shooting_view = QGraphicsView(self.shooting_scene) self.shooting_view.setMouseTracking(True) self.setCentralWidget(self.shooting_view) + self.shooting_view.setAlignment(Qt.AlignTop | Qt.AlignLeft) + + # Mouse tracking + self.shooting_view.installEventFilter(self) self.setMouseTracking(True) self.marker = QGraphicsSimpleTextItem("X") + self.marker.setBrush(QColor(255, 0, 0)) self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) - self.shooting_scene.addItem(self.marker) + self.shooting_view.viewport().installEventFilter(self) + # Position the marker + self.display_marker_center( + self.marker, (self.canvas_width / 2, self.canvas_height / 2) + ) + self.shooting_scene.addItem(self.marker) # Add the view to the QStackedWidget self.stacked_widget.addWidget(self.shooting_view) + # Disable scrollbars + self.shooting_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.shooting_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + def init_tetragon( self, tetragon_coords: list = [(100, 100), (200, 100), (200, 200), (100, 200)] ): + # Generate the calibration scene self.calibration_scene = QGraphicsScene(self) + self.calibration_scene.setSceneRect(0, 0, self.canvas_width, self.canvas_height) + + # Generate the calibration view self.calibration_view = QGraphicsView(self.calibration_scene) - self.calibration_view.setMouseTracking(True) - self.setCentralWidget(self.calibration_view) - self.setMouseTracking(True) + self.calibration_view.setAlignment(Qt.AlignTop | Qt.AlignLeft) + + # Disable scrollbars + self.calibration_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.calibration_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + # Add the tetragon to the calibration scene self.vertices = [] for x, y in tetragon_coords: - vertex = QGraphicsEllipseItem(x - 5, y - 5, 10, 10) + vertex = QGraphicsEllipseItem(0, 0, 10, 10) vertex.setBrush(Qt.red) vertex.setFlag(QGraphicsEllipseItem.ItemIsMovable) + vertex.setPos(x, y) self.vertices.append(vertex) self.calibration_scene.addItem(vertex) + print(f"Vertex added at: ({x}, {y})") # Debugging statement + + print( + f"Scene Rect: {self.calibration_scene.sceneRect()}" + ) # Debugging statement + + # Mouse tracking + self.calibration_view.installEventFilter(self) + self.setMouseTracking(True) # Add the view to the QStackedWidget self.stacked_widget.addWidget(self.calibration_view) @@ -516,33 +596,112 @@ def switch_to_calibration_scene(self): def get_coordinates(self): return [vertex.pos() for vertex in self.vertices] - def updateVertices(self, new_coordinates): + def create_tetragon(self, tetragon_coords): + # Add the tetragon to the calibration scene + self.vertices = [] + for x, y in tetragon_coords: + vertex = QGraphicsEllipseItem(x - 5, y - 5, 10, 10) + vertex.setBrush(Qt.red) + vertex.setFlag(QGraphicsEllipseItem.ItemIsMovable) + self.vertices.append(vertex) + vertex.setVisible(True) # Show the item + self.calibration_scene.addItem(vertex) + + def update_vertices(self, new_coordinates): + # Check if the lengths of vertices and new_coordinates match + if len(self.vertices) != len(new_coordinates): + print("Error: Mismatch in the number of vertices and new coordinates") + return for vertex, (x, y) in zip(self.vertices, new_coordinates): vertex.setPos(x, y) - - def recordinate(self, rawcord): - return -self.scale * (rawcord - (self.window_geometry[2] / 2)) / 50 - - def mouseMoveEvent(self, event: "QGraphicsSceneMouseEvent"): - new_cursor_position = event.screenPos() - print(f"current x: {new_cursor_position}") - - def mousePressEvent(self, event): - marker_x = self.marker.pos().x() - marker_y = self.marker.pos().y() - print(f"x position (x,y): {(marker_x, marker_y)}") - # print('Mouse press coords: ( %f : %f )' % (self.mouseX, self.mouseY)) + print(f'vertex pos: {vertex.pos()}') + + def eventFilter(self, source, event): + "The mouse movements do not work without this function" + self.calibration_mode = self.photom_controls.photom_assembly._calibrating + if event.type() == QMouseEvent.MouseMove: + pass + if self._left_click_hold and not self.calibration_mode: + # Move the mirror around if the left button is clicked + self._move_marker_and_update_sliders() + # Debugging statements + # print('mouse move') + # print(f'x1: {event.screenPos().x()}, y1: {event.screenPos().y()}') + # print(f'x: {event.posF().x()}, y: {event.posF().y()}') + # print(f'x: {event.localPosF().x()}, y: {event.localPosF().y()}') + # print(f'x: {event.windowPosF().x()}, y: {event.windowPosF().y()}') + # print(f'x: {event.screenPosF().x()}, y: {event.screenPosF().y()}') + # print(f'x: {event.globalPosF().x()}, y: {event.globalPosF().y()}') + # print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') + elif event.type() == QMouseEvent.MouseButtonPress: + print('mouse button pressed') + if self.calibration_mode: + print('calibration mode') + if event.buttons() == Qt.LeftButton: + self._left_click_hold = True + print('left button pressed') + # print(f'x: {event.posF().x()}, y: {event.posF().y()}') + print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') + elif event.buttons() == Qt.RightButton: + self._right_click_hold = True + print('right button pressed') + else: + print('shooting mode') + if event.buttons() == Qt.LeftButton: + self._left_click_hold = True + print('left button pressed') + print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') + self._move_marker_and_update_sliders() + elif event.buttons() == Qt.RightButton: + self._right_click_hold = True + print('right button pressed') + elif event.type() == QMouseEvent.MouseButtonRelease: + print('mouse button released') + if event.buttons() == Qt.LeftButton: + self._left_click_hold = False + print('left button released') + elif event.buttons() == Qt.RightButton: + self._right_click_hold = False + print('right button released') + + return super(LaserMarkerWindow, self).eventFilter(source, event) + + def _move_marker_and_update_sliders(self): # Update the mirror slider values if self.photom_controls is not None: + marker_position = [self.marker.pos().x(), self.marker.pos().y()] + new_coords = self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror.affine_transform_obj.apply_affine(marker_position) self.photom_controls.mirror_widgets[ self.photom_controls._current_mirror_idx - ].mirror_x_slider.setValue(int(self.marker.pos().x())) + ].mirror_x_slider.setValue(new_coords[0][0]) self.photom_controls.mirror_widgets[ self.photom_controls._current_mirror_idx - ].mirror_y_slider.setValue(int(self.marker.pos().y())) - - def mouseReleaseEvent(self, event): - print("Mouse release coords: ( %f : %f )" % (self.mouseX, self.mouseY)) + ].mirror_y_slider.setValue(new_coords[1][0]) + + def get_marker_center(self, marker): + fm = QFontMetricsF(QFont()) + boundingRect = fm.tightBoundingRect(marker.text()) + mergintop = fm.ascent() + boundingRect.top() + x = marker.pos().x() + boundingRect.left() + boundingRect.width() / 2 + y = marker.pos().y() + mergintop + boundingRect.height() / 2 + return x, y + + def display_marker_center(self, marker, coords=None): + if coords is None: + coords = (marker.x(), marker.y()) + + if coords is None: + coords = (marker.x(), marker.y()) + fm = QFontMetricsF(QFont()) + boundingRect = fm.tightBoundingRect(marker.text()) + mergintop = fm.ascent() + boundingRect.top() + marker.setPos( + coords[0] - boundingRect.left() - boundingRect.width() / 2, + coords[1] - mergintop - boundingRect.height() / 2, + ) + return marker if __name__ == "__main__": @@ -563,12 +722,6 @@ def mouseReleaseEvent(self, event): Laser = MockLaser from copylot.hardware.mirrors.optotune.mirror import OptoMirror as Mirror - # try: - # os.environ["DISPLAY"] = ":1003" - - # except: - # raise Exception("DISPLAY environment variable not set") - config_path = r"./copylot/assemblies/photom/demo/photom_VIS_config.yml" # TODO: this should be a function that parses the config_file and returns the photom_assembly @@ -628,7 +781,7 @@ def mouseReleaseEvent(self, event): rectangle_coords = calculate_rectangle_corners(rectangle_size) # translate each coordinate by the offset rectangle_coords = [(x + 30, y) for x, y in rectangle_coords] - camera_window.updateVertices(rectangle_coords) + camera_window.update_vertices(rectangle_coords) else: # Set the positions of the windows ctrl_window = PhotomApp( diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index 47073407..915685fc 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -37,7 +37,7 @@ def __init__( # TODO: replace these hardcoded values to mirror's scan steps given the magnification # and the mirrors angles - self._calibration_rectangle_size_xy = [0.002, 0.002] + self._calibration_rectangle_size_xy = [0.004, 0.004] assert len(self.mirror) == len(affine_matrix_path) @@ -61,6 +61,7 @@ def calibrate( i += 1 if i == 4: i = 0 + time.sleep(1) # moving the mirror in a rectangle else: raise IndexError("Mirror index out of range.") @@ -114,3 +115,53 @@ def get_laser_power(self, laser_index: int) -> float: def set_laser_power(self, laser_index: int, power: float): self.laser[laser_index].power = power + + ## Functions to convert between image coordinates and mirror coordinates or to DAC voltages + def normalize(self, value, min_val, max_val): + # Error handling for division by zero + if max_val == min_val: + raise ValueError("Maximum and minimum values cannot be the same") + return (value - min_val) / (max_val - min_val) + + def convert_values( + self, input_values: list, input_range: list, output_range: list + ) -> list: + """ + Converts a list of input values from one range to another + input_values: list of values to convert + input_range: list containing the minimum and maximum values of the input range + output_range: list containing the minimum and maximum values of the output range + + Returns a list of converted values + """ + # Error handling for incorrect range definitions + if not (len(input_range) == 2 and len(output_range) == 2): + raise ValueError( + "Input and output ranges must each contain exactly two elements" + ) + if not (input_range[0] < input_range[1] and output_range[0] < output_range[1]): + raise ValueError( + "In both ranges, the first element must be less than the second" + ) + + # input_values is not a list make a list + if not isinstance(input_values, list): + input_values = [input_values] + + # Precompute range differences + input_min, input_max = min(input_range), max(input_range) + output_min, output_max = min(output_range), max(output_range) + output_span = output_max - output_min + + output_values = [] + for input_val in input_values: + normalized_val = self.normalize(input_val, input_min, input_max) + output_values.append(normalized_val * output_span + output_min) + + return output_values + + def get_sensor_size(self): + if self.camera is not None: + self.camera_sensor_size = self.camera[0].sensor_size + else: + raise NotImplementedError("No camera found.") diff --git a/copylot/assemblies/photom/utils/affine_transform.py b/copylot/assemblies/photom/utils/affine_transform.py index d958e733..e0cab455 100644 --- a/copylot/assemblies/photom/utils/affine_transform.py +++ b/copylot/assemblies/photom/utils/affine_transform.py @@ -76,7 +76,7 @@ def get_affine_matrix(self, origin, dest): ) return self.T_affine - def apply_affine(self, coord_list: list)->list: + def apply_affine(self, coord_list: list) -> list: """ Perform affine transformation. :param coord_list: a list of origin coordinate (e.g. [[x,y], ...] or [[list for ch0], [list for ch1]]) @@ -152,3 +152,19 @@ def save_matrix(self, matrix: np.array = None, config_file: Path = None) -> None affine_transform_yx=matrix.tolist(), ) model_to_yaml(model, self.config_file) + + def load_matrix(self, config_file: Path = None) -> None: + """ + Load affine matrix from a YAML file. + + Parameters + ---------- + config_file : str, optional + path to the YAML file, by default None will load the current config_file. + This updates the config_file attribute. + """ + if config_file is not None: + self.config_file = config_file + + settings = yaml_to_model(self.config_file, AffineTransformationSettings) + self.T_affine = np.array(settings.affine_transform_yx) From abd9c24bf6ff55a6a2b6f8f2dc931e093be11e31 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Thu, 25 Jan 2024 12:12:56 -0800 Subject: [PATCH 28/60] adding partial laser controls with gui --- .../photom/demo/photom_VIS_config.yml | 6 +- .../photom/demo/photom_calibration.py | 64 +++++++++++++------ copylot/hardware/lasers/vortran/vortran.py | 21 ++++-- 3 files changed, 60 insertions(+), 31 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_VIS_config.yml b/copylot/assemblies/photom/demo/photom_VIS_config.yml index cb7c134a..78fba271 100644 --- a/copylot/assemblies/photom/demo/photom_VIS_config.yml +++ b/copylot/assemblies/photom/demo/photom_VIS_config.yml @@ -1,8 +1,6 @@ lasers: - - name: laser_1 - power: 50 - - name: laser_2 - power: 30 + - name: laser_405 + COM_port: COM7 mirrors: - name: mirror_1_VIS diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 9b42428a..2b7e929e 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -31,8 +31,10 @@ # DEMO_MODE = True DEMO_MODE = False - +# TODO fix the right click releaser # TODO: deal with the logic when clicking calibrate. Mirror dropdown +# TODO: update the mock laser and mirror +# TODO: replace the entry boxes tos et the laser powers class LaserWidget(QWidget): @@ -41,12 +43,18 @@ def __init__(self, laser): self.laser = laser self.emission_state = 0 # 0 = off, 1 = on + self.emission_delay = 0.0 + + self._curr_power = 0.0 + self._slider_decimal = 1 self.initializer_laser() self.initialize_UI() def initializer_laser(self): self.laser.toggle_emission = self.emission_state + self.laser.power = self._curr_power + self.laser.emission_delay = self.emission_delay def initialize_UI(self): layout = QVBoxLayout() @@ -54,15 +62,17 @@ def initialize_UI(self): self.laser_label = QLabel(self.laser.name) layout.addWidget(self.laser_label) - self.laser_power_slider = QSlider(Qt.Horizontal) + self.laser_power_slider = DoubleSlider( + orientation=Qt.Horizontal, decimals=self._slider_decimal + ) self.laser_power_slider.setMinimum(0) self.laser_power_slider.setMaximum(100) - self.laser_power_slider.setValue(self.laser.laser_power) + self.laser_power_slider.setValue(self.laser.power) self.laser_power_slider.valueChanged.connect(self.update_power) layout.addWidget(self.laser_power_slider) # Add a QLabel to display the power value - self.power_label = QLabel(f"Power: {self.laser.laser_power}") + self.power_label = QLabel(f"Power: {self._curr_power} %") layout.addWidget(self.power_label) self.laser_toggle_button = QPushButton("Toggle") @@ -83,9 +93,10 @@ def toggle_laser(self): self.laser_toggle_button.setStyleSheet("background-color: green") def update_power(self, value): - self.laser.laser_power = value + self.laser.power = value / (10**self._slider_decimal) + self._curr_power = self.laser.power # Update the QLabel with the new power value - self.power_label.setText(f"Power: {value}") + self.power_label.setText(f"Power: {self._curr_power} %") # TODO: connect widget to actual abstract mirror calls @@ -654,15 +665,27 @@ def eventFilter(self, source, event): self._move_marker_and_update_sliders() elif event.buttons() == Qt.RightButton: self._right_click_hold = True + self.photom_controls.photom_assembly.laser[0].toggle_emission = True print('right button pressed') elif event.type() == QMouseEvent.MouseButtonRelease: - print('mouse button released') - if event.buttons() == Qt.LeftButton: - self._left_click_hold = False - print('left button released') - elif event.buttons() == Qt.RightButton: - self._right_click_hold = False - print('right button released') + if self.calibration_mode: + if event.buttons() == Qt.LeftButton: + self._left_click_hold = False + print('left button released') + elif event.buttons() == Qt.RightButton: + self._right_click_hold = False + print('right button released') + else: + print('mouse button released') + if event.buttons() == Qt.LeftButton: + self._left_click_hold = False + print('left button released') + elif event.buttons() == Qt.RightButton: + print('right button released') + self._right_click_hold = False + self.photom_controls.photom_assembly.laser[ + 0 + ].toggle_emission = False return super(LaserMarkerWindow, self).eventFilter(source, event) @@ -714,13 +737,8 @@ def display_marker_center(self, marker, coords=None): Mirror = MockMirror else: - # NOTE: These are the actual classes that will be used in the photom assembly - # from copylot.hardware.lasers.vortran import VortranLaser as Laser - # TODO: remove after testing - from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror - - Laser = MockLaser from copylot.hardware.mirrors.optotune.mirror import OptoMirror as Mirror + from copylot.hardware.lasers.vortran.vortran import VortranLaser as Laser config_path = r"./copylot/assemblies/photom/demo/photom_VIS_config.yml" @@ -728,7 +746,13 @@ def display_marker_center(self, marker, coords=None): # Load the config file and parse it with open(config_path, "r") as config_file: config = yaml.load(config_file, Loader=yaml.FullLoader) - lasers = [Laser(**laser_data) for laser_data in config["lasers"]] + lasers = [ + Laser( + name=laser_data["name"], + port=laser_data["COM_port"], + ) + for laser_data in config["lasers"] + ] mirrors = [ Mirror( name=mirror_data["name"], diff --git a/copylot/hardware/lasers/vortran/vortran.py b/copylot/hardware/lasers/vortran/vortran.py index 29a0b9ec..bb6322c2 100644 --- a/copylot/hardware/lasers/vortran/vortran.py +++ b/copylot/hardware/lasers/vortran/vortran.py @@ -47,7 +47,7 @@ class VortranLaser(AbstractLaser): ] VOLTRAN_CMDS = GLOBAL_CMD + GLOBAL_QUERY + LASER_CMD + LASER_QUERY - def __init__(self, serial_number=None, port=None, baudrate=19200, timeout=1): + def __init__(self, name, serial_number=None, port=None, baudrate=19200, timeout=1): """ Wrapper for vortran stradus lasers. establishes a connection through COM port @@ -65,6 +65,7 @@ def __init__(self, serial_number=None, port=None, baudrate=19200, timeout=1): timeout for write/read in seconds """ # Serial Communication + self.name = name self.port: str = port self.baudrate: int = baudrate self.address = None @@ -308,15 +309,15 @@ def external_power_control(self, control): self._ext_power_ctrl = self._write_cmd('EPC', str(control))[0] @property - def current_control(self): + def current_control_mode(self): """ Laser Current Control (0-Max) """ self._current_ctrl = self._write_cmd('?LC')[0] return self._current_ctrl - @current_control.setter - def current_control(self, value): + @current_control_mode.setter + def current_control_mode(self, value): """ Laser Current Control (0-Max) """ @@ -337,6 +338,12 @@ def toggle_emission(self, value): Toggles Laser Emission On and Off (1 = On, 0 = Off) """ + + if isinstance(value, bool): + value = str(int(value)) + elif isinstance(value, int): + value = str(value) + self._toggle_emission = self._write_cmd('LE', value)[0] def turn_on(self): @@ -356,7 +363,7 @@ def turn_off(self): return self._toggle_emission @property - def laser_power(self): + def power(self): """ Sets the laser power Requires pulse_mode() to be OFF @@ -364,8 +371,8 @@ def laser_power(self): self._curr_power = float(self._write_cmd('?LP')[0]) return self._curr_power - @laser_power.setter - def laser_power(self, power: float): + @power.setter + def power(self, power: float): """ Sets the laser power Requires pulse_mode() to be OFF From 0adf31f9cfa75220fd02f77e22f69999ae4f8ab9 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Tue, 6 Feb 2024 17:38:44 -0800 Subject: [PATCH 29/60] adding flipping of camera sensor and pixel format for the flir camera. --- copylot/hardware/cameras/flir/flir_camera.py | 70 ++++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/copylot/hardware/cameras/flir/flir_camera.py b/copylot/hardware/cameras/flir/flir_camera.py index c71cb9df..46edf6e0 100644 --- a/copylot/hardware/cameras/flir/flir_camera.py +++ b/copylot/hardware/cameras/flir/flir_camera.py @@ -67,17 +67,17 @@ def device_id(self): """ return self._device_id - @device_id.setter - def device_id(self, val): - """ - Set the serial number of the current camera - - Parameters - __________ - val : string - Unique serial number of the current camera - """ - self._device_id = val + # @device_id.setter + # def device_id(self, val): + # """ + # Set the serial number of the current camera + + # Parameters + # __________ + # val : string + # Unique serial number of the current camera + # """ + # self._device_id = val def open(self, index=0): """ @@ -545,4 +545,50 @@ def shutter_mode(self, mode='global'): logger.error( 'Mode input: ', mode, ' is not valid. Enter global or rolling mode' ) - pass + + @property + def flip_sensor_X(self): + return self.cam.ReverseX.GetValue() + + @flip_sensor_X.setter + def flip_sensor_X(self, value): + self.cam.ReverseX.SetValue(value) + + @property + def flip_sensor_Y(self): + return self.cam.ReverseY.GetValue() + + @flip_sensor_Y.setter + def flip_sensor_Y(self, value): + self.cam.ReverseY.SetValue(value) + + @property + def pixel_format(self): + """ + Returns the current pixel format of the camera. + """ + current_format = self.cam.PixelFormat.GetCurrentEntry() + return current_format.GetSymbolic() + + @pixel_format.setter + def pixel_format(self, format_str): + """ + Sets the camera's pixel format. + + Parameters + ---------- + format_str : str + The desired pixel format as a string (e.g., "Mono8", "RGB8Packed"). + """ + node_pixel_format = self.cam.PixelFormat + if not PySpin.IsWritable(node_pixel_format): + logger.error("Pixel Format node is not writable") + raise FlirCameraException("Pixel Format node is not writable") + + new_format = node_pixel_format.GetEntryByName(format_str) + if new_format is None or not PySpin.IsReadable(new_format): + logger.error(f"Pixel format '{format_str}' is not supported") + raise FlirCameraException(f"Pixel format '{format_str}' is not supported") + + node_pixel_format.SetIntValue(new_format.GetValue()) + logger.info(f"Pixel format set to {format_str}") \ No newline at end of file From 312b72241bb147db64dbde919b59c0f83943c5f2 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Wed, 7 Feb 2024 11:26:58 -0800 Subject: [PATCH 30/60] adding demo auto-calibration for testing --- .../photom/demo/demo_photom_assembly.py | 45 ++++-- .../photom/demo/photom_calibration.py | 2 +- copylot/assemblies/photom/photom.py | 145 +++++++++++++----- .../photom/utils/scanning_algorithms.py | 65 +++++++- copylot/hardware/mirrors/abstract_mirror.py | 19 ++- 5 files changed, 211 insertions(+), 65 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_photom_assembly.py b/copylot/assemblies/photom/demo/demo_photom_assembly.py index 1cf4a9fc..527aae09 100644 --- a/copylot/assemblies/photom/demo/demo_photom_assembly.py +++ b/copylot/assemblies/photom/demo/demo_photom_assembly.py @@ -3,13 +3,20 @@ from copylot.assemblies.photom.utils import affine_transform from copylot.hardware.mirrors.optotune.mirror import OptoMirror from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror +from copylot.hardware.cameras.flir.flir_camera import FlirCamera import time # %% +config_file = './photom_VIS_config.yml' # Mock imports for the mirror and the lasers laser = MockLaser('Mock Laser', power=0) mirror = OptoMirror(com_port='COM8') + +camera_array = FlirCamera() +print(camera_array.list_available_cameras()) +camera = camera_array.open(index=0) + # %% # Test the moving of the mirrors mirror.position = (0.009, 0.0090) @@ -30,6 +37,7 @@ affine_matrix_path=[ r'C:\Users\ZebraPhysics\Documents\GitHub\coPylot\copylot\assemblies\photom\demo\test_tmp.yml' ], + camera=[camera], ) photom_device.set_position( mirror_index=0, position=[camera_sensor_width // 2, camera_sensor_height // 2] @@ -37,20 +45,33 @@ curr_pos = photom_device.get_position(mirror_index=0) print(curr_pos) +# %% +mirror_roi = [ + [0.004, 0.004], + [0.006, 0.006], +] # Top-left and Bottom-right corners of the mirror ROI +photom_device.camera[0] +photom_device.calibrate_w_camera( + mirror_index=0, + camera_index=0, + rectangle_boundaries=mirror_roi, + config_file='./affine_T.yml', + save_calib_stack_path='./calib_stack', +) # %% -# TODO: Test the calibration without GUI -import time +# # TODO: Test the calibration without GUI +# import time -start_time = time.time() -photom_device._calibrating = True -while time.time() - start_time < 5: - # Your code here - elapsed_time = time.time() - start_time - print(f'starttime: {start_time} elapsed_time: {elapsed_time}') - photom_device.calibrate( - mirror_index=0, rectangle_size_xy=[0.002, 0.002], center=[0.000, 0.000] - ) -photom_device._calibrating = False +# start_time = time.time() +# photom_device._calibrating = True +# while time.time() - start_time < 5: +# # Your code here +# elapsed_time = time.time() - start_time +# print(f'starttime: {start_time} elapsed_time: {elapsed_time}') +# photom_device.calibrate( +# mirror_index=0, rectangle_size_xy=[0.002, 0.002], center=[0.000, 0.000] +# ) +# photom_device._calibrating = False # %% diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 2b7e929e..20d80c58 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -380,7 +380,7 @@ def done_calibration(self): self.target_pts = self.photom_window.get_coordinates() # Mirror calibration size - mirror_calib_size = self.photom_assembly._calibration_rectangle_size_xy + mirror_calib_size = self.photom_assembly._calibration_rectangle_boundaries origin = np.array( [[pt.x(), pt.y()] for pt in self.target_pts], dtype=np.float32, diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index 915685fc..ada23e49 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -1,5 +1,3 @@ -from dataclasses import dataclass -from re import T from copylot.hardware.cameras.abstract_camera import AbstractCamera from copylot.hardware.mirrors.abstract_mirror import AbstractMirror from copylot.hardware.lasers.abstract_laser import AbstractLaser @@ -8,14 +6,19 @@ from copylot.assemblies.photom.utils.affine_transform import AffineTransform from copylot.assemblies.photom.utils.scanning_algorithms import ( calculate_rectangle_corners, + generate_grid_points, ) from pathlib import Path from copylot import logger from typing import Tuple import time from typing import Optional +import numpy as np +import tifffile +from copylot.assemblies.photom.utils import image_analysis as ia # TODO: add the logger from copylot +# TODO: add mirror's confidence ROI or update with calibration in OptotuneDocumentation class PhotomAssembly: @@ -32,52 +35,34 @@ def __init__( self.laser = laser # list of lasers self.mirror = mirror self.DAC = dac - + self.affine_matrix_path = affine_matrix_path self._calibrating = False + # TODO: These are hardcoded values. Unsure if they should come from a config file + self._calibration_rectangle_boundaries = None - # TODO: replace these hardcoded values to mirror's scan steps given the magnification - # and the mirrors angles - self._calibration_rectangle_size_xy = [0.004, 0.004] - - assert len(self.mirror) == len(affine_matrix_path) + def init_mirrors(self): + assert len(self.mirror) == len(self.affine_matrix_path) + self._calibration_rectangle_boundaries = np.zeros((len(self.mirror), 2, 2)) # Apply AffineTransform to each mirror - for i, tx_path in enumerate(affine_matrix_path): - self.mirror[i].affine_transform_obj = AffineTransform(config_file=tx_path) + for i in range(len(self.mirror)): + self.mirror[i].affine_transform_obj = AffineTransform( + config_file=self.affine_matrix_path[i] + ) + self._calibration_rectangle_boundaries[i] = [[None, None], [None, None]] - def calibrate( - self, mirror_index: int, rectangle_size_xy: tuple[int, int], center=[0.0, 0.0] - ): - if mirror_index < len(self.mirror): - print("Calibrating mirror...") - rectangle_coords = calculate_rectangle_corners(rectangle_size_xy, center) - # offset the rectangle coords by the center - # iterate over each corner and move the mirror - i = 0 - while self._calibrating: - # Logic for calibrating the mirror - self.set_position(mirror_index, rectangle_coords[i]) - time.sleep(1) - i += 1 - if i == 4: - i = 0 - time.sleep(1) - # moving the mirror in a rectangle - else: - raise IndexError("Mirror index out of range.") + # TODO probably will replace the camera with zyx or yx image array input + ## Camera Functions + def capture(self, camera_index: int, exposure_time: float) -> list: + pass + ## Mirror Functions def stop_mirror(self, mirror_index: int): if mirror_index < len(self.mirror): self._calibrating = False else: raise IndexError("Mirror index out of range.") - # TODO probably will replace the camera with zyx or yx image array input - ## Camera Functions - def capture(self): - pass - - ## Mirror Functions def get_position(self, mirror_index: int) -> list[float]: if mirror_index < len(self.mirror): if self.DAC is not None: @@ -108,6 +93,88 @@ def set_position(self, mirror_index: int, position: list[float]): else: raise IndexError("Mirror index out of range.") + def calibrate( + self, mirror_index: int, rectangle_size_xy: tuple[int, int], center=[0.0, 0.0] + ): + if mirror_index < len(self.mirror): + print("Calibrating mirror...") + rectangle_coords = calculate_rectangle_corners(rectangle_size_xy, center) + # offset the rectangle coords by the center + # iterate over each corner and move the mirror + i = 0 + while self._calibrating: + # Logic for calibrating the mirror + self.set_position(mirror_index, rectangle_coords[i]) + time.sleep(1) + i += 1 + if i == 4: + i = 0 + time.sleep(1) + # moving the mirror in a rectangle + else: + raise IndexError("Mirror index out of range.") + + def calibrate_w_camera( + self, + mirror_index: int, + camera_index: int, + rectangle_boundaries: Tuple[Tuple[int, int], Tuple[int, int]], + config_file: Path = './affine_matrix.yml', + save_calib_stack_path: Path = None, + ): + assert self.camera is not None + assert config_file.endswith('.yaml') + self._calibration_rectangle_boundaries[mirror_index] = rectangle_boundaries + + x_min, x_max, y_min, y_max = self.camera.image_size_limits + # assuming the minimum is always zero, which is typically that case + assert mirror_index < len(self.mirror) + assert camera_index < len(self.camera) + print("Calibrating mirror_idx <{mirror_idx}> with camera_idx <{camera_idx}>") + # TODO: replace these values with something from the config + # Generate grid of points + grid_points = generate_grid_points( + rectangle_size=rectangle_boundaries, n_points=5 + ) + # Acquire sequence of images with points + img_sequence = np.zeros((len(grid_points), x_max, y_max)) + for idx, coord in enumerate(grid_points): + self.set_position(mirror_index, coord) + img_sequence[idx] = self.camera[camera_index].snap() + + # Find the coordinates of peak per image + peak_coords = np.zeros((len(grid_points), 2)) + for idx, img in enumerate(img_sequence): + peak_coords[idx] = ia.find_objects_centroids( + img, sigma=5, threshold_rel=0.5, min_distance=10 + ) + + # Find the affine transform + T_affine = self.mirror[mirror_index].affine_transform_obj.get_affine_matrix( + peak_coords, grid_points + ) + print(f"Affine matrix: {T_affine}") + + # Save the matrix + config_file = Path(config_file) + if not config_file.exists(): + config_file.mkdir(parents=True, exist_ok=True) + + self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.save_matrix(matrix=T_affine, config_file=config_file) + + if save_calib_stack_path is not None: + save_calib_stack_path = Path(save_calib_stack_path) + save_calib_stack_path = Path(save_calib_stack_path) + if not save_calib_stack_path.exists(): + save_calib_stack_path.mkdir(parents=True, exist_ok=True) + timestamp = time.strftime("%Y%m%d_%H%M%S") + output_path_name = ( + save_calib_stack_path / f'calibration_images_{timestamp}.tif' + ) + tifffile.imwrite(output_path_name) + ## LASER Fuctions def get_laser_power(self, laser_index: int) -> float: power = self.laser[laser_index].power @@ -159,9 +226,3 @@ def convert_values( output_values.append(normalized_val * output_span + output_min) return output_values - - def get_sensor_size(self): - if self.camera is not None: - self.camera_sensor_size = self.camera[0].sensor_size - else: - raise NotImplementedError("No camera found.") diff --git a/copylot/assemblies/photom/utils/scanning_algorithms.py b/copylot/assemblies/photom/utils/scanning_algorithms.py index d4eb6a71..5609740e 100644 --- a/copylot/assemblies/photom/utils/scanning_algorithms.py +++ b/copylot/assemblies/photom/utils/scanning_algorithms.py @@ -1,12 +1,13 @@ import math - -""" -Borrowing from Hirofumi's photom-code - -""" +import numpy as np +from typing import ArrayLike class ScanAlgorithm: + """ + Borrowing from Hirofumi's photom-code + """ + def __init__(self, initial_cord, size, gap, shape, sec_per_cycle): """ Generates lists of x & y coordinates for various shapes with different scanning curves. @@ -196,7 +197,7 @@ def generate_rect(self): return cord_x, cord_y -def calculate_rectangle_corners(window_size: tuple[int, int],center=[0.0,0.0]): +def calculate_rectangle_corners(window_size: tuple[int, int], center=[0.0, 0.0]): # window_size is a tuple of (width, height) # Calculate the coordinates of the rectangle corners @@ -207,6 +208,54 @@ def calculate_rectangle_corners(window_size: tuple[int, int],center=[0.0,0.0]): x1y0 = [x0y0[0] + window_size[0], x0y0[1]] x1y1 = [x0y0[0] + window_size[0], x0y0[1] + window_size[1]] x0y1 = [x0y0[0], x0y0[1] + window_size[1]] - - + return [x0y0, x1y0, x1y1, x0y1] + + +def generate_grid_points( + rectangle_size: ArrayLike[int, int], n_points: int = 5 +) -> np.ndarray: + """ + Generate grid points for a given rectangle. + + Parameters: + - rectangle_size: ArrayLike, containing the start and end coordinates of the rectangle + as [[start_x, start_y], [end_x, end_y]]. + - n_points: The number of points per row and column in the grid. + + Returns: + - An array of coordinates for the grid points, evenly distributed across the rectangle. + + Example: + >>> rectangle_size = [[-1, -1], [1, 1]] + >>> n_points = 3 + >>> generate_grid_points(rectangle_size, n_points) + array([[-1. , -1. ], + [ 0. , -1. ], + [ 1. , -1. ], + [-1. , 0. ], + [ 0. , 0. ], + [ 1. , 0. ], + [-1. , 1. ], + [ 0. , 1. ], + [ 1. , 1. ]], dtype=float32) + """ + start_x, start_y = rectangle_size[0] + end_x, end_y = rectangle_size[1] + + # Calculate intervals between points in the grid + interval_x = (end_x - start_x) / (n_points - 1) + interval_y = (end_y - start_y) / (n_points - 1) + + # Initialize an array to store the coordinates of the grid points + grid_points = np.zeros((n_points * n_points, 2), dtype=np.float32) + + # Populate the array with the coordinates of the grid points + for i in range(n_points): + for j in range(n_points): + index = i * n_points + j + x = start_x + j * interval_x + y = start_y + i * interval_y + grid_points[index] = [y, x] + + return grid_points diff --git a/copylot/hardware/mirrors/abstract_mirror.py b/copylot/hardware/mirrors/abstract_mirror.py index 5163abb7..f93c12b8 100644 --- a/copylot/hardware/mirrors/abstract_mirror.py +++ b/copylot/hardware/mirrors/abstract_mirror.py @@ -1,5 +1,6 @@ from abc import ABCMeta, abstractmethod + class AbstractMirror(metaclass=ABCMeta): """AbstractMirror @@ -11,17 +12,31 @@ class AbstractMirror(metaclass=ABCMeta): """ def __init__(self): + self.name: str = "AbstractMirror" self.affine_transform_obj = None + self.pos_x: float = 0.0 + self.pos_y: float = 0.0 + + @property + def device_id(self): + "Returns the device unique id(name or serial number)of the current mirror" + return self.name + + @device_id.setter + @abstractmethod + def device_id(self, value: str): + "Sets the device unique id(name or serial number)of the current mirror" + pass @property @abstractmethod - def position(self) -> list[float,float]: + def position(self) -> list[float, float]: """Get the current mirror position XY""" pass @position.setter @abstractmethod - def position(self, value: list[float,float]): + def position(self, value: list[float, float]): """Set the mirror position XY""" pass From e403c529a47b659bfb19c4d4a3d8a0d6f01728ce Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Wed, 7 Feb 2024 11:59:54 -0800 Subject: [PATCH 31/60] addin functiongs for auto calibration --- .../photom/demo/demo_calibration_camera.py | 106 ++++++++++++++ .../photom/demo/test_photom_calibration.py | 130 ++++++++++++++++++ .../assemblies/photom/utils/image_analysis.py | 56 ++++++++ 3 files changed, 292 insertions(+) create mode 100644 copylot/assemblies/photom/demo/demo_calibration_camera.py create mode 100644 copylot/assemblies/photom/demo/test_photom_calibration.py create mode 100644 copylot/assemblies/photom/utils/image_analysis.py diff --git a/copylot/assemblies/photom/demo/demo_calibration_camera.py b/copylot/assemblies/photom/demo/demo_calibration_camera.py new file mode 100644 index 00000000..adc787f7 --- /dev/null +++ b/copylot/assemblies/photom/demo/demo_calibration_camera.py @@ -0,0 +1,106 @@ +# %% +import numpy as np +from scipy.ndimage import median_filter +from skimage.feature import peak_local_max +import napari +from skimage import measure, filters +from skimage.filters import gaussian +import os +from skimage.morphology import remove_small_objects + + +# %% +os.environ["DISPLAY"] = ":1002" +viewer = napari.Viewer() + +# %% + + +def find_objects_centroids( + image, sigma=5, threshold_rel=0.5, min_distance=10, min_area=3 +): + """ + Calculate the centroids of blurred objects in an image, excluding hot pixels or small artifacts. + + Parameters: + - image: 2D numpy array, single frame from the fluorescence microscopy data. + - sigma: Standard deviation for Gaussian filter to smooth the image. + - threshold_rel: Relative threshold for detecting peaks. Adjust according to image contrast. + - min_distance: The minimum distance between peaks detected. + - min_area: The minimum area of an object to be considered valid. + + Returns: + - centroids: (N, 2) array where each row is (row, column) of an object's centroid. + """ + # Smooth the image to enhance peak detection + smoothed_image = gaussian(image, sigma=sigma) + + # Thresholding to isolate objects + threshold_value = filters.threshold_otsu(smoothed_image) + binary_image = smoothed_image > threshold_value * threshold_rel + + # Remove small objects (hot pixels or small artifacts) from the binary image + cleaned_image = remove_small_objects(binary_image, min_size=min_area) + + # Label the cleaned image to identify distinct objects + label_image = measure.label(cleaned_image) + properties = measure.regionprops(label_image) + + # Calculate centroids of filtered objects + centroids = [prop.centroid for prop in properties if prop.area >= min_area] + + return np.array(centroids) + + +# %% +# Define the rectangle and grid parameters +n_points = 5 # Number of points per row/column in the grid +rect_start_x, rect_start_y = 20, 20 +rect_end_x, rect_end_y = 80, 80 +frame_width, frame_height = 100, 100 +n_frames = 25 # Total number of frames +px = 4 # Size of the point in the grid +# Calculate intervals between points in the grid +interval_x = (rect_end_x - rect_start_x) // (n_points - 1) +interval_y = (rect_end_y - rect_start_y) // (n_points - 1) + +# Initialize frames +frames = np.zeros((n_frames, frame_width, frame_height), dtype=np.float32) +# Store the coordinates of the grid points per frame +center_coords = np.zeros((n_frames, 2), dtype=np.float32) + +# Populate frames with the grid points +for i in range(n_points): + for j in range(n_points): + frame_index = i * n_points + j + x = rect_start_x + j * interval_x + y = rect_start_y + i * interval_y + center_coords[frame_index] = [y, x] + # center_coords[frame_index, i, j] = [x, y] + frames[ + frame_index, y - px // 2 : y + px // 2, x - px // 2 : x + px // 2 + ] = 100 # Set the point to white + +for i in range(frames.shape[0]): + frames[i] = gaussian(frames[i], sigma=4) + +viewer.add_image(frames) + +print(center_coords) +# %% +centroids = [] +for i in range(frames.shape[0]): + coords = find_objects_centroids( + frames[i], sigma=3, threshold_rel=0.5, min_distance=10 + ) + centroids.append([i, np.round(coords[0][0]), np.round(coords[0][1])]) + +centroids = np.array(centroids) +viewer.add_points(centroids, size=5, face_color="red") +print(centroids) + +# %% +# compare with the ground truth +assert np.allclose(centroids[:, 1:], center_coords, atol=1) + +# %% diff --git a/copylot/assemblies/photom/demo/test_photom_calibration.py b/copylot/assemblies/photom/demo/test_photom_calibration.py new file mode 100644 index 00000000..1fbe4dd2 --- /dev/null +++ b/copylot/assemblies/photom/demo/test_photom_calibration.py @@ -0,0 +1,130 @@ +import unittest +from unittest.mock import MagicMock, patch +from PyQt5.QtWidgets import QApplication, QMainWindow + +# Import the classes and functions you want to test +from copylot.assemblies.photom.demo.photom_calibration import ( + LaserWidget, + MirrorWidget, + PhotomApp, + LaserMarkerWindow, +) + + +class TestLaserWidget(unittest.TestCase): + def test_update_power(self): + # Create a mock laser object + laser = MagicMock() + laser.power = 50 + + # Create an instance of the LaserWidget with the mock laser + widget = LaserWidget(laser) + + # Call the update_power method with a new value + widget.update_power(75) + + # Check that the laser's power was updated + self.assertEqual(laser.set_power.call_args[0][0], 75) + + # Check that the power_label text was updated + self.assertEqual(widget.power_label.text(), "Power: 75") + + +class TestMirrorWidget(unittest.TestCase): + def test_update_mirror_x(self): + # Create a mock mirror object + mirror = MagicMock() + mirror.x = 50 + + # Create an instance of the MirrorWidget with the mock mirror + widget = MirrorWidget(mirror) + + # Call the update_mirror_x method with a new value + widget.update_mirror_x(75) + + # Check that the mirror's x position was updated + self.assertEqual(mirror.x, 75) + + # Check that the mirror_x_label text was updated + self.assertEqual(widget.mirror_x_label.text(), "X: 75") + + +class TestPhotomApp(unittest.TestCase): + def setUp(self): + # Create a mock PhotomAssembly object + self.photom_assembly = MagicMock() + + # Create a mock QMainWindow object + self.photom_window = MagicMock() + + # Create a mock demo_window object + self.demo_window = MagicMock() + + # Create an instance of the PhotomApp with the mock objects + self.app = PhotomApp( + self.photom_assembly, self.photom_window, demo_window=self.demo_window + ) + + def test_calibrate(self): + # Create a mock mirror object + mirror = MagicMock() + mirror.name = "Mirror 1" + + # Set the mirrors attribute of the PhotomApp to a list containing the mock mirror + self.app.mirrors = [mirror] + + # Set the currentText method of the mirror_dropdown to return the name of the mock mirror + self.app.mirror_dropdown.currentText = MagicMock(return_value="Mirror 1") + + # Call the calibrate method + self.app.calibrate() + + # Check that the calibrate method of the mock PhotomAssembly was called with the index of the mock mirror + self.assertEqual( + self.photom_assembly.calibrate.call_args[0][0], + self.app.mirrors.index(mirror), + ) + + def test_done_calibration(self): + # Create a mock mirror object + mirror = MagicMock() + mirror.name = "Mirror 1" + + # Set the mirrors attribute of the PhotomApp to a list containing the mock mirror + self.app.mirrors = [mirror] + + # Set the _calibrating_mirror_idx attribute of the PhotomApp to the index of the mock mirror + self.app._calibrating_mirror_idx = self.app.mirrors.index(mirror) + + # Call the done_calibration method + self.app.done_calibration() + + # Check that the save_matrix method of the mock AffineTransform object was called + self.assertTrue(mirror.affine_trans_obj.save_matrix.called) + + def test_update_transparency(self): + # Call the update_transparency method with a new value + self.app.update_transparency(50) + + # Check that the transparency_label text was updated + self.assertEqual(self.app.transparency_label.text(), "Transparency: 50%") + + +class TestLaserMarkerWindow(unittest.TestCase): + def test_init(self): + # Create an instance of the LaserMarkerWindow + window = LaserMarkerWindow() + + # Check that the window is an instance of QMainWindow + self.assertIsInstance(window, QMainWindow) + + +if __name__ == "__main__": + # Create a QApplication instance before running the tests + app = QApplication([]) + + # Run the tests + unittest.main() + + # Close the QApplication instance after running the tests + app.quit() diff --git a/copylot/assemblies/photom/utils/image_analysis.py b/copylot/assemblies/photom/utils/image_analysis.py new file mode 100644 index 00000000..6cb95087 --- /dev/null +++ b/copylot/assemblies/photom/utils/image_analysis.py @@ -0,0 +1,56 @@ +import numpy as np +from skimage.feature import peak_local_max +from skimage import filters +from skimage.filters import gaussian + + +def find_objects_centroids(image, sigma=5, threshold_rel=0.5, min_distance=10): + """ + Calculate the centroids of blurred objects in an image. + + Parameters: + - image: 2D numpy array, single frame from the fluorescence microscopy data. + - sigma: Standard deviation for Gaussian filter to smooth the image. + - threshold_rel: Relative threshold for detecting peaks. Adjust according to image contrast. + - min_distance: The minimum distance between peaks detected. + + Returns: + - centroids: (N, 2) array where each row is (row, column) of an object's centroid. + """ + # Smooth the image to enhance peak detection + smoothed_image = gaussian(image, sigma=sigma) + + # Thresholding to isolate objects + threshold_value = filters.threshold_otsu(smoothed_image) + binary_image = smoothed_image > threshold_value * threshold_rel + + # Detect peaks which represent object centroids + coordinates = peak_local_max( + smoothed_image, + min_distance=min_distance, + threshold_abs=threshold_value * threshold_rel, + ) + + return coordinates + + +def calculate_centroids(image_sequence, sigma=5, threshold_rel=0.5, min_distance=10): + """ + Calculate the centroids of objects in a sequence of images. + + Parameters: + - image_sequence: 3D numpy array, sequence of frames from the fluorescence microscopy data. + - sigma: Standard deviation for Gaussian filter to smooth the image. + - threshold_rel: Relative threshold for detecting peaks. Adjust according to image contrast. + - min_distance: The minimum distance between peaks detected. + + Returns: + - centroids: (N, 2) array where each row is (row, column) of an object's centroid. + """ + centroids = [] + for frame in image_sequence: + frame_centroids = find_objects_centroids( + frame, sigma=sigma, threshold_rel=threshold_rel, min_distance=min_distance + ) + centroids.append(frame_centroids) + return np.array(centroids) From 6fa5f478a46e68ef78f3db28a2bb03717fe10ed2 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Wed, 7 Feb 2024 14:12:14 -0800 Subject: [PATCH 32/60] adding minor fixes to bugs from the remote pesudocoding --- .../photom/demo/demo_photom_assembly.py | 49 +++++++++++++--- copylot/assemblies/photom/photom.py | 56 ++++++++++++------- .../photom/utils/scanning_algorithms.py | 6 +- copylot/hardware/cameras/flir/demo/demo.py | 5 +- copylot/hardware/mirrors/optotune/mirror.py | 13 +++++ 5 files changed, 95 insertions(+), 34 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_photom_assembly.py b/copylot/assemblies/photom/demo/demo_photom_assembly.py index 527aae09..4de241bb 100644 --- a/copylot/assemblies/photom/demo/demo_photom_assembly.py +++ b/copylot/assemblies/photom/demo/demo_photom_assembly.py @@ -9,14 +9,10 @@ # %% config_file = './photom_VIS_config.yml' # Mock imports for the mirror and the lasers -laser = MockLaser('Mock Laser', power=0) +laser = MockLaser(name='Mock Laser', power=0) mirror = OptoMirror(com_port='COM8') -camera_array = FlirCamera() -print(camera_array.list_available_cameras()) -camera = camera_array.open(index=0) - # %% # Test the moving of the mirrors mirror.position = (0.009, 0.0090) @@ -45,17 +41,52 @@ curr_pos = photom_device.get_position(mirror_index=0) print(curr_pos) +# %% +from copylot.assemblies.photom.utils.scanning_algorithms import ( + calculate_rectangle_corners, + generate_grid_points, +) +import time + +mirror_roi = [ + [-0.01, 0.00], + [0.019, 0.019], +] +grid_points = generate_grid_points(rectangle_size=mirror_roi, n_points=5) +for idx, coord in enumerate(grid_points): + mirror.position = [coord[1], coord[0]] + time.sleep(0.1) + + +# %% +cam = FlirCamera() +# open the system +cam.open() +# serial number +print(cam.device_id) +# list of cameras +print(cam.list_available_cameras()) +photom_device = PhotomAssembly( + laser=[laser], + mirror=[mirror], + affine_matrix_path=[r'./affine_T.yml'], + camera=[cam], +) # %% mirror_roi = [ - [0.004, 0.004], - [0.006, 0.006], + [-0.01, 0.00], + [0.019, 0.019], ] # Top-left and Bottom-right corners of the mirror ROI -photom_device.camera[0] +photom_device.camera[0].exposure = 5000 +photom_device.camera[0].gain = 0 +photom_device.camera[0].flip_horizontal = True +photom_device.camera[0].pixel_format = 'Mono16' photom_device.calibrate_w_camera( mirror_index=0, camera_index=0, rectangle_boundaries=mirror_roi, - config_file='./affine_T.yml', + grid_n_points=5, + config_file='./affine_T.yaml', save_calib_stack_path='./calib_stack', ) diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index ada23e49..be1e3727 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -1,3 +1,4 @@ +from re import T from copylot.hardware.cameras.abstract_camera import AbstractCamera from copylot.hardware.mirrors.abstract_mirror import AbstractMirror from copylot.hardware.lasers.abstract_laser import AbstractLaser @@ -16,6 +17,7 @@ import numpy as np import tifffile from copylot.assemblies.photom.utils import image_analysis as ia +from tqdm import tqdm # TODO: add the logger from copylot # TODO: add mirror's confidence ROI or update with calibration in OptotuneDocumentation @@ -39,6 +41,7 @@ def __init__( self._calibrating = False # TODO: These are hardcoded values. Unsure if they should come from a config file self._calibration_rectangle_boundaries = None + self.init_mirrors() def init_mirrors(self): assert len(self.mirror) == len(self.affine_matrix_path) @@ -49,7 +52,7 @@ def init_mirrors(self): self.mirror[i].affine_transform_obj = AffineTransform( config_file=self.affine_matrix_path[i] ) - self._calibration_rectangle_boundaries[i] = [[None, None], [None, None]] + # self._calibration_rectangle_boundaries[i] = [[, ], [, ]] # TODO probably will replace the camera with zyx or yx image array input ## Camera Functions @@ -119,14 +122,15 @@ def calibrate_w_camera( mirror_index: int, camera_index: int, rectangle_boundaries: Tuple[Tuple[int, int], Tuple[int, int]], + grid_n_points: int = 5, config_file: Path = './affine_matrix.yml', save_calib_stack_path: Path = None, ): assert self.camera is not None - assert config_file.endswith('.yaml') - self._calibration_rectangle_boundaries[mirror_index] = rectangle_boundaries + assert config_file.endswith('.yml') or config_file.endswith('.yaml') + # self._calibration_rectangle_boundaries[mirror_index] = rectangle_boundaries - x_min, x_max, y_min, y_max = self.camera.image_size_limits + x_min, x_max, y_min, y_max = self.camera[camera_index].image_size_limits # assuming the minimum is always zero, which is typically that case assert mirror_index < len(self.mirror) assert camera_index < len(self.camera) @@ -134,17 +138,40 @@ def calibrate_w_camera( # TODO: replace these values with something from the config # Generate grid of points grid_points = generate_grid_points( - rectangle_size=rectangle_boundaries, n_points=5 + rectangle_size=rectangle_boundaries, + n_points=grid_n_points, ) # Acquire sequence of images with points - img_sequence = np.zeros((len(grid_points), x_max, y_max)) - for idx, coord in enumerate(grid_points): - self.set_position(mirror_index, coord) + img_sequence = np.zeros((len(grid_points), y_max, x_max), dtype='uint16') + for idx, coord in tqdm( + enumerate(grid_points), + total=len(grid_points), + desc="Collecting grid points", + ): + self.mirror[mirror_index].position = [coord[1], coord[0]] img_sequence[idx] = self.camera[camera_index].snap() + if save_calib_stack_path is not None: + print('Saving calibration stack') + save_calib_stack_path = Path(save_calib_stack_path) + save_calib_stack_path = Path(save_calib_stack_path) + if not save_calib_stack_path.exists(): + save_calib_stack_path.mkdir(parents=True, exist_ok=True) + timestamp = time.strftime("%Y%m%d_%H%M%S") + output_path_name = ( + save_calib_stack_path / f'calibration_images_{timestamp}.tif' + ) + tifffile.imwrite( + output_path_name, img_sequence, dtype='uint16', imagej=True + ) + # Find the coordinates of peak per image peak_coords = np.zeros((len(grid_points), 2)) - for idx, img in enumerate(img_sequence): + for idx, img in tqdm( + enumerate(img_sequence), + total=len(img_sequence), + desc='Finding peak coordinates', + ): peak_coords[idx] = ia.find_objects_centroids( img, sigma=5, threshold_rel=0.5, min_distance=10 ) @@ -164,17 +191,6 @@ def calibrate_w_camera( self._current_mirror_idx ].affine_transform_obj.save_matrix(matrix=T_affine, config_file=config_file) - if save_calib_stack_path is not None: - save_calib_stack_path = Path(save_calib_stack_path) - save_calib_stack_path = Path(save_calib_stack_path) - if not save_calib_stack_path.exists(): - save_calib_stack_path.mkdir(parents=True, exist_ok=True) - timestamp = time.strftime("%Y%m%d_%H%M%S") - output_path_name = ( - save_calib_stack_path / f'calibration_images_{timestamp}.tif' - ) - tifffile.imwrite(output_path_name) - ## LASER Fuctions def get_laser_power(self, laser_index: int) -> float: power = self.laser[laser_index].power diff --git a/copylot/assemblies/photom/utils/scanning_algorithms.py b/copylot/assemblies/photom/utils/scanning_algorithms.py index 5609740e..dc9517fc 100644 --- a/copylot/assemblies/photom/utils/scanning_algorithms.py +++ b/copylot/assemblies/photom/utils/scanning_algorithms.py @@ -1,6 +1,6 @@ import math import numpy as np -from typing import ArrayLike +from numpy.typing import ArrayLike class ScanAlgorithm: @@ -212,9 +212,7 @@ def calculate_rectangle_corners(window_size: tuple[int, int], center=[0.0, 0.0]) return [x0y0, x1y0, x1y1, x0y1] -def generate_grid_points( - rectangle_size: ArrayLike[int, int], n_points: int = 5 -) -> np.ndarray: +def generate_grid_points(rectangle_size: ArrayLike, n_points: int = 5) -> np.ndarray: """ Generate grid points for a given rectangle. diff --git a/copylot/hardware/cameras/flir/demo/demo.py b/copylot/hardware/cameras/flir/demo/demo.py index 8d85b99c..76b577a4 100644 --- a/copylot/hardware/cameras/flir/demo/demo.py +++ b/copylot/hardware/cameras/flir/demo/demo.py @@ -1,5 +1,7 @@ +#%% from copylot.hardware.cameras.flir.flir_camera import FlirCamera +#%% if __name__ == '__main__': cam = FlirCamera() @@ -10,12 +12,13 @@ print(cam.device_id) # list of cameras print(cam.list_available_cameras()) - + #%% # Return 10 frames and save output arrays as .csv files (this can be changed) # Option 1: take multiple frames in a single acquisition # Can control timeout (wait_time) for grabbing images from the camera buffer snap1 = cam.snap(n_images=5, wait_time=1000) + #%% cam.save_image(snap1) # Option 2: iterate over snap() # Saving in each iteration causes a delay between beginning/ending camera acquisition diff --git a/copylot/hardware/mirrors/optotune/mirror.py b/copylot/hardware/mirrors/optotune/mirror.py index 1b25aafe..fcbba91e 100644 --- a/copylot/hardware/mirrors/optotune/mirror.py +++ b/copylot/hardware/mirrors/optotune/mirror.py @@ -6,10 +6,14 @@ For more details regarding operation, refer to the manuals in https://www.optotune.com/fast-steering-mirrors """ +from os import device_encoding +from zmq import device from copylot import logger from copylot.hardware.mirrors.optotune import optoMDC from copylot.hardware.mirrors.abstract_mirror import AbstractMirror +# TODO: switch device ID for the serial number + class OptoMirror(AbstractMirror): def __init__( @@ -50,6 +54,15 @@ def __del__(self): self.mirror.disconnect() logger.info("mirror disconnected") + @property + def device_id(self): + return self.name + + @device_id.setter + def device_id(self, value: str): + self.name = value + logger.info(f"device_id set to: {value}") + @property def position(self): """ From e8fabb5a01c0d40f11004a06d05e2b52f7c0541a Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Wed, 7 Feb 2024 15:52:35 -0800 Subject: [PATCH 33/60] fixed the autocalibration scanning pattern --- copylot/assemblies/photom/photom.py | 26 ++++++++------ .../assemblies/photom/utils/image_analysis.py | 34 +++++++++++++++++-- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index be1e3727..15a01e18 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -125,6 +125,7 @@ def calibrate_w_camera( grid_n_points: int = 5, config_file: Path = './affine_matrix.yml', save_calib_stack_path: Path = None, + verbose: bool = False, ): assert self.camera is not None assert config_file.endswith('.yml') or config_file.endswith('.yaml') @@ -151,6 +152,17 @@ def calibrate_w_camera( self.mirror[mirror_index].position = [coord[1], coord[0]] img_sequence[idx] = self.camera[camera_index].snap() + # Find the coordinates of peak per image + peak_coords = np.zeros((len(grid_points), 2)) + for idx, img in tqdm( + enumerate(img_sequence), + total=len(img_sequence), + desc='Finding peak coordinates', + ): + peak_coords[idx] = ia.find_objects_centroids( + img, sigma=5, threshold_rel=1.0, min_distance=30, max_num_peaks=1 + ) + if save_calib_stack_path is not None: print('Saving calibration stack') save_calib_stack_path = Path(save_calib_stack_path) @@ -164,17 +176,11 @@ def calibrate_w_camera( tifffile.imwrite( output_path_name, img_sequence, dtype='uint16', imagej=True ) + print("Saving coordinate and image stack plot") - # Find the coordinates of peak per image - peak_coords = np.zeros((len(grid_points), 2)) - for idx, img in tqdm( - enumerate(img_sequence), - total=len(img_sequence), - desc='Finding peak coordinates', - ): - peak_coords[idx] = ia.find_objects_centroids( - img, sigma=5, threshold_rel=0.5, min_distance=10 - ) + if verbose: + # Plot the centroids with the MIP of the image sequence + ia.plot_centroids(img_sequence, peak_coords, mip=True) # Find the affine transform T_affine = self.mirror[mirror_index].affine_transform_obj.get_affine_matrix( diff --git a/copylot/assemblies/photom/utils/image_analysis.py b/copylot/assemblies/photom/utils/image_analysis.py index 6cb95087..b977974e 100644 --- a/copylot/assemblies/photom/utils/image_analysis.py +++ b/copylot/assemblies/photom/utils/image_analysis.py @@ -4,7 +4,9 @@ from skimage.filters import gaussian -def find_objects_centroids(image, sigma=5, threshold_rel=0.5, min_distance=10): +def find_objects_centroids( + image, sigma=5, threshold_rel=0.5, min_distance=10, max_num_peaks=1 +): """ Calculate the centroids of blurred objects in an image. @@ -22,18 +24,46 @@ def find_objects_centroids(image, sigma=5, threshold_rel=0.5, min_distance=10): # Thresholding to isolate objects threshold_value = filters.threshold_otsu(smoothed_image) - binary_image = smoothed_image > threshold_value * threshold_rel + # binary_image = smoothed_image > threshold_value * threshold_rel # Detect peaks which represent object centroids coordinates = peak_local_max( smoothed_image, min_distance=min_distance, threshold_abs=threshold_value * threshold_rel, + num_peaks=max_num_peaks, + exclude_border=True, ) return coordinates +def plot_centroids(image_sequence, centroids, mip=True): + """ + Plot the centroids of objects on top of the image sequence. + + Parameters: + - image_sequence: 3D numpy array, sequence of frames from the fluorescence microscopy data. + - centroids: (N, 2) array where each row is (row, column) of an object's centroid. + """ + import matplotlib.pyplot as plt + + if mip: + plt.figure() + plt.imshow(np.max(image_sequence, axis=0), cmap="gray") + for i, centroid in enumerate(centroids): + plt.scatter(centroid[1], centroid[0], color="red") + plt.text(centroid[1], centroid[0], str(i + 1), color="white", fontsize=8) + plt.show() + + else: + for frame, frame_centroids in zip(image_sequence, centroids): + plt.figure() + plt.imshow(frame, cmap="gray") + plt.scatter(frame_centroids[1], frame_centroids[0], color="red") + plt.show() + + def calculate_centroids(image_sequence, sigma=5, threshold_rel=0.5, min_distance=10): """ Calculate the centroids of objects in a sequence of images. From 2ffb7aac9f3faa7abdb943f53817c01e714a5e8a Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Wed, 7 Feb 2024 17:23:27 -0800 Subject: [PATCH 34/60] adding arduino PWM --- .../photom/demo/demo_arduino_pwm.py | 28 +++++++++++++ copylot/assemblies/photom/utils/arduino.py | 40 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 copylot/assemblies/photom/demo/demo_arduino_pwm.py create mode 100644 copylot/assemblies/photom/utils/arduino.py diff --git a/copylot/assemblies/photom/demo/demo_arduino_pwm.py b/copylot/assemblies/photom/demo/demo_arduino_pwm.py new file mode 100644 index 00000000..ea16361e --- /dev/null +++ b/copylot/assemblies/photom/demo/demo_arduino_pwm.py @@ -0,0 +1,28 @@ +# %% +from copylot.assemblies.photom.utilis.arduino_pwm import ArduinoPWM + +# TODO: modify COM port based on the system +arduino = ArduinoPWM(serial_port='COM3', baud_rate=115200) + +# %% +# Test the PWM signal +duty_cycle = 50 # [%] (0-100) +milliseconds_on = 10 # [ms] +frequency = 1 / (milliseconds_on * 1000) # [Hz] +total_duration = 5000 # [ms] total time to run the PWM signal + +command = f'U,{duty_cycle},{frequency},{total_duration}' +arduino.send_command(command) +arduino.send_command('S') + + +# %% +# Run it every 5 seconds +import time + +repetitions = 10 +time_interval_s = 5 + +for i in range(repetitions): + arduino.send_command('S') + time.sleep(time_interval_s) diff --git a/copylot/assemblies/photom/utils/arduino.py b/copylot/assemblies/photom/utils/arduino.py new file mode 100644 index 00000000..04d70cd7 --- /dev/null +++ b/copylot/assemblies/photom/utils/arduino.py @@ -0,0 +1,40 @@ +import serial +import time + +""" +quick and dirty class to control the Arduino. + +The arduino currently parses the following commands: +- U,duty_cycle,frequency,duration: Update the PWM settings +- S: Start the PWM +""" + + +class ArduinoPWM: + def __init__(self, serial_port, baud_rate): + self.serial_port = serial_port + self.baud_rate = baud_rate + self.ser = None + self.connect() + + def __del__(self): + self.close() + + def connect(self): + try: + self.ser = serial.Serial(self.serial_port, self.baud_rate) + time.sleep(2) # Wait for connection to establish + except serial.SerialException as e: + print(f"Error: {e}") + + def close(self): + if self.ser: + self.ser.close() + print("Serial connection closed.") + + def send_command(self, command): + print(f"Sending command: {command}") + self.ser.write((command + '\n').encode()) + self.time.sleep(2) # Wait for Arduino to process the command + while self.ser.in_waiting: + print(self.ser.readline().decode().strip()) From 042b139b0866e60b14086ff894b872b66f1cab92 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Wed, 7 Feb 2024 17:35:19 -0800 Subject: [PATCH 35/60] adding ability to set the laser power --- .../photom/demo/demo_photom_calibration.py | 74 +++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_photom_calibration.py b/copylot/assemblies/photom/demo/demo_photom_calibration.py index 9d628d41..3d7b9bee 100644 --- a/copylot/assemblies/photom/demo/demo_photom_calibration.py +++ b/copylot/assemblies/photom/demo/demo_photom_calibration.py @@ -18,6 +18,7 @@ QStackedWidget, QComboBox, QFileDialog, + QLineEdit, ) from PyQt5.QtGui import QColor, QPen from copylot.assemblies.photom.utils.scanning_algorithms import ( @@ -61,8 +62,11 @@ def initialize_UI(self): layout.addWidget(self.laser_power_slider) # Add a QLabel to display the power value - self.power_label = QLabel(f"Power: {self.laser.laser_power}") - layout.addWidget(self.power_label) + self.power_edit = QLineEdit(f"{self.laser.laser_power}") # Changed to QLineEdit + self.power_edit.returnPressed.connect( + self.edit_power + ) # Connect the returnPressed signal + layout.addWidget(self.power_edit) self.laser_toggle_button = QPushButton("Toggle") self.laser_toggle_button.clicked.connect(self.toggle_laser) @@ -83,8 +87,26 @@ def toggle_laser(self): def update_power(self, value): self.laser.laser_power = value - # Update the QLabel with the new power value - self.power_label.setText(f"Power: {value}") + # Update the QLineEdit with the new power value + self.power_edit.setText(f"{value:.2f}") # Use string formatting for float + + def edit_power(self): + try: + # Convert the text value to float + value = float(self.power_edit.text()) + if 0 <= value <= 100: # Assuming the power range is 0 to 100 + self.laser.laser_power = value + self.laser_power_slider.setValue( + int(value) + ) # Synchronize the slider position, may need adjustment for float handling + self.power_edit.setText(f"{value:.2f}") + else: + self.power_edit.setText( + f"{self.laser.laser_power:.2f}" + ) # Reset to the last valid value if out of bounds + except ValueError: + # If conversion fails, reset QLineEdit to the last valid value + self.power_edit.setText(f"{self.laser.laser_power:.2f}") class QDoubleSlider(QSlider): @@ -105,6 +127,48 @@ def setMaximum(self, val): super().setMaximum(int(val * self._multiplier)) +class ArduinoPWMWidget(QWidget): + def __init__(self, arduino_pwm): + super().__init__() + self.arduino_pwm = arduino_pwm + self.initialize_UI() + + def initialize_UI(self): + layout = QVBoxLayout() + + self.duty_cycle_slider = QDoubleSlider(Qt.Horizontal) + self.duty_cycle_slider.setMinimum(0) + self.duty_cycle_slider.setMaximum(100) + self.duty_cycle_slider.setValue(50) + layout.addWidget(self.duty_cycle_slider) + + # Add a QLabel to display the duty cycle value + self.duty_cycle_label = QLabel(f"Duty Cycle: {self.arduino_pwm.duty_cycle}") + layout.addWidget(self.duty_cycle_label) + + self.frequency_slider = QDoubleSlider(Qt.Horizontal) + self.frequency_slider.setMinimum(0) + self.frequency_slider.setMaximum(100) + self.frequency_slider.setValue(50) + layout.addWidget(self.frequency_slider) + + # Add a QLabel to display the frequency value + self.frequency_label = QLabel(f"Frequency: {self.arduino_pwm.frequency}") + layout.addWidget(self.frequency_label) + + self.duration_slider = QSlider(Qt.Horizontal) + self.duration_slider.setMinimum(0) + self.duration_slider.setMaximum(100) + self.duration_slider.setValue(50) + layout.addWidget(self.duration_slider) + + # Add a QLabel to display the duration value + self.duration_label = QLabel(f"Duration: {self.arduino_pwm.duration}") + layout.addWidget(self.duration_label) + + self.setLayout(layout) + + # TODO: connect widget to actual abstract mirror calls class MirrorWidget(QWidget): def __init__(self, mirror): @@ -528,7 +592,7 @@ def mouseReleaseEvent(self, event): from copylot.hardware.mirrors.optotune.mirror import OptoMirror as Mirror try: - os.environ["DISPLAY"] = ":1003" + os.environ["DISPLAY"] = ":1002" except: raise Exception("DISPLAY environment variable not set") From ac47b83fa5fe6950c178b111086f48c9f16feaac Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Wed, 7 Feb 2024 18:08:10 -0800 Subject: [PATCH 36/60] add arduino the GUI --- .../photom/demo/demo_photom_calibration.py | 126 ++++++++++++++---- .../assemblies/photom/photom_mock_devices.py | 28 ++++ 2 files changed, 131 insertions(+), 23 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_photom_calibration.py b/copylot/assemblies/photom/demo/demo_photom_calibration.py index 3d7b9bee..a891bf51 100644 --- a/copylot/assemblies/photom/demo/demo_photom_calibration.py +++ b/copylot/assemblies/photom/demo/demo_photom_calibration.py @@ -19,6 +19,7 @@ QComboBox, QFileDialog, QLineEdit, + QGridLayout, ) from PyQt5.QtGui import QColor, QPen from copylot.assemblies.photom.utils.scanning_algorithms import ( @@ -131,43 +132,99 @@ class ArduinoPWMWidget(QWidget): def __init__(self, arduino_pwm): super().__init__() self.arduino_pwm = arduino_pwm + + # default values + self.duty_cycle = 50 # [%] (0-100) + self.frequency = 1000 # [Hz] + self.duration = 5000 # [ms] + self.initialize_UI() def initialize_UI(self): - layout = QVBoxLayout() + layout = QGridLayout() # Use QGridLayout + # Duty Cycle + layout.addWidget(QLabel("Duty Cycle [%]:"), 0, 0) # Label for duty cycle self.duty_cycle_slider = QDoubleSlider(Qt.Horizontal) self.duty_cycle_slider.setMinimum(0) self.duty_cycle_slider.setMaximum(100) - self.duty_cycle_slider.setValue(50) - layout.addWidget(self.duty_cycle_slider) - - # Add a QLabel to display the duty cycle value - self.duty_cycle_label = QLabel(f"Duty Cycle: {self.arduino_pwm.duty_cycle}") - layout.addWidget(self.duty_cycle_label) - + self.duty_cycle_slider.setValue(self.duty_cycle) + self.duty_cycle_slider.valueChanged.connect(self.update_duty_cycle) + layout.addWidget(self.duty_cycle_slider, 0, 1) + self.duty_cycle_edit = QLineEdit(f"{self.duty_cycle}") + self.duty_cycle_edit.returnPressed.connect(self.edit_duty_cycle) + layout.addWidget(self.duty_cycle_edit, 0, 2) + + # Frequency + layout.addWidget(QLabel("Frequency [Hz]:"), 1, 0) # Label for frequency self.frequency_slider = QDoubleSlider(Qt.Horizontal) self.frequency_slider.setMinimum(0) self.frequency_slider.setMaximum(100) - self.frequency_slider.setValue(50) - layout.addWidget(self.frequency_slider) - - # Add a QLabel to display the frequency value - self.frequency_label = QLabel(f"Frequency: {self.arduino_pwm.frequency}") - layout.addWidget(self.frequency_label) - - self.duration_slider = QSlider(Qt.Horizontal) + self.frequency_slider.setValue(self.frequency) + self.frequency_slider.valueChanged.connect(self.update_frequency) + layout.addWidget(self.frequency_slider, 1, 1) + self.frequency_edit = QLineEdit(f"{self.frequency}") + self.frequency_edit.returnPressed.connect(self.edit_frequency) + layout.addWidget(self.frequency_edit, 1, 2) + + # Duration + layout.addWidget(QLabel("Duration [ms]:"), 2, 0) # Label for duration + self.duration_slider = QDoubleSlider(Qt.Horizontal) self.duration_slider.setMinimum(0) self.duration_slider.setMaximum(100) - self.duration_slider.setValue(50) - layout.addWidget(self.duration_slider) - - # Add a QLabel to display the duration value - self.duration_label = QLabel(f"Duration: {self.arduino_pwm.duration}") - layout.addWidget(self.duration_label) + self.duration_slider.setValue(self.duration) + self.duration_slider.valueChanged.connect(self.update_duration) + layout.addWidget(self.duration_slider, 2, 1) + self.duration_edit = QLineEdit(f"{self.duration}") + self.duration_edit.returnPressed.connect(self.edit_duration) + layout.addWidget(self.duration_edit, 2, 2) + + # Add Start Button + self.start_button = QPushButton("Start PWM") + self.start_button.clicked.connect( + self.start_pwm + ) # Assuming start_pwm is a method you've defined + layout.addWidget(self.start_button, 0, 3, 1, 2) # Span 1 row and 2 columns self.setLayout(layout) + def update_duty_cycle(self, value): + self.duty_cycle = value + self.duty_cycle_edit.setText(f"{value:.2f}") + self.update_command() + + def edit_duty_cycle(self): + value = float(self.duty_cycle_edit.text()) + self.duty_cycle = value + self.duty_cycle_slider.setValue(value) + + def update_frequency(self, value): + self.frequency = value + self.frequency_edit.setText(f"{value:.2f}") + self.update_command() + + def edit_frequency(self): + value = float(self.frequency_edit.text()) + self.frequency = value + self.frequency_slider.setValue(value) + + def update_duration(self, value): + self.duration = value + self.duration_edit.setText(f"{value:.2f}") + self.update_command() + + def edit_duration(self): + value = float(self.duration_edit.text()) + self.duration = value + self.duration_slider.setValue(value) + + def update_command(self): + self.command = f"U,{self.duty_cycle},{self.frequency},{self.duration}" + self.arduino_pwm.send_command(self.command) + + def start_pwm(self): + self.arduino_pwm.send_command("S") + # TODO: connect widget to actual abstract mirror calls class MirrorWidget(QWidget): @@ -224,6 +281,7 @@ def __init__( photom_assembly: PhotomAssembly, photom_window_size: Tuple[int, int] = (100, 100), demo_window=None, + arduino=[], ): super().__init__() @@ -236,6 +294,9 @@ def __init__( self.photom_window_size = photom_window_size self._current_mirror_idx = 0 + # TODO: this probably will probably get removed along with any arduino pwm functionalities + self.arduino_pwm = arduino + if DEMO_MODE: self.demo_window = demo_window @@ -300,11 +361,22 @@ def initialize_UI(self): mirror_layout.addWidget(mirror_widget) mirror_group.setLayout(mirror_layout) + # Adding group for arduino PWM + arduino_group = QGroupBox("Arduino PWM") + arduino_layout = QVBoxLayout() + self.arduino_pwm_widgets = [] + for arduino in self.arduino_pwm: + arduino_pwm_widget = ArduinoPWMWidget(arduino) + self.arduino_pwm_widgets.append(arduino_pwm_widget) + arduino_layout.addWidget(arduino_pwm_widget) + arduino_group.setLayout(arduino_layout) + # Add the laser and mirror group boxes to the main layout main_layout = QVBoxLayout() main_layout.addWidget(transparency_group) main_layout.addWidget(laser_group) main_layout.addWidget(mirror_group) + main_layout.addWidget(arduino_group) self.mirror_dropdown = QComboBox() self.mirror_dropdown.addItems([mirror.name for mirror in self.mirrors]) @@ -581,15 +653,21 @@ def mouseReleaseEvent(self, event): import os if DEMO_MODE: - from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror + from copylot.assemblies.photom.photom_mock_devices import ( + MockLaser, + MockMirror, + MockArduinoPWM, + ) Laser = MockLaser Mirror = MockMirror + ArduinoPWM = MockArduinoPWM else: # NOTE: These are the actual classes that will be used in the photom assembly from copylot.hardware.lasers.vortran import VortranLaser as Laser from copylot.hardware.mirrors.optotune.mirror import OptoMirror as Mirror + from copylot.assemblies.photom.utils.arduino_pwm import ArduinoPWM try: os.environ["DISPLAY"] = ":1002" @@ -617,6 +695,7 @@ def mouseReleaseEvent(self, event): affine_matrix_paths = [ mirror['affine_matrix_path'] for mirror in config['mirrors'] ] + arduino = [ArduinoPWM(serial_port='COM3', baud_rate=115200)] # Check that the number of mirrors and affine matrices match assert len(mirrors) == len(affine_matrix_paths) @@ -643,6 +722,7 @@ def mouseReleaseEvent(self, event): photom_assembly=photom_assembly, photom_window_size=(ctrl_window_width, ctrl_window_width), demo_window=camera_window, + arduino=arduino, ) ctrl_window.setGeometry(0, 0, ctrl_window_width, ctrl_window_width) diff --git a/copylot/assemblies/photom/photom_mock_devices.py b/copylot/assemblies/photom/photom_mock_devices.py index 32ef0f89..b9ea9329 100644 --- a/copylot/assemblies/photom/photom_mock_devices.py +++ b/copylot/assemblies/photom/photom_mock_devices.py @@ -93,3 +93,31 @@ def movement_limits(self) -> list[float, float, float, float]: def movement_limits(self, value: list[float, float, float, float]): """Set the mirror movement limits""" self._movement_limits = value + + +class MockArduinoPWM: + def __init__(self, serial_port, baud_rate): + self.serial_port = serial_port + self.baud_rate = baud_rate + print( + f"MockArduinoPWM initialized with serial port {serial_port} and baud rate {baud_rate}" + ) + + def __del__(self): + self.close() + + def connect(self): + print("MockArduinoPWM: Simulating serial connection...") + + def close(self): + print("MockArduinoPWM: Simulating closing serial connection...") + + def send_command(self, command): + print(f"MockArduinoPWM: Simulating sending command: {command}") + # Simulate some processing time + import time + + time.sleep(0.5) + # Simulate a response from the Arduino if needed + response = "OK" # or simulate different responses based on the command + print(f"MockArduinoPWM: Received response: {response}") From 2203c24d840f82b3cea67004c18484a227f69ba9 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Wed, 7 Feb 2024 18:23:12 -0800 Subject: [PATCH 37/60] adding code to run the PWM without the CLI --- .../photom/demo/demo_arduino_pwm.py | 61 ++++++++++++++----- copylot/assemblies/photom/utils/arduino.py | 15 +++++ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_arduino_pwm.py b/copylot/assemblies/photom/demo/demo_arduino_pwm.py index ea16361e..74304286 100644 --- a/copylot/assemblies/photom/demo/demo_arduino_pwm.py +++ b/copylot/assemblies/photom/demo/demo_arduino_pwm.py @@ -1,28 +1,59 @@ # %% +from copylot.assemblies.photom.photom import PhotomAssembly +from copylot.assemblies.photom.utils import affine_transform +from copylot.hardware.mirrors.optotune.mirror import OptoMirror +from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror +from copylot.hardware.cameras.flir.flir_camera import FlirCamera +import time from copylot.assemblies.photom.utilis.arduino_pwm import ArduinoPWM +# %% +config_file = './photom_VIS_config.yml' +# Mock imports for the mirror and the lasers +laser = MockLaser('Mock Laser', power=0) +mirror = OptoMirror(com_port='COM8') +cam = FlirCamera() +cam.open() + # TODO: modify COM port based on the system arduino = ArduinoPWM(serial_port='COM3', baud_rate=115200) # %% +# Make the photom device +photom_device = PhotomAssembly( + laser=[laser], + mirror=[mirror], + affine_matrix_path=[r'./affine_T.yml'], + camera=[cam], +) + +# %% +# Perform calibration +mirror_roi = [ + [-0.01, 0.00], + [0.019, 0.019], +] # Top-left and Bottom-right corners of the mirror ROI +photom_device.camera[0].exposure = 5000 # [us] +photom_device.camera[0].gain = 0 +# photom_device.camera[0].flip_horizontal = True +# photom_device.camera[0].pixel_format = 'Mono16' +photom_device.calibrate_w_camera( + mirror_index=0, + camera_index=0, + rectangle_boundaries=mirror_roi, + grid_n_points=5, + config_file='./affine_T.yaml', + save_calib_stack_path='./calib_stack', +) +# %% +# Set the ablation parameters # Test the PWM signal duty_cycle = 50 # [%] (0-100) milliseconds_on = 10 # [ms] -frequency = 1 / (milliseconds_on * 1000) # [Hz] total_duration = 5000 # [ms] total time to run the PWM signal -command = f'U,{duty_cycle},{frequency},{total_duration}' -arduino.send_command(command) -arduino.send_command('S') - - # %% -# Run it every 5 seconds -import time - -repetitions = 10 -time_interval_s = 5 - -for i in range(repetitions): - arduino.send_command('S') - time.sleep(time_interval_s) +# Ablate the cells +frequency = 1 / (milliseconds_on * 1000) # [Hz] +arduino.set_pwm(duty_cycle, frequency, total_duration) +arduino.start_timelapse(repetitions=3, time_interval_s=5) diff --git a/copylot/assemblies/photom/utils/arduino.py b/copylot/assemblies/photom/utils/arduino.py index 04d70cd7..3d01c449 100644 --- a/copylot/assemblies/photom/utils/arduino.py +++ b/copylot/assemblies/photom/utils/arduino.py @@ -1,5 +1,6 @@ import serial import time +from tqdm import tqdm """ quick and dirty class to control the Arduino. @@ -38,3 +39,17 @@ def send_command(self, command): self.time.sleep(2) # Wait for Arduino to process the command while self.ser.in_waiting: print(self.ser.readline().decode().strip()) + + def set_pwm(self, duty_cycle, frequency, duration): + command = f'U,{duty_cycle},{frequency},{duration}' + self.send_command(command) + + def start(self): + self.send_command('S') + + def start_timelapse(self, repetitions=1, time_interval_s=5): + for i in tqdm( + range(repetitions), desc='Timelapse', unit='repetition', total=repetitions + ): + self.start() + time.sleep(time_interval_s) From 3f20e74e16d17535d0d56c0cc1e97346e91de3fa Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Thu, 8 Feb 2024 10:42:00 -0800 Subject: [PATCH 38/60] adding the arduino code for versioning --- .../sketch_PWM_vortran/sketch_PWM_vortran.ino | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 copylot/assemblies/photom/utils/arduino/sketch_PWM_vortran/sketch_PWM_vortran.ino diff --git a/copylot/assemblies/photom/utils/arduino/sketch_PWM_vortran/sketch_PWM_vortran.ino b/copylot/assemblies/photom/utils/arduino/sketch_PWM_vortran/sketch_PWM_vortran.ino new file mode 100644 index 00000000..289a694a --- /dev/null +++ b/copylot/assemblies/photom/utils/arduino/sketch_PWM_vortran/sketch_PWM_vortran.ino @@ -0,0 +1,73 @@ +#define PWM_PIN 9 + +// Variables for PWM parameters +unsigned int dutyCycle = 0; +unsigned long duration = 0; +unsigned long pwmFrequency = 0; + +void setup() { + Serial.begin(115200); + pinMode(PWM_PIN, OUTPUT); + Serial.println("Send 'U,duty,frequency,duration' to set PWM. Send 'S' to start PWM."); +} + +void loop() { + if (Serial.available() > 0) { + String command = Serial.readStringUntil('\n'); + + if (command.startsWith("U,")) { + // Update PWM settings + int firstComma = command.indexOf(','); + int secondComma = command.indexOf(',', firstComma + 1); + int thirdComma = command.indexOf(',', secondComma + 1); + + dutyCycle = command.substring(firstComma + 1, secondComma).toInt(); + pwmFrequency = command.substring(secondComma + 1, thirdComma).toInt(); + duration = command.substring(thirdComma + 1).toInt(); + + Serial.println("Duty Cycle"); + Serial.println(dutyCycle); + Serial.println("PWM Freq"); + Serial.println(pwmFrequency); + Serial.println("Duration"); + Serial.println(duration); + setPWMFrequency(pwmFrequency); + + Serial.println("PWM updated. Send 'S' to start."); + } + else if (command.startsWith("S")) { + // Start PWM with the set parameters + int pwmValue = map(dutyCycle, 0, 100, 0, 255); + analogWrite(PWM_PIN, pwmValue); + + // Wait for the specified duration to stop PWM + delay(duration); + analogWrite(PWM_PIN, 0); + + // Notify completion + Serial.println("PWM cycle complete. Update settings or start again."); + } + } +} + +void setPWMFrequency(unsigned long frequency) { + // Stop the timer + TCCR1B &= 0b11111000; + + // Calculate and set the appropriate prescaler for the desired frequency + if (frequency < 500) { + TCCR1B |= 0b101; // Prescaler set to 1024 + } else if (frequency < 1000) { + TCCR1B |= 0b100; // Prescaler set to 256 + } else if (frequency < 4000) { + TCCR1B |= 0b011; // Prescaler set to 64 + } else if (frequency < 12000) { + TCCR1B |= 0b010; // Prescaler set to 8 + } else { + TCCR1B |= 0b001; // No prescaling + } + + // Adjust the timer count based on desired frequency + unsigned long timerCount = 16000000 / (2 * frequency) - 1; + ICR1 = timerCount; +} From 34e986ffc9cee16902eee168934d2afa2cc35284 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Thu, 8 Feb 2024 14:25:32 -0800 Subject: [PATCH 39/60] fixing the bug that was flipping between (xy) to (yx) order dimensions --- .../photom/demo/demo_arduino_pwm.py | 71 +++++++++++++++---- .../photom/demo/demo_photom_assembly.py | 18 ++--- copylot/assemblies/photom/photom.py | 36 +++++++--- copylot/assemblies/photom/utils/arduino.py | 2 +- .../assemblies/photom/utils/image_analysis.py | 5 +- .../photom/utils/scanning_algorithms.py | 4 +- 6 files changed, 99 insertions(+), 37 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_arduino_pwm.py b/copylot/assemblies/photom/demo/demo_arduino_pwm.py index 74304286..8c1e6499 100644 --- a/copylot/assemblies/photom/demo/demo_arduino_pwm.py +++ b/copylot/assemblies/photom/demo/demo_arduino_pwm.py @@ -2,21 +2,23 @@ from copylot.assemblies.photom.photom import PhotomAssembly from copylot.assemblies.photom.utils import affine_transform from copylot.hardware.mirrors.optotune.mirror import OptoMirror -from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror + +# from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror +from copylot.hardware.lasers.vortran.vortran import VortranLaser from copylot.hardware.cameras.flir.flir_camera import FlirCamera import time -from copylot.assemblies.photom.utilis.arduino_pwm import ArduinoPWM +from copylot.assemblies.photom.utils.arduino import ArduinoPWM # %% config_file = './photom_VIS_config.yml' # Mock imports for the mirror and the lasers -laser = MockLaser('Mock Laser', power=0) +laser = VortranLaser('Mock Laser', port='COM9') mirror = OptoMirror(com_port='COM8') cam = FlirCamera() cam.open() # TODO: modify COM port based on the system -arduino = ArduinoPWM(serial_port='COM3', baud_rate=115200) +arduino = ArduinoPWM(serial_port='COM10', baud_rate=115200) # %% # Make the photom device @@ -29,31 +31,70 @@ # %% # Perform calibration +# Top-left and Bottom-right corners of the mirror ROI mirror_roi = [ - [-0.01, 0.00], - [0.019, 0.019], -] # Top-left and Bottom-right corners of the mirror ROI -photom_device.camera[0].exposure = 5000 # [us] + [ + 0.018, + -0.005, + ], # [x,y] + [ + 0.0, + 0.013, + ], +] # [x,y] + +photom_device.camera[0].exposure = 10000 # [us] photom_device.camera[0].gain = 0 -# photom_device.camera[0].flip_horizontal = True # photom_device.camera[0].pixel_format = 'Mono16' + +config_file = r'./test_auto_affineT.yml' + +photom_device.laser[0].power = 5 # [mW] +photom_device.laser[0].pulse_mode = 0 +photom_device.laser[0].toggle_emission = True photom_device.calibrate_w_camera( mirror_index=0, camera_index=0, rectangle_boundaries=mirror_roi, grid_n_points=5, - config_file='./affine_T.yaml', + config_file=config_file, save_calib_stack_path='./calib_stack', + verbose=True, ) +photom_device.laser[0].toggle_emission = False + +# %% +# Remove the camera from the photom_device so we can use it in the acquisiton engine +photom_device.camera[0].exposure = 50_000 # [us] +photom_device.camera = [] + + +# %% +# Demo that laser should be in the center +photom_device.laser[0].toggle_emission = True +photom_device.set_position(0, [1024, 1224]) # center [y,x] +time.sleep(3) +photom_device.laser[0].toggle_emission = False # %% # Set the ablation parameters # Test the PWM signal duty_cycle = 50 # [%] (0-100) -milliseconds_on = 10 # [ms] -total_duration = 5000 # [ms] total time to run the PWM signal +period_ms = 20 # [ms] +total_duration_ms = 5000 # [ms] total time to run the PWM signal +reps = 2 +time_interval_s = 3 +photom_device.laser[0].power = 10 # [%] # %% # Ablate the cells -frequency = 1 / (milliseconds_on * 1000) # [Hz] -arduino.set_pwm(duty_cycle, frequency, total_duration) -arduino.start_timelapse(repetitions=3, time_interval_s=5) +photom_device.set_position(0, [1024, 1224]) # center [y,x] +photom_device.laser[0].pulse_mode = 1 # True enabled, False disabled +photom_device.laser[0].toggle_emission = True +frequency = 1000.0 / period_ms # [Hz] +arduino.set_pwm(duty_cycle, frequency, total_duration_ms) +arduino.start_timelapse(repetitions=reps, time_interval_s=time_interval_s) +photom_device.laser[0].toggle_emission = 0 +photom_device.laser[0].pulse_mode = 0 # True enabled, False disabled +photom_device.laser[0].power = 5 # [mW] + +# %% diff --git a/copylot/assemblies/photom/demo/demo_photom_assembly.py b/copylot/assemblies/photom/demo/demo_photom_assembly.py index 4de241bb..e5ded54c 100644 --- a/copylot/assemblies/photom/demo/demo_photom_assembly.py +++ b/copylot/assemblies/photom/demo/demo_photom_assembly.py @@ -48,14 +48,16 @@ ) import time -mirror_roi = [ - [-0.01, 0.00], - [0.019, 0.019], -] +# mirror_roi = [ +# [-0.005, 0.018], # [y,x] +# [0.013, 0.0], +# ] +mirror_roi = [[0.18, -0.005], [0.0, 0.013]] grid_points = generate_grid_points(rectangle_size=mirror_roi, n_points=5) for idx, coord in enumerate(grid_points): - mirror.position = [coord[1], coord[0]] - time.sleep(0.1) + mirror.position = [coord[0], coord[1]] + print(coord) + time.sleep(0.5) # %% @@ -74,8 +76,8 @@ ) # %% mirror_roi = [ - [-0.01, 0.00], - [0.019, 0.019], + [-0.005, 0.018], # [y,x] + [0.013, 0.0], ] # Top-left and Bottom-right corners of the mirror ROI photom_device.camera[0].exposure = 5000 photom_device.camera[0].gain = 0 diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index 15a01e18..161df893 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -1,4 +1,6 @@ from re import T + +from matplotlib import pyplot as plt from copylot.hardware.cameras.abstract_camera import AbstractCamera from copylot.hardware.mirrors.abstract_mirror import AbstractMirror from copylot.hardware.lasers.abstract_laser import AbstractLaser @@ -149,7 +151,7 @@ def calibrate_w_camera( total=len(grid_points), desc="Collecting grid points", ): - self.mirror[mirror_index].position = [coord[1], coord[0]] + self.mirror[mirror_index].position = [coord[0], coord[1]] img_sequence[idx] = self.camera[camera_index].snap() # Find the coordinates of peak per image @@ -163,13 +165,13 @@ def calibrate_w_camera( img, sigma=5, threshold_rel=1.0, min_distance=30, max_num_peaks=1 ) + timestamp = time.strftime("%Y%m%d_%H%M%S") if save_calib_stack_path is not None: print('Saving calibration stack') save_calib_stack_path = Path(save_calib_stack_path) save_calib_stack_path = Path(save_calib_stack_path) if not save_calib_stack_path.exists(): save_calib_stack_path.mkdir(parents=True, exist_ok=True) - timestamp = time.strftime("%Y%m%d_%H%M%S") output_path_name = ( save_calib_stack_path / f'calibration_images_{timestamp}.tif' ) @@ -179,9 +181,27 @@ def calibrate_w_camera( print("Saving coordinate and image stack plot") if verbose: + if save_calib_stack_path is None: + save_calib_stack_path = Path.cwd() # Plot the centroids with the MIP of the image sequence - ia.plot_centroids(img_sequence, peak_coords, mip=True) + ia.plot_centroids( + img_sequence, + peak_coords, + mip=True, + save_path=save_calib_stack_path / f'calibration_plot_{timestamp}.png' + if save_calib_stack_path is not None + else './calibration_plot.png', + ) + ## Save the points + grid_points = np.array(grid_points) + peak_coords = np.array(peak_coords) + # save the array of grid points and peak coordinates + np.savez( + save_calib_stack_path / f'calibration_points_{timestamp}.npz', + grid_points=grid_points, + peak_coords=peak_coords, + ) # Find the affine transform T_affine = self.mirror[mirror_index].affine_transform_obj.get_affine_matrix( peak_coords, grid_points @@ -189,13 +209,9 @@ def calibrate_w_camera( print(f"Affine matrix: {T_affine}") # Save the matrix - config_file = Path(config_file) - if not config_file.exists(): - config_file.mkdir(parents=True, exist_ok=True) - - self.photom_assembly.mirror[ - self._current_mirror_idx - ].affine_transform_obj.save_matrix(matrix=T_affine, config_file=config_file) + self.mirror[mirror_index].affine_transform_obj.save_matrix( + matrix=T_affine, config_file=config_file + ) ## LASER Fuctions def get_laser_power(self, laser_index: int) -> float: diff --git a/copylot/assemblies/photom/utils/arduino.py b/copylot/assemblies/photom/utils/arduino.py index 3d01c449..b5c5b5f0 100644 --- a/copylot/assemblies/photom/utils/arduino.py +++ b/copylot/assemblies/photom/utils/arduino.py @@ -36,7 +36,7 @@ def close(self): def send_command(self, command): print(f"Sending command: {command}") self.ser.write((command + '\n').encode()) - self.time.sleep(2) # Wait for Arduino to process the command + time.sleep(2) # Wait for Arduino to process the command while self.ser.in_waiting: print(self.ser.readline().decode().strip()) diff --git a/copylot/assemblies/photom/utils/image_analysis.py b/copylot/assemblies/photom/utils/image_analysis.py index b977974e..cef0707f 100644 --- a/copylot/assemblies/photom/utils/image_analysis.py +++ b/copylot/assemblies/photom/utils/image_analysis.py @@ -38,7 +38,7 @@ def find_objects_centroids( return coordinates -def plot_centroids(image_sequence, centroids, mip=True): +def plot_centroids(image_sequence, centroids, mip=True, save_path=None): """ Plot the centroids of objects on top of the image sequence. @@ -54,6 +54,9 @@ def plot_centroids(image_sequence, centroids, mip=True): for i, centroid in enumerate(centroids): plt.scatter(centroid[1], centroid[0], color="red") plt.text(centroid[1], centroid[0], str(i + 1), color="white", fontsize=8) + + if save_path is not None: + plt.savefig(save_path) plt.show() else: diff --git a/copylot/assemblies/photom/utils/scanning_algorithms.py b/copylot/assemblies/photom/utils/scanning_algorithms.py index dc9517fc..47adaf3b 100644 --- a/copylot/assemblies/photom/utils/scanning_algorithms.py +++ b/copylot/assemblies/photom/utils/scanning_algorithms.py @@ -222,7 +222,7 @@ def generate_grid_points(rectangle_size: ArrayLike, n_points: int = 5) -> np.nda - n_points: The number of points per row and column in the grid. Returns: - - An array of coordinates for the grid points, evenly distributed across the rectangle. + - An array of coordinates for the grid points, evenly distributed across the rectangle in (x,y). Example: >>> rectangle_size = [[-1, -1], [1, 1]] @@ -254,6 +254,6 @@ def generate_grid_points(rectangle_size: ArrayLike, n_points: int = 5) -> np.nda index = i * n_points + j x = start_x + j * interval_x y = start_y + i * interval_y - grid_points[index] = [y, x] + grid_points[index] = [x, y] return grid_points From 38c2072156d5a6e25cfd9db12347d5cc84328ae4 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Thu, 8 Feb 2024 15:11:57 -0800 Subject: [PATCH 40/60] fxing the laser GUI for custom laser control. adding demo gui for the arduino controls for PWM --- .../photom/demo/demo_photom_calibration.py | 8 +- .../photom/demo/photom_VIS_config.yml | 2 +- .../photom/demo/photom_calibration.py | 150 +++++++++++++++++- 3 files changed, 150 insertions(+), 10 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_photom_calibration.py b/copylot/assemblies/photom/demo/demo_photom_calibration.py index a891bf51..d08dce1d 100644 --- a/copylot/assemblies/photom/demo/demo_photom_calibration.py +++ b/copylot/assemblies/photom/demo/demo_photom_calibration.py @@ -665,9 +665,9 @@ def mouseReleaseEvent(self, event): else: # NOTE: These are the actual classes that will be used in the photom assembly - from copylot.hardware.lasers.vortran import VortranLaser as Laser + from copylot.hardware.lasers.vortran.vortran import VortranLaser as Laser from copylot.hardware.mirrors.optotune.mirror import OptoMirror as Mirror - from copylot.assemblies.photom.utils.arduino_pwm import ArduinoPWM + from copylot.assemblies.photom.utils.arduino import ArduinoPWM try: os.environ["DISPLAY"] = ":1002" @@ -675,9 +675,7 @@ def mouseReleaseEvent(self, event): except: raise Exception("DISPLAY environment variable not set") - config_path = ( - "/home/eduardo.hirata/repos/coPylot/copylot/assemblies/photom/demo/config.yml" - ) + config_path = r"copylot\assemblies\photom\demo\photom_VIS_config.yml" # TODO: this should be a function that parses the config_file and returns the photom_assembly # Load the config file and parse it diff --git a/copylot/assemblies/photom/demo/photom_VIS_config.yml b/copylot/assemblies/photom/demo/photom_VIS_config.yml index 78fba271..f1342a26 100644 --- a/copylot/assemblies/photom/demo/photom_VIS_config.yml +++ b/copylot/assemblies/photom/demo/photom_VIS_config.yml @@ -1,6 +1,6 @@ lasers: - name: laser_405 - COM_port: COM7 + COM_port: COM9 mirrors: - name: mirror_1_VIS diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 20d80c58..dcf145e9 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -1,4 +1,5 @@ import sys +from tokenize import Double import yaml from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtWidgets import ( @@ -18,6 +19,8 @@ QStackedWidget, QComboBox, QFileDialog, + QLineEdit, + QGridLayout, ) from PyQt5.QtGui import QColor, QPen, QFont, QFontMetricsF, QMouseEvent from copylot.assemblies.photom.utils.scanning_algorithms import ( @@ -72,8 +75,11 @@ def initialize_UI(self): layout.addWidget(self.laser_power_slider) # Add a QLabel to display the power value - self.power_label = QLabel(f"Power: {self._curr_power} %") - layout.addWidget(self.power_label) + self.power_edit = QLineEdit(f"{self._curr_power:.2f}") # Changed to QLineEdit + self.power_edit.returnPressed.connect( + self.edit_power + ) # Connect the returnPressed signal + layout.addWidget(self.power_edit) self.laser_toggle_button = QPushButton("Toggle") self.laser_toggle_button.clicked.connect(self.toggle_laser) @@ -94,9 +100,28 @@ def toggle_laser(self): def update_power(self, value): self.laser.power = value / (10**self._slider_decimal) - self._curr_power = self.laser.power + + self._curr_power = value / (10**self._slider_decimal) # Update the QLabel with the new power value - self.power_label.setText(f"Power: {self._curr_power} %") + self.power_edit.setText(f"{self._curr_power:.2f}") + + def edit_power(self): + try: + # Extract the numerical value from the QLineEdit text + power_value_str = self.power_edit.text() + power_value = float(power_value_str) + + if ( + 0 <= power_value <= 100 + ): # Assuming the power range is 0 to 100 percentages + self._curr_power = power_value + self.laser.power = self._curr_power + self.laser_power_slider.setValue(self._curr_power) + self.power_edit.setText(f"{self._curr_power:.2f}") + else: + self.power_edit.setText(f"{self._curr_power:.2f}") + except ValueError: + self.power_edit.setText(f"{self._curr_power:.2f}") # TODO: connect widget to actual abstract mirror calls @@ -155,6 +180,104 @@ def check_mirror_limits(self): self.movement_limits_y = movement_limits[2:4] +class ArduinoPWMWidget(QWidget): + def __init__(self, arduino_pwm): + super().__init__() + self.arduino_pwm = arduino_pwm + + # default values + self.duty_cycle = 50 # [%] (0-100) + self.frequency = 1000 # [Hz] + self.duration = 5000 # [ms] + + self.initialize_UI() + + def initialize_UI(self): + layout = QGridLayout() # Use QGridLayout + + # Duty Cycle + layout.addWidget(QLabel("Duty Cycle [%]:"), 0, 0) # Label for duty cycle + self.duty_cycle_slider = DoubleSlider(orientation=Qt.Horizontal) + self.duty_cycle_slider.setMinimum(0) + self.duty_cycle_slider.setMaximum(100) + self.duty_cycle_slider.setValue(self.duty_cycle) + self.duty_cycle_slider.valueChanged.connect(self.update_duty_cycle) + layout.addWidget(self.duty_cycle_slider, 0, 1) + self.duty_cycle_edit = QLineEdit(f"{self.duty_cycle}") + self.duty_cycle_edit.returnPressed.connect(self.edit_duty_cycle) + layout.addWidget(self.duty_cycle_edit, 0, 2) + + # Frequency + layout.addWidget(QLabel("Frequency [Hz]:"), 1, 0) # Label for frequency + self.frequency_slider = DoubleSlider(orientation=Qt.Horizontal) + self.frequency_slider.setMinimum(0) + self.frequency_slider.setMaximum(100) + self.frequency_slider.setValue(self.frequency) + self.frequency_slider.valueChanged.connect(self.update_frequency) + layout.addWidget(self.frequency_slider, 1, 1) + self.frequency_edit = QLineEdit(f"{self.frequency}") + self.frequency_edit.returnPressed.connect(self.edit_frequency) + layout.addWidget(self.frequency_edit, 1, 2) + + # Duration + layout.addWidget(QLabel("Duration [ms]:"), 2, 0) # Label for duration + self.duration_slider = DoubleSlider(orientation=Qt.Horizontal) + self.duration_slider.setMinimum(0) + self.duration_slider.setMaximum(100) + self.duration_slider.setValue(self.duration) + self.duration_slider.valueChanged.connect(self.update_duration) + layout.addWidget(self.duration_slider, 2, 1) + self.duration_edit = QLineEdit(f"{self.duration}") + self.duration_edit.returnPressed.connect(self.edit_duration) + layout.addWidget(self.duration_edit, 2, 2) + + # Add Start Button + self.start_button = QPushButton("Start PWM") + self.start_button.clicked.connect( + self.start_pwm + ) # Assuming start_pwm is a method you've defined + layout.addWidget(self.start_button, 0, 3, 1, 2) # Span 1 row and 2 columns + + self.setLayout(layout) + + def update_duty_cycle(self, value): + self.duty_cycle = value + self.duty_cycle_edit.setText(f"{value:.2f}") + self.update_command() + + def edit_duty_cycle(self): + value = float(self.duty_cycle_edit.text()) + self.duty_cycle = value + self.duty_cycle_slider.setValue(value) + + def update_frequency(self, value): + self.frequency = value + self.frequency_edit.setText(f"{value:.2f}") + self.update_command() + + def edit_frequency(self): + value = float(self.frequency_edit.text()) + self.frequency = value + self.frequency_slider.setValue(value) + + def update_duration(self, value): + self.duration = value + self.duration_edit.setText(f"{value:.2f}") + self.update_command() + + def edit_duration(self): + value = float(self.duration_edit.text()) + self.duration = value + self.duration_slider.setValue(value) + + def update_command(self): + self.command = f"U,{self.duty_cycle},{self.frequency},{self.duration}" + self.arduino_pwm.send_command(self.command) + + def start_pwm(self): + self.arduino_pwm.send_command("S") + + class PhotomApp(QMainWindow): def __init__( self, @@ -162,8 +285,11 @@ def __init__( photom_window_size: Tuple[int, int] = (400, 500), photom_window_pos: Tuple[int, int] = (100, 100), demo_window=None, + arduino=[], ): super().__init__() + # TODO:temporary for arduino. remove when we replace with dac + self.arduino_pwm = arduino self.photom_window = None self.photom_controls_window = None @@ -252,11 +378,23 @@ def initialize_UI(self): mirror_layout.addWidget(mirror_widget) mirror_group.setLayout(mirror_layout) + # TODO remove if arduino is removed + # Adding group for arduino PWM + arduino_group = QGroupBox("Arduino PWM") + arduino_layout = QVBoxLayout() + self.arduino_pwm_widgets = [] + for arduino in self.arduino_pwm: + arduino_pwm_widget = ArduinoPWMWidget(arduino) + self.arduino_pwm_widgets.append(arduino_pwm_widget) + arduino_layout.addWidget(arduino_pwm_widget) + arduino_group.setLayout(arduino_layout) + # Add the laser and mirror group boxes to the main layout main_layout = QVBoxLayout() main_layout.addWidget(transparency_group) main_layout.addWidget(laser_group) main_layout.addWidget(mirror_group) + main_layout.addWidget(arduino_group) # TODO remove if arduino is removed self.mirror_dropdown = QComboBox() self.mirror_dropdown.addItems([mirror.name for mirror in self.mirrors]) @@ -739,6 +877,7 @@ def display_marker_center(self, marker, coords=None): else: from copylot.hardware.mirrors.optotune.mirror import OptoMirror as Mirror from copylot.hardware.lasers.vortran.vortran import VortranLaser as Laser + from copylot.assemblies.photom.utils.arduino import ArduinoPWM config_path = r"./copylot/assemblies/photom/demo/photom_VIS_config.yml" @@ -765,6 +904,8 @@ def display_marker_center(self, marker, coords=None): affine_matrix_paths = [ mirror['affine_matrix_path'] for mirror in config['mirrors'] ] + arduino = [ArduinoPWM(serial_port='COM10', baud_rate=115200)] + # Check that the number of mirrors and affine matrices match assert len(mirrors) == len(affine_matrix_paths) @@ -793,6 +934,7 @@ def display_marker_center(self, marker, coords=None): photom_window_size=(ctrl_window_width, ctrl_window_width), photom_window_pos=(100, 100), demo_window=camera_window, + arduino=arduino, ) # Set the camera window to the calibration scene camera_window.switch_to_calibration_scene() From 353112eac5b569fb6d1b296caf84762cb065656d Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Tue, 13 Feb 2024 16:34:18 -0800 Subject: [PATCH 41/60] fixed the right click laser toggle added pulsing mode on laser added parameters for arduino pwm adding some patches to vortran laser with pulse power adding bitbanging arduino code --- .../photom/demo/demo_arduino_pwm.py | 24 +- .../photom/demo/demo_photom_calibration.py | 2 +- .../photom/demo/photom_calibration.py | 299 +++++++++++++----- copylot/assemblies/photom/utils/arduino.py | 2 +- .../sketch_PWM_vortran_2.ino | 78 +++++ copylot/hardware/lasers/vortran/vortran.py | 4 + 6 files changed, 319 insertions(+), 90 deletions(-) create mode 100644 copylot/assemblies/photom/utils/arduino/sketch_PWM_vortran_2/sketch_PWM_vortran_2.ino diff --git a/copylot/assemblies/photom/demo/demo_arduino_pwm.py b/copylot/assemblies/photom/demo/demo_arduino_pwm.py index 8c1e6499..b10fb504 100644 --- a/copylot/assemblies/photom/demo/demo_arduino_pwm.py +++ b/copylot/assemblies/photom/demo/demo_arduino_pwm.py @@ -43,7 +43,7 @@ ], ] # [x,y] -photom_device.camera[0].exposure = 10000 # [us] +photom_device.camera[0].exposure = 10_000 # [us] photom_device.camera[0].gain = 0 # photom_device.camera[0].pixel_format = 'Mono16' @@ -65,7 +65,7 @@ # %% # Remove the camera from the photom_device so we can use it in the acquisiton engine -photom_device.camera[0].exposure = 50_000 # [us] +photom_device.camera[0].exposure = 10_000 # [us] photom_device.camera = [] @@ -79,22 +79,28 @@ # Set the ablation parameters # Test the PWM signal duty_cycle = 50 # [%] (0-100) -period_ms = 20 # [ms] -total_duration_ms = 5000 # [ms] total time to run the PWM signal +period_ms = 500 # [ms] +total_duration_ms = 5_000 # [ms] total time to run the PWM signal reps = 2 time_interval_s = 3 -photom_device.laser[0].power = 10 # [%] +photom_device.laser[0].pulse_power = 10 # [%] # %% # Ablate the cells -photom_device.set_position(0, [1024, 1224]) # center [y,x] +# photom_device.set_position(0, [1024, 1224]) # center [y,x] +photom_device.set_position(0, [0, 0]) # edge [y,x] might not be visble +photom_device.laser[0].toggle_emission = 1 +time.sleep(0.2) photom_device.laser[0].pulse_mode = 1 # True enabled, False disabled -photom_device.laser[0].toggle_emission = True +time.sleep(0.2) frequency = 1000.0 / period_ms # [Hz] arduino.set_pwm(duty_cycle, frequency, total_duration_ms) arduino.start_timelapse(repetitions=reps, time_interval_s=time_interval_s) -photom_device.laser[0].toggle_emission = 0 +# %% photom_device.laser[0].pulse_mode = 0 # True enabled, False disabled -photom_device.laser[0].power = 5 # [mW] +time.sleep(0.2) +photom_device.laser[0].toggle_emission = 0 +time.sleep(0.2) +photom_device.laser[0].power = 5.0 # [mW] # %% diff --git a/copylot/assemblies/photom/demo/demo_photom_calibration.py b/copylot/assemblies/photom/demo/demo_photom_calibration.py index d08dce1d..5e6fd60c 100644 --- a/copylot/assemblies/photom/demo/demo_photom_calibration.py +++ b/copylot/assemblies/photom/demo/demo_photom_calibration.py @@ -21,7 +21,7 @@ QLineEdit, QGridLayout, ) -from PyQt5.QtGui import QColor, QPen +from PyQt5.QtGui import QColor, QPen, QFont, QFontMetricsF, QMouseEvent from copylot.assemblies.photom.utils.scanning_algorithms import ( calculate_rectangle_corners, ) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index dcf145e9..abc0d78b 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -1,5 +1,6 @@ import sys from tokenize import Double +from matplotlib.pylab import f import yaml from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtWidgets import ( @@ -21,6 +22,7 @@ QFileDialog, QLineEdit, QGridLayout, + QProgressBar, ) from PyQt5.QtGui import QColor, QPen, QFont, QFontMetricsF, QMouseEvent from copylot.assemblies.photom.utils.scanning_algorithms import ( @@ -30,6 +32,7 @@ import numpy as np from copylot.assemblies.photom.photom import PhotomAssembly from typing import Any, Tuple +import time # DEMO_MODE = True DEMO_MODE = False @@ -46,18 +49,29 @@ def __init__(self, laser): self.laser = laser self.emission_state = 0 # 0 = off, 1 = on - self.emission_delay = 0.0 + self.emission_delay = 0 # 0 =off ,1= 5 sec delay - self._curr_power = 0.0 + self._curr_power = 0 self._slider_decimal = 1 + self._curr_laser_pulse_mode = False self.initializer_laser() self.initialize_UI() def initializer_laser(self): - self.laser.toggle_emission = self.emission_state - self.laser.power = self._curr_power + # Set the power to 0 + self.laser.toggle_emission = 0 self.laser.emission_delay = self.emission_delay + self.laser.pulse_power = self._curr_power + self.laser.power = self._curr_power + + # Make sure laser is in continuous mode + if self.laser.pulse_mode == 1: + self.laser.toggle_emission = 1 + time.sleep(0.2) + self.laser.pulse_mode = self._curr_laser_pulse_mode ^ 1 + time.sleep(0.2) + self.laser.toggle_emission = 0 def initialize_UI(self): layout = QVBoxLayout() @@ -81,6 +95,12 @@ def initialize_UI(self): ) # Connect the returnPressed signal layout.addWidget(self.power_edit) + # Set Pulse Mode Button + self.pulse_mode_button = QPushButton("Pulse Mode") + self.pulse_mode_button.clicked.connect(self.laser_pulse_mode) + layout.addWidget(self.pulse_mode_button) + self.pulse_mode_button.setStyleSheet("background-color: magenta") + self.laser_toggle_button = QPushButton("Toggle") self.laser_toggle_button.clicked.connect(self.toggle_laser) # make it background red if laser is off @@ -99,9 +119,12 @@ def toggle_laser(self): self.laser_toggle_button.setStyleSheet("background-color: green") def update_power(self, value): - self.laser.power = value / (10**self._slider_decimal) - self._curr_power = value / (10**self._slider_decimal) + if self._curr_laser_pulse_mode: + self.laser.pulse_power = self._curr_power + else: + self.laser.power = self._curr_power + # Update the QLabel with the new power value self.power_edit.setText(f"{self._curr_power:.2f}") @@ -120,9 +143,30 @@ def edit_power(self): self.power_edit.setText(f"{self._curr_power:.2f}") else: self.power_edit.setText(f"{self._curr_power:.2f}") + print(f"Power: {self._curr_power}") except ValueError: self.power_edit.setText(f"{self._curr_power:.2f}") + def laser_pulse_mode(self): + self._curr_laser_pulse_mode = not self._curr_laser_pulse_mode + self.laser.toggle_emission = 1 + if self._curr_laser_pulse_mode: + self.pulse_mode_button.setStyleSheet("background-color: green") + self.laser.pulse_power = self._curr_power + self.laser.pulse_mode = 1 + time.sleep(0.2) + else: + self.laser.power = self._curr_power + self.laser.pulse_mode = 0 + self.pulse_mode_button.setStyleSheet("background-color: magenta") + time.sleep(0.2) + self.laser_toggle_button.setStyleSheet("background-color: magenta") + self.laser.toggle_emission = 0 + self.emission_state = 0 + + print(f'pulse mode bool: {self._curr_laser_pulse_mode}') + print(f'digital modulation = {self.laser.pulse_mode}') + # TODO: connect widget to actual abstract mirror calls class MirrorWidget(QWidget): @@ -181,101 +225,163 @@ def check_mirror_limits(self): class ArduinoPWMWidget(QWidget): - def __init__(self, arduino_pwm): + def __init__(self, photom_assembly, arduino_pwm): super().__init__() self.arduino_pwm = arduino_pwm - + self.photom_assembly = photom_assembly # default values self.duty_cycle = 50 # [%] (0-100) - self.frequency = 1000 # [Hz] + self.time_period_ms = 10 # [ms] + self.frequency = 1000.0 / self.time_period_ms # [Hz] self.duration = 5000 # [ms] + self.repetitions = 1 # By default it runs once + self.time_interval_s = 0 # [s] + + self.command = f"U,{self.duty_cycle},{self.frequency},{self.duration}" self.initialize_UI() def initialize_UI(self): layout = QGridLayout() # Use QGridLayout + # Laser Dropdown Menu + self.laser_dropdown = QComboBox() + for laser in self.photom_assembly.laser: + self.laser_dropdown.addItem(laser.name) + layout.addWidget(QLabel("Select Laser:"), 0, 0) + layout.addWidget(self.laser_dropdown, 0, 1) + self.laser_dropdown.currentIndexChanged.connect(self.current_laser_changed) + # Duty Cycle - layout.addWidget(QLabel("Duty Cycle [%]:"), 0, 0) # Label for duty cycle - self.duty_cycle_slider = DoubleSlider(orientation=Qt.Horizontal) - self.duty_cycle_slider.setMinimum(0) - self.duty_cycle_slider.setMaximum(100) - self.duty_cycle_slider.setValue(self.duty_cycle) - self.duty_cycle_slider.valueChanged.connect(self.update_duty_cycle) - layout.addWidget(self.duty_cycle_slider, 0, 1) + layout.addWidget(QLabel("Duty Cycle [%]:"), 1, 0) self.duty_cycle_edit = QLineEdit(f"{self.duty_cycle}") self.duty_cycle_edit.returnPressed.connect(self.edit_duty_cycle) - layout.addWidget(self.duty_cycle_edit, 0, 2) - - # Frequency - layout.addWidget(QLabel("Frequency [Hz]:"), 1, 0) # Label for frequency - self.frequency_slider = DoubleSlider(orientation=Qt.Horizontal) - self.frequency_slider.setMinimum(0) - self.frequency_slider.setMaximum(100) - self.frequency_slider.setValue(self.frequency) - self.frequency_slider.valueChanged.connect(self.update_frequency) - layout.addWidget(self.frequency_slider, 1, 1) - self.frequency_edit = QLineEdit(f"{self.frequency}") - self.frequency_edit.returnPressed.connect(self.edit_frequency) - layout.addWidget(self.frequency_edit, 1, 2) + layout.addWidget(self.duty_cycle_edit, 1, 1) + + # Time Period + layout.addWidget(QLabel("Time Period [ms]:"), 2, 0) + self.time_period_edit = QLineEdit(f"{self.time_period_ms}") + self.time_period_edit.returnPressed.connect(self.edit_time_period) + layout.addWidget(self.time_period_edit, 2, 1) # Duration - layout.addWidget(QLabel("Duration [ms]:"), 2, 0) # Label for duration - self.duration_slider = DoubleSlider(orientation=Qt.Horizontal) - self.duration_slider.setMinimum(0) - self.duration_slider.setMaximum(100) - self.duration_slider.setValue(self.duration) - self.duration_slider.valueChanged.connect(self.update_duration) - layout.addWidget(self.duration_slider, 2, 1) + layout.addWidget(QLabel("Duration [ms]:"), 3, 0) self.duration_edit = QLineEdit(f"{self.duration}") self.duration_edit.returnPressed.connect(self.edit_duration) - layout.addWidget(self.duration_edit, 2, 2) - - # Add Start Button + layout.addWidget(self.duration_edit, 3, 1) + + # Repetitions + layout.addWidget(QLabel("Repetitions:"), 4, 0) + self.repetitions_edit = QLineEdit(f"{self.repetitions}") + self.repetitions_edit.textChanged.connect(self.edit_repetitions) + layout.addWidget(self.repetitions_edit, 4, 1) + + # Time interval + layout.addWidget(QLabel("Time interval [s]:"), 5, 0) + self.time_interval_edit = QLineEdit(f"{self.time_interval_s}") + self.time_interval_edit.textChanged.connect(self.edit_time_interval) + layout.addWidget(self.time_interval_edit, 5, 1) + + # Apply Button + self.apply_button = QPushButton("Apply") + self.apply_button.clicked.connect(self.apply_settings) + layout.addWidget(self.apply_button, 6, 0, 1, 2) + + # Start Button self.start_button = QPushButton("Start PWM") - self.start_button.clicked.connect( - self.start_pwm - ) # Assuming start_pwm is a method you've defined - layout.addWidget(self.start_button, 0, 3, 1, 2) # Span 1 row and 2 columns + self.start_button.clicked.connect(self.start_pwm) + layout.addWidget(self.start_button, 7, 0, 1, 2) - self.setLayout(layout) + # Add Stop Button + self.stop_button = QPushButton("Stop PWM") + self.stop_button.clicked.connect(self.stop_pwm) + layout.addWidget(self.stop_button, 8, 0, 1, 2) # Adjust position as needed - def update_duty_cycle(self, value): - self.duty_cycle = value - self.duty_cycle_edit.setText(f"{value:.2f}") - self.update_command() + # Add Progress Bar + self.progressBar = QProgressBar(self) + self.progressBar.setMaximum(100) # Set the maximum value + layout.addWidget(self.progressBar, 9, 0, 1, 2) # Adjust position as needed + + self.setLayout(layout) def edit_duty_cycle(self): - value = float(self.duty_cycle_edit.text()) - self.duty_cycle = value - self.duty_cycle_slider.setValue(value) + try: + value = float(self.duty_cycle_edit.text()) + self.duty_cycle = value + self.update_command() + except ValueError: + self.duty_cycle_edit.setText(f"{self.duty_cycle}") - def update_frequency(self, value): - self.frequency = value - self.frequency_edit.setText(f"{value:.2f}") - self.update_command() + def edit_time_period(self): + try: + value = float(self.time_period_edit.text()) + self.time_period_ms = value + self.frequency = 1000.0 / self.time_period_ms + self.update_command() + except ValueError: + self.time_period_edit.setText(f"{self.time_period}") - def edit_frequency(self): - value = float(self.frequency_edit.text()) - self.frequency = value - self.frequency_slider.setValue(value) + def edit_duration(self): + try: + value = float(self.duration_edit.text()) + self.duration = value + self.update_command() + except ValueError: + self.duration_edit.setText(f"{self.duration}") - def update_duration(self, value): - self.duration = value - self.duration_edit.setText(f"{value:.2f}") - self.update_command() + def edit_repetitions(self): + try: + value = int(self.repetitions_edit.text()) + self.repetitions = value + except ValueError: + self.repetitions_edit.setText( + f"{self.repetitions}" + ) # Reset to last valid value - def edit_duration(self): - value = float(self.duration_edit.text()) - self.duration = value - self.duration_slider.setValue(value) + def edit_time_interval(self): + try: + value = float(self.time_interval_edit.text()) + self.time_interval_s = value + except ValueError: + self.time_interval_edit.setText( + f"{self.time_interval_s}" + ) # Reset to last valid value def update_command(self): self.command = f"U,{self.duty_cycle},{self.frequency},{self.duration}" + print(f"arduino out: {self.command}") self.arduino_pwm.send_command(self.command) def start_pwm(self): - self.arduino_pwm.send_command("S") + print("Starting PWM...") + self.pwm_worker = PWMWorker( + self.arduino_pwm, 'S', self.repetitions, self.time_interval_s, self.duration + ) + self.pwm_worker.finished.connect(self.on_pwm_finished) + self.pwm_worker.progress.connect(self.update_progress_bar) + self.pwm_worker.start() + + def update_progress_bar(self, value): + self.progressBar.setValue(value) # Update the progress bar with the new value + + def stop_pwm(self): + if hasattr(self, 'pwm_worker') and self.pwm_worker.isRunning(): + self.pwm_worker.request_stop() + + def on_pwm_finished(self): + print("PWM operation completed.") + # self.progressBar.setValue(0) # Reset the progress bar + + def current_laser_changed(self, index): + self._curr_laser_idx = index + self.apply_settings() + + def apply_settings(self): + # Implement functionality to apply settings to the selected laser + self._curr_laser_idx = self.laser_dropdown.currentIndex() + # TODO: Need to modify the data struct for command for multiple lasers + if hasattr(self, 'command'): + self.arduino_pwm.send_command(self.command) class PhotomApp(QMainWindow): @@ -384,7 +490,7 @@ def initialize_UI(self): arduino_layout = QVBoxLayout() self.arduino_pwm_widgets = [] for arduino in self.arduino_pwm: - arduino_pwm_widget = ArduinoPWMWidget(arduino) + arduino_pwm_widget = ArduinoPWMWidget(self.photom_assembly, arduino) self.arduino_pwm_widgets.append(arduino_pwm_widget) arduino_layout.addWidget(arduino_pwm_widget) arduino_group.setLayout(arduino_layout) @@ -537,7 +643,6 @@ def done_calibration(self): T_affine = self.photom_assembly.mirror[ self._current_mirror_idx ].affine_transform_obj.get_affine_matrix(origin, dest) - # logger.debug(f"Affine matrix: {T_affine}") print(f"Affine matrix: {T_affine}") # Save the affine matrix to a file @@ -591,6 +696,36 @@ def display_rectangle(self): self.photom_window.switch_to_calibration_scene() +class PWMWorker(QThread): + finished = pyqtSignal() + progress = pyqtSignal(int) # Signal to report progress + _stop_requested = False + + def request_stop(self): + self._stop_requested = True + + def __init__(self, arduino_pwm, command, repetitions, time_interval_s, duration): + super().__init__() + self.arduino_pwm = arduino_pwm + self.command = command + self.repetitions = repetitions + self.time_interval_s = time_interval_s + self.duration = duration + + def run(self): + # Simulate sending the command and waiting (replace with actual logic) + for i in range(self.repetitions): + if self._stop_requested: + break + self.arduino_pwm.send_command(self.command) + # TODO: replace when using a better microcontroller since we dont get signals back rn + time.sleep(self.duration / 1000) + self.progress.emit(int((i + 1) / self.repetitions * 100)) + time.sleep(self.time_interval_s) # Simulate time interval + + self.finished.emit() + + class CalibrationThread(QThread): finished = pyqtSignal() @@ -807,23 +942,24 @@ def eventFilter(self, source, event): print('right button pressed') elif event.type() == QMouseEvent.MouseButtonRelease: if self.calibration_mode: - if event.buttons() == Qt.LeftButton: + if event.button() == Qt.LeftButton: self._left_click_hold = False print('left button released') - elif event.buttons() == Qt.RightButton: + elif event.button() == Qt.RightButton: self._right_click_hold = False print('right button released') else: print('mouse button released') - if event.buttons() == Qt.LeftButton: - self._left_click_hold = False + if event.button() == Qt.LeftButton: print('left button released') - elif event.buttons() == Qt.RightButton: - print('right button released') + self._left_click_hold = False + elif event.button() == Qt.RightButton: self._right_click_hold = False self.photom_controls.photom_assembly.laser[ 0 ].toggle_emission = False + time.sleep(0.5) + print('right button released') return super(LaserMarkerWindow, self).eventFilter(source, event) @@ -869,15 +1005,19 @@ def display_marker_center(self, marker, coords=None): import os if DEMO_MODE: - from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror + from copylot.assemblies.photom.photom_mock_devices import ( + MockLaser, + MockMirror, + MockArduinoPWM, + ) Laser = MockLaser Mirror = MockMirror - + ArduinoPWM = MockArduinoPWM else: from copylot.hardware.mirrors.optotune.mirror import OptoMirror as Mirror from copylot.hardware.lasers.vortran.vortran import VortranLaser as Laser - from copylot.assemblies.photom.utils.arduino import ArduinoPWM + from copylot.assemblies.photom.utils.arduino import ArduinoPWM as ArduinoPWM config_path = r"./copylot/assemblies/photom/demo/photom_VIS_config.yml" @@ -954,6 +1094,7 @@ def display_marker_center(self, marker, coords=None): photom_assembly=photom_assembly, photom_window_size=(ctrl_window_width, ctrl_window_width), photom_window_pos=(100, 100), + arduino=arduino, ) sys.exit(app.exec_()) diff --git a/copylot/assemblies/photom/utils/arduino.py b/copylot/assemblies/photom/utils/arduino.py index b5c5b5f0..4679c1d5 100644 --- a/copylot/assemblies/photom/utils/arduino.py +++ b/copylot/assemblies/photom/utils/arduino.py @@ -36,7 +36,7 @@ def close(self): def send_command(self, command): print(f"Sending command: {command}") self.ser.write((command + '\n').encode()) - time.sleep(2) # Wait for Arduino to process the command + time.sleep(1) # Wait for Arduino to process the command while self.ser.in_waiting: print(self.ser.readline().decode().strip()) diff --git a/copylot/assemblies/photom/utils/arduino/sketch_PWM_vortran_2/sketch_PWM_vortran_2.ino b/copylot/assemblies/photom/utils/arduino/sketch_PWM_vortran_2/sketch_PWM_vortran_2.ino new file mode 100644 index 00000000..43ebe6ad --- /dev/null +++ b/copylot/assemblies/photom/utils/arduino/sketch_PWM_vortran_2/sketch_PWM_vortran_2.ino @@ -0,0 +1,78 @@ +#define PWM_PIN 9 + +// Variables for PWM parameters +unsigned int dutyCycle = 0; +unsigned long duration = 0; // Duration in milliseconds +float pwmFrequency = 0.0; // Frequency as a float +unsigned long pwmPeriod = 0; // Period in milliseconds + +void setup() { + Serial.begin(115200); + pinMode(PWM_PIN, OUTPUT); + Serial.println("Send 'U,duty,freq,duration' to set PWM. Send 'S' to start PWM."); +} + +void loop() { + static unsigned long lastToggleTime = 0; + static bool pinState = LOW; + static bool pwmRunning = false; + static unsigned long pwmStartTime = 0; + + if (Serial.available() > 0) { + String command = Serial.readStringUntil('\n'); + + if (command.startsWith("U,")) { + // Update PWM settings + int firstComma = command.indexOf(','); + int secondComma = command.indexOf(',', firstComma + 1); + int thirdComma = command.indexOf(',', secondComma + 1); + + dutyCycle = command.substring(firstComma + 1, secondComma).toInt(); + pwmFrequency = command.substring(secondComma + 1, thirdComma).toFloat(); // Use float for frequency + duration = command.substring(thirdComma + 1).toInt(); + + // Convert frequency to period (milliseconds) + if (pwmFrequency > 0) { + pwmPeriod = (unsigned long)(1000.0 / pwmFrequency); + } else { + pwmPeriod = 0; // Avoid division by zero + } + + Serial.println("Duty Cycle: " + String(dutyCycle)); + Serial.println("PWM Frequency (Hz): " + String(pwmFrequency)); + Serial.println("PWM Period (ms): " + String(pwmPeriod)); + Serial.println("Duration (ms): " + String(duration)); + + Serial.println("PWM settings updated. Send 'S' to start."); + } + else if (command.startsWith("S")) { + // Start PWM + pwmRunning = true; + pwmStartTime = millis(); + lastToggleTime = millis(); // Use millis() for longer periods + pinState = HIGH; + digitalWrite(PWM_PIN, pinState); + } + } + + if (pwmRunning && pwmPeriod > 0) { + unsigned long currentTime = millis(); // Use millis() for compatibility with long periods + unsigned long elapsedTime = currentTime - lastToggleTime; + unsigned long highTime = pwmPeriod * dutyCycle / 100; + unsigned long lowTime = pwmPeriod - highTime; + + // Toggle pin state based on the duty cycle + if ((pinState == HIGH && elapsedTime >= highTime) || (pinState == LOW && elapsedTime >= lowTime)) { + pinState = !pinState; + digitalWrite(PWM_PIN, pinState); + lastToggleTime = currentTime; + } + + // Stop PWM after the specified duration + if (currentTime - pwmStartTime >= duration) { + pwmRunning = false; + digitalWrite(PWM_PIN, LOW); // Ensure pin is low after stopping + Serial.println("PWM cycle complete. Update settings or start again."); + } + } +} diff --git a/copylot/hardware/lasers/vortran/vortran.py b/copylot/hardware/lasers/vortran/vortran.py index bb6322c2..8b55cede 100644 --- a/copylot/hardware/lasers/vortran/vortran.py +++ b/copylot/hardware/lasers/vortran/vortran.py @@ -400,6 +400,9 @@ def pulse_power(self, power): Pulse Power configuration """ logger.info(f'Setting Power:{power}') + if power > self._max_power: + power = self._max_power + logger.info(f'Maximum power is: {self._max_power}') self._pulse_power = float(self._write_cmd('PP', str(power))[0]) @property @@ -415,6 +418,7 @@ def pulse_mode(self, mode=0): """ Toggle Pulse Mode On and Off (1=On) """ + logger.debug(f'Digital Modulation: {mode}') self._pulse_mode = self._write_cmd('PUL', str(mode))[0] @property From 1e152d97d7bb7c634b935576d14c8d1a89f63fd0 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Tue, 13 Feb 2024 17:03:51 -0800 Subject: [PATCH 42/60] added code to close both windows if one is closed --- .../photom/demo/photom_calibration.py | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index abc0d78b..f1d98d4d 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -388,7 +388,8 @@ class PhotomApp(QMainWindow): def __init__( self, photom_assembly: PhotomAssembly, - photom_window_size: Tuple[int, int] = (400, 500), + photom_sensor_size: Tuple[int, int] = (2048, 2048), + photom_window_size: int = 800, photom_window_pos: Tuple[int, int] = (100, 100), demo_window=None, arduino=[], @@ -404,6 +405,7 @@ def __init__( self.lasers = self.photom_assembly.laser self.mirrors = self.photom_assembly.mirror self.photom_window_size = photom_window_size + self.photom_sensor_size = photom_sensor_size self.photom_window_pos = photom_window_pos self._current_mirror_idx = 0 self._laser_window_transparency = 0.7 @@ -421,16 +423,19 @@ def __init__( def initializer_laser_marker_window(self): # Making the photom_window a square and display right besides the control UI window_pos = ( - self.photom_window_size[0] + self.photom_window_pos[0], + self.photom_window_size + self.photom_window_pos[0], self.photom_window_pos[1], ) - window_size = (self.photom_window_size[0], self.photom_window_size[1]) self.photom_window = LaserMarkerWindow( photom_controls=self, name='Laser Marker', - window_size=window_size, + sensor_size=self.photom_sensor_size, + fixed_width=self.photom_window_size, window_pos=window_pos, ) + self.photom_window.windowClosed.connect( + self.closeAllWindows + ) # Connect the signal to slot def initialize_UI(self): """ @@ -440,8 +445,8 @@ def initialize_UI(self): self.setGeometry( self.photom_window_pos[0], self.photom_window_pos[1], - self.photom_window_size[0], - self.photom_window_size[1], + self.photom_window_size, + self.photom_window_size, ) self.setWindowTitle("Laser and Mirror Control App") @@ -695,6 +700,15 @@ def update_transparency(self, value): def display_rectangle(self): self.photom_window.switch_to_calibration_scene() + def closeEvent(self, event): + self.closeAllWindows() # Ensure closing main window closes everything + super().closeEvent(event) + + def closeAllWindows(self): + self.photom_window.close() + self.close() + QApplication.quit() # Quit the application + class PWMWorker(QThread): finished = pyqtSignal() @@ -744,17 +758,23 @@ def run(self): class LaserMarkerWindow(QMainWindow): + windowClosed = pyqtSignal() # Define the signal + def __init__( self, photom_controls: QMainWindow = None, name="Laser Marker", - window_size: Tuple = (400, 500), + sensor_size: Tuple = (2048, 2048), window_pos: Tuple = (100, 100), + fixed_width: int = 800, ): super().__init__() self.photom_controls = photom_controls self.window_name = name - self.window_geometry = window_pos + window_size + self.aspect_ratio = sensor_size[0] / sensor_size[1] + fixed_width = 800 + calculated_height = int(fixed_width / self.aspect_ratio) + self.window_geometry = window_pos + (calculated_height, fixed_width) self.setMouseTracking(True) self.setWindowOpacity(self.photom_controls._laser_window_transparency) @@ -817,10 +837,15 @@ def initMarker(self): # Mouse tracking self.shooting_view.installEventFilter(self) self.setMouseTracking(True) - self.marker = QGraphicsSimpleTextItem("X") + self.marker = QGraphicsSimpleTextItem("+") self.marker.setBrush(QColor(255, 0, 0)) self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) self.shooting_view.viewport().installEventFilter(self) + # Set larger font size + font = self.marker.font() + font.setPointSize(20) + self.marker.setFont(font) + # Position the marker self.display_marker_center( self.marker, (self.canvas_width / 2, self.canvas_height / 2) @@ -1000,10 +1025,17 @@ def display_marker_center(self, marker, coords=None): ) return marker + def closeEvent(self, event): + self.windowClosed.emit() # Emit the signal when the window is about to close + super().closeEvent(event) # Proceed with the default close event + if __name__ == "__main__": import os + # TODO: grab the actual value if the camera is connected to photom_assmebly + CAMERA_SENSOR_YX = (2048, 2448) + if DEMO_MODE: from copylot.assemblies.photom.photom_mock_devices import ( MockLaser, @@ -1066,12 +1098,14 @@ def display_marker_center(self, marker, coords=None): if DEMO_MODE: camera_window = LaserMarkerWindow( name="Mock laser dots", - window_size=(ctrl_window_width, ctrl_window_width), + sensor_size=(2048, 2048), window_pos=(100, 100), + fixed_width=ctrl_window_width, ) # Set the positions of the windows ctrl_window = PhotomApp( photom_assembly=photom_assembly, - photom_window_size=(ctrl_window_width, ctrl_window_width), + photom_sensor_size=CAMERA_SENSOR_YX, + photom_window_size=ctrl_window_width, photom_window_pos=(100, 100), demo_window=camera_window, arduino=arduino, @@ -1092,7 +1126,8 @@ def display_marker_center(self, marker, coords=None): # Set the positions of the windows ctrl_window = PhotomApp( photom_assembly=photom_assembly, - photom_window_size=(ctrl_window_width, ctrl_window_width), + photom_sensor_size=CAMERA_SENSOR_YX, + photom_window_size=ctrl_window_width, photom_window_pos=(100, 100), arduino=arduino, ) From 2569b30eab9e7f39b9d74ed6c3f15c7e4ca70247 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Tue, 13 Feb 2024 17:40:05 -0800 Subject: [PATCH 43/60] added the dashed lines around some confidence ROI --- .../photom/demo/photom_calibration.py | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index f1d98d4d..7cc3d999 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -23,8 +23,9 @@ QLineEdit, QGridLayout, QProgressBar, + QGraphicsRectItem, ) -from PyQt5.QtGui import QColor, QPen, QFont, QFontMetricsF, QMouseEvent +from PyQt5.QtGui import QColor, QPen, QFont, QFontMetricsF, QMouseEvent, QBrush from copylot.assemblies.photom.utils.scanning_algorithms import ( calculate_rectangle_corners, ) @@ -833,17 +834,18 @@ def initMarker(self): self.shooting_view.setMouseTracking(True) self.setCentralWidget(self.shooting_view) self.shooting_view.setAlignment(Qt.AlignTop | Qt.AlignLeft) + self.shooting_view.setFixedSize(self.canvas_width, self.canvas_height) # Mouse tracking self.shooting_view.installEventFilter(self) self.setMouseTracking(True) - self.marker = QGraphicsSimpleTextItem("+") + self.marker = QGraphicsSimpleTextItem("X") self.marker.setBrush(QColor(255, 0, 0)) self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) self.shooting_view.viewport().installEventFilter(self) # Set larger font size font = self.marker.font() - font.setPointSize(20) + font.setPointSize(10) self.marker.setFont(font) # Position the marker @@ -852,6 +854,24 @@ def initMarker(self): ) self.shooting_scene.addItem(self.marker) + + ## Add the rectangle + rect_width = 2 * self.canvas_width / 3 + rect_height = 2 * self.canvas_height / 3 + rect_x = (self.canvas_width - rect_width) / 2 + rect_y = (self.canvas_height - rect_height) / 2 + # Continue from the previous code in initMarker... + pen = QPen(QColor(0, 0, 0)) + pen.setStyle(Qt.DashLine) # Dashed line style + pen.setWidth(2) # Set the pen width + + # Create the rectangle with no fill (transparent) + rect_item = QGraphicsRectItem(rect_x, rect_y, rect_width, rect_height) + rect_item.setPen(pen) + rect_item.setBrush(QBrush(Qt.transparent)) # Transparent fill + # Add the rectangle to the scene + self.shooting_scene.addItem(rect_item) + # Add the view to the QStackedWidget self.stacked_widget.addWidget(self.shooting_view) @@ -1018,10 +1038,10 @@ def display_marker_center(self, marker, coords=None): coords = (marker.x(), marker.y()) fm = QFontMetricsF(QFont()) boundingRect = fm.tightBoundingRect(marker.text()) - mergintop = fm.ascent() + boundingRect.top() + margintop = fm.ascent() + boundingRect.top() marker.setPos( coords[0] - boundingRect.left() - boundingRect.width() / 2, - coords[1] - mergintop - boundingRect.height() / 2, + coords[1] - margintop - boundingRect.height() / 2, ) return marker From 4633b758604ca0de25902842239988ad6fed0448 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Fri, 1 Mar 2024 17:31:35 -0800 Subject: [PATCH 44/60] -adding more affine_transform functions to get and set matrix -fixing registration mismatch due to hardcoded var -adding affine transform dependent on size of laser window --- .../photom/demo/demo_affine_scan.py | 2 +- .../photom/demo/demo_photom_assembly.py | 51 ++++++-------- .../photom/demo/demo_photom_calibration.py | 2 +- .../assemblies/photom/demo/demo_yml_gui.py | 2 +- .../photom/demo/photom_calibration.py | 68 +++++++++++++------ copylot/assemblies/photom/photom.py | 12 ++-- .../photom/utils/affine_transform.py | 13 +++- .../photom/utils/scanning_algorithms.py | 2 +- 8 files changed, 89 insertions(+), 63 deletions(-) diff --git a/copylot/assemblies/photom/demo/demo_affine_scan.py b/copylot/assemblies/photom/demo/demo_affine_scan.py index a7fb3944..50a633b8 100644 --- a/copylot/assemblies/photom/demo/demo_affine_scan.py +++ b/copylot/assemblies/photom/demo/demo_affine_scan.py @@ -49,7 +49,7 @@ [xv[-1, -1] + 1, yv[-1, -1] + 1], [xv[0, -1] + 1, yv[0, -1] + 1], ] -trans_obj.get_affine_matrix(pts1, pts2) +trans_obj.compute_affine_matrix(pts1, pts2) # %% trans_obj.save_matrix(config_file='./test.yml') # %% diff --git a/copylot/assemblies/photom/demo/demo_photom_assembly.py b/copylot/assemblies/photom/demo/demo_photom_assembly.py index e5ded54c..6a5b4600 100644 --- a/copylot/assemblies/photom/demo/demo_photom_assembly.py +++ b/copylot/assemblies/photom/demo/demo_photom_assembly.py @@ -5,11 +5,13 @@ from copylot.assemblies.photom.photom_mock_devices import MockLaser, MockMirror from copylot.hardware.cameras.flir.flir_camera import FlirCamera import time +from copylot.hardware.lasers.vortran.vortran import VortranLaser as Laser # %% config_file = './photom_VIS_config.yml' # Mock imports for the mirror and the lasers -laser = MockLaser(name='Mock Laser', power=0) +# laser = MockLaser(name='Mock Laser', power=0) +laser = Laser(name='vortran405', port='COM9') mirror = OptoMirror(com_port='COM8') @@ -42,17 +44,14 @@ print(curr_pos) # %% +# Demo the grid scanning from copylot.assemblies.photom.utils.scanning_algorithms import ( calculate_rectangle_corners, generate_grid_points, ) import time -# mirror_roi = [ -# [-0.005, 0.018], # [y,x] -# [0.013, 0.0], -# ] -mirror_roi = [[0.18, -0.005], [0.0, 0.013]] +mirror_roi = [[-0.01, 0.0], [0.015, 0.018]] grid_points = generate_grid_points(rectangle_size=mirror_roi, n_points=5) for idx, coord in enumerate(grid_points): mirror.position = [coord[0], coord[1]] @@ -61,13 +60,9 @@ # %% +# Load the camera cam = FlirCamera() -# open the system cam.open() -# serial number -print(cam.device_id) -# list of cameras -print(cam.list_available_cameras()) photom_device = PhotomAssembly( laser=[laser], mirror=[mirror], @@ -75,11 +70,17 @@ camera=[cam], ) # %% -mirror_roi = [ - [-0.005, 0.018], # [y,x] - [0.013, 0.0], -] # Top-left and Bottom-right corners of the mirror ROI -photom_device.camera[0].exposure = 5000 +# Turn on the laser +photom_device.laser[0].power = 0.0 +photom_device.laser[0].toggle_emission = True +photom_device.laser[0].power = 30.0 +# %% +# mirror_roi = [ +# [-0.005, 0.018], # [y,x] +# [0.013, 0.0], +# ] # Top-left and Bottom-right corners of the mirror ROI +mirror_roi = [[-0.01, 0.0], [0.015, 0.018]] # [x,y] +photom_device.camera[0].exposure = 1000 photom_device.camera[0].gain = 0 photom_device.camera[0].flip_horizontal = True photom_device.camera[0].pixel_format = 'Mono16' @@ -88,23 +89,9 @@ camera_index=0, rectangle_boundaries=mirror_roi, grid_n_points=5, - config_file='./affine_T.yaml', + config_file='./affine_T_v1_projT.yml', save_calib_stack_path='./calib_stack', + verbose=True, ) # %% -# # TODO: Test the calibration without GUI -# import time - -# start_time = time.time() -# photom_device._calibrating = True -# while time.time() - start_time < 5: -# # Your code here -# elapsed_time = time.time() - start_time -# print(f'starttime: {start_time} elapsed_time: {elapsed_time}') -# photom_device.calibrate( -# mirror_index=0, rectangle_size_xy=[0.002, 0.002], center=[0.000, 0.000] -# ) -# photom_device._calibrating = False - -# %% diff --git a/copylot/assemblies/photom/demo/demo_photom_calibration.py b/copylot/assemblies/photom/demo/demo_photom_calibration.py index 5e6fd60c..f7c31ef5 100644 --- a/copylot/assemblies/photom/demo/demo_photom_calibration.py +++ b/copylot/assemblies/photom/demo/demo_photom_calibration.py @@ -463,7 +463,7 @@ def done_calibration(self): T_affine = self.photom_assembly.mirror[ self._current_mirror_idx - ].affine_transform_obj.get_affine_matrix(dest, origin) + ].affine_transform_obj.compute_affine_matrix(dest, origin) # logger.debug(f"Affine matrix: {T_affine}") print(f"Affine matrix: {T_affine}") diff --git a/copylot/assemblies/photom/demo/demo_yml_gui.py b/copylot/assemblies/photom/demo/demo_yml_gui.py index 85805f61..c9bc7559 100644 --- a/copylot/assemblies/photom/demo/demo_yml_gui.py +++ b/copylot/assemblies/photom/demo/demo_yml_gui.py @@ -221,7 +221,7 @@ def done_calibration(self): [[pt.x(), pt.y()] for pt in self.source_pts], dtype=np.float32 ) dest = np.array([[pt.x(), pt.y()] for pt in self.target_pts], dtype=np.float32) - T_affine = self.affine_trans_obj.get_affine_matrix(dest, origin) + T_affine = self.affine_trans_obj.compute_affine_matrix(dest, origin) self.affine_trans_obj.save_matrix() print(T_affine) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 7cc3d999..692eb564 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -42,6 +42,7 @@ # TODO: deal with the logic when clicking calibrate. Mirror dropdown # TODO: update the mock laser and mirror # TODO: replace the entry boxes tos et the laser powers +# TODO: resizeable laser marker window with aspect ratio of the camera sensor class LaserWidget(QWidget): @@ -358,6 +359,8 @@ def start_pwm(self): self.pwm_worker = PWMWorker( self.arduino_pwm, 'S', self.repetitions, self.time_interval_s, self.duration ) + # Rest Progress Bar + self.progressBar.setValue(0) self.pwm_worker.finished.connect(self.on_pwm_finished) self.pwm_worker.progress.connect(self.update_progress_bar) self.pwm_worker.start() @@ -389,8 +392,8 @@ class PhotomApp(QMainWindow): def __init__( self, photom_assembly: PhotomAssembly, - photom_sensor_size: Tuple[int, int] = (2048, 2048), - photom_window_size: int = 800, + photom_sensor_size_yx: Tuple[int, int] = (2048, 2448), + photom_window_size_x: int = 800, photom_window_pos: Tuple[int, int] = (100, 100), demo_window=None, arduino=[], @@ -405,12 +408,12 @@ def __init__( self.photom_assembly = photom_assembly self.lasers = self.photom_assembly.laser self.mirrors = self.photom_assembly.mirror - self.photom_window_size = photom_window_size - self.photom_sensor_size = photom_sensor_size + self.photom_window_size_x = photom_window_size_x + self.photom_sensor_size_yx = photom_sensor_size_yx self.photom_window_pos = photom_window_pos self._current_mirror_idx = 0 self._laser_window_transparency = 0.7 - + self.scaling_matrix = np.eye(3) self.calibration_thread = CalibrationThread( self.photom_assembly, self._current_mirror_idx ) @@ -424,16 +427,30 @@ def __init__( def initializer_laser_marker_window(self): # Making the photom_window a square and display right besides the control UI window_pos = ( - self.photom_window_size + self.photom_window_pos[0], + self.photom_window_size_x + self.photom_window_pos[0], self.photom_window_pos[1], ) self.photom_window = LaserMarkerWindow( photom_controls=self, name='Laser Marker', - sensor_size=self.photom_sensor_size, - fixed_width=self.photom_window_size, + sensor_size_yx=self.photom_sensor_size_yx, + fixed_width=self.photom_window_size_x, window_pos=window_pos, ) + self.aspect_ratio = ( + self.photom_sensor_size_yx[1] / self.photom_sensor_size_yx[0] + ) + calculated_height = self.photom_window_size_x / self.aspect_ratio + self.scaling_factor_x = ( + self.photom_sensor_size_yx[1] / self.photom_window_size_x + ) + self.scaling_factor_y = self.photom_sensor_size_yx[0] / calculated_height + # TODO: the affine transforms are in XY coordinates. Need to change to YX + self.scaling_matrix = np.array( + [[self.scaling_factor_x, 0, 1], [0, self.scaling_factor_y, 1], [0, 0, 1]] + ) + print(f'scaling factor x: {self.scaling_factor_x}') + print(f'scaling factor y: {self.scaling_factor_y}') self.photom_window.windowClosed.connect( self.closeAllWindows ) # Connect the signal to slot @@ -446,8 +463,8 @@ def initialize_UI(self): self.setGeometry( self.photom_window_pos[0], self.photom_window_pos[1], - self.photom_window_size, - self.photom_window_size, + self.photom_window_size_x, + self.photom_window_size_x, ) self.setWindowTitle("Laser and Mirror Control App") @@ -601,6 +618,15 @@ def load_calibration(self): print( f'Loaded matrix:{self.photom_assembly.mirror[self._current_mirror_idx].affine_transform_obj.T_affine}' ) + # Scale the matrix calculated from calibration to match the photom laser window + T_mirror_cam_matrix = self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.get_affine_matrix() + T_compose_mat = T_mirror_cam_matrix @ self.scaling_matrix + T_mirror_cam_matrix = self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.set_affine_matrix(T_compose_mat) + print("Scaled matrix:", T_compose_mat) self.photom_window.switch_to_shooting_scene() self.photom_window.marker.show() @@ -648,7 +674,7 @@ def done_calibration(self): ) T_affine = self.photom_assembly.mirror[ self._current_mirror_idx - ].affine_transform_obj.get_affine_matrix(origin, dest) + ].affine_transform_obj.compute_affine_matrix(origin, dest) print(f"Affine matrix: {T_affine}") # Save the affine matrix to a file @@ -765,17 +791,17 @@ def __init__( self, photom_controls: QMainWindow = None, name="Laser Marker", - sensor_size: Tuple = (2048, 2048), + sensor_size_yx: Tuple = (2048, 2048), window_pos: Tuple = (100, 100), fixed_width: int = 800, ): super().__init__() self.photom_controls = photom_controls self.window_name = name - self.aspect_ratio = sensor_size[0] / sensor_size[1] - fixed_width = 800 - calculated_height = int(fixed_width / self.aspect_ratio) - self.window_geometry = window_pos + (calculated_height, fixed_width) + self.aspect_ratio = sensor_size_yx[1] / sensor_size_yx[0] + self.fixed_width = fixed_width + calculated_height = int(self.fixed_width / self.aspect_ratio) + self.window_geometry = window_pos + (self.fixed_width, calculated_height) self.setMouseTracking(True) self.setWindowOpacity(self.photom_controls._laser_window_transparency) @@ -1118,14 +1144,14 @@ def closeEvent(self, event): if DEMO_MODE: camera_window = LaserMarkerWindow( name="Mock laser dots", - sensor_size=(2048, 2048), + sensor_size_yx=(2048, 2048), window_pos=(100, 100), fixed_width=ctrl_window_width, ) # Set the positions of the windows ctrl_window = PhotomApp( photom_assembly=photom_assembly, - photom_sensor_size=CAMERA_SENSOR_YX, - photom_window_size=ctrl_window_width, + photom_sensor_size_yx=CAMERA_SENSOR_YX, + photom_window_size_x=ctrl_window_width, photom_window_pos=(100, 100), demo_window=camera_window, arduino=arduino, @@ -1146,8 +1172,8 @@ def closeEvent(self, event): # Set the positions of the windows ctrl_window = PhotomApp( photom_assembly=photom_assembly, - photom_sensor_size=CAMERA_SENSOR_YX, - photom_window_size=ctrl_window_width, + photom_sensor_size_yx=CAMERA_SENSOR_YX, + photom_window_size_x=ctrl_window_width, photom_window_pos=(100, 100), arduino=arduino, ) diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index 161df893..29f3ee2d 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -161,10 +161,11 @@ def calibrate_w_camera( total=len(img_sequence), desc='Finding peak coordinates', ): + # NOTE: typically the images are returned as (y,x) coords peak_coords[idx] = ia.find_objects_centroids( img, sigma=5, threshold_rel=1.0, min_distance=30, max_num_peaks=1 ) - + peak_coords_xy = peak_coords[:, [1, 0]] timestamp = time.strftime("%Y%m%d_%H%M%S") if save_calib_stack_path is not None: print('Saving calibration stack') @@ -184,6 +185,7 @@ def calibrate_w_camera( if save_calib_stack_path is None: save_calib_stack_path = Path.cwd() # Plot the centroids with the MIP of the image sequence + ia.plot_centroids( img_sequence, peak_coords, @@ -195,16 +197,16 @@ def calibrate_w_camera( ## Save the points grid_points = np.array(grid_points) - peak_coords = np.array(peak_coords) + peak_coords_xy = np.array(peak_coords_xy) # save the array of grid points and peak coordinates np.savez( save_calib_stack_path / f'calibration_points_{timestamp}.npz', grid_points=grid_points, - peak_coords=peak_coords, + peak_coords_xy=peak_coords_xy, ) # Find the affine transform - T_affine = self.mirror[mirror_index].affine_transform_obj.get_affine_matrix( - peak_coords, grid_points + T_affine = self.mirror[mirror_index].affine_transform_obj.compute_affine_matrix( + peak_coords_xy, grid_points ) print(f"Affine matrix: {T_affine}") diff --git a/copylot/assemblies/photom/utils/affine_transform.py b/copylot/assemblies/photom/utils/affine_transform.py index e0cab455..eb2bf972 100644 --- a/copylot/assemblies/photom/utils/affine_transform.py +++ b/copylot/assemblies/photom/utils/affine_transform.py @@ -60,7 +60,7 @@ def reset_T_affine(self): """ self.T_affine = np.eye(3) - def get_affine_matrix(self, origin, dest): + def compute_affine_matrix(self, origin, dest): """ Compute affine matrix from 2 origin & 2 destination coordinates. :param origin: 3 sets of coordinate of origin e.g. [(x1, y1), (x2, y2), (x3, y3)] @@ -76,6 +76,17 @@ def get_affine_matrix(self, origin, dest): ) return self.T_affine + def get_affine_matrix(self): + """ + Get the current affine matrix. + :return: affine matrix + """ + return self.T_affine + + def set_affine_matrix(self, matrix: np.array): + assert matrix.shape == (3, 3) + self.T_affine = matrix + def apply_affine(self, coord_list: list) -> list: """ Perform affine transformation. diff --git a/copylot/assemblies/photom/utils/scanning_algorithms.py b/copylot/assemblies/photom/utils/scanning_algorithms.py index 47adaf3b..a62eb175 100644 --- a/copylot/assemblies/photom/utils/scanning_algorithms.py +++ b/copylot/assemblies/photom/utils/scanning_algorithms.py @@ -212,7 +212,7 @@ def calculate_rectangle_corners(window_size: tuple[int, int], center=[0.0, 0.0]) return [x0y0, x1y0, x1y1, x0y1] -def generate_grid_points(rectangle_size: ArrayLike, n_points: int = 5) -> np.ndarray: +def generate_grid_points(rectangle_size: ArrayLike, n_points: int = 5,flip_xy = False) -> np.ndarray: """ Generate grid points for a given rectangle. From 091b674fdb3faf2e66beb2497fa89d278db380cb Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Fri, 1 Mar 2024 17:31:48 -0800 Subject: [PATCH 45/60] removing flir unnecessary id that is nto implemented --- copylot/hardware/cameras/flir/flir_camera.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/copylot/hardware/cameras/flir/flir_camera.py b/copylot/hardware/cameras/flir/flir_camera.py index 46edf6e0..ddd6b487 100644 --- a/copylot/hardware/cameras/flir/flir_camera.py +++ b/copylot/hardware/cameras/flir/flir_camera.py @@ -137,7 +137,7 @@ def close(self): # set back to default self.system = None self.cam_list = None - self.device_id = None + # self._device_id = None self.nodemap_tldevice = None def list_available_cameras(self): @@ -545,7 +545,7 @@ def shutter_mode(self, mode='global'): logger.error( 'Mode input: ', mode, ' is not valid. Enter global or rolling mode' ) - + @property def flip_sensor_X(self): return self.cam.ReverseX.GetValue() @@ -584,11 +584,11 @@ def pixel_format(self, format_str): if not PySpin.IsWritable(node_pixel_format): logger.error("Pixel Format node is not writable") raise FlirCameraException("Pixel Format node is not writable") - + new_format = node_pixel_format.GetEntryByName(format_str) if new_format is None or not PySpin.IsReadable(new_format): logger.error(f"Pixel format '{format_str}' is not supported") raise FlirCameraException(f"Pixel format '{format_str}' is not supported") - + node_pixel_format.SetIntValue(new_format.GetValue()) - logger.info(f"Pixel format set to {format_str}") \ No newline at end of file + logger.info(f"Pixel format set to {format_str}") From 3ab2dac7b52aaa840364caa27855468c4a5be847 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Fri, 1 Mar 2024 18:20:08 -0800 Subject: [PATCH 46/60] adding dynamic resizing of the window and update to the affine transform --- .../photom/demo/photom_calibration.py | 81 ++++++++++++++++--- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 692eb564..4d669a06 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -19,6 +19,7 @@ QGraphicsEllipseItem, QStackedWidget, QComboBox, + QSpinBox, QFileDialog, QLineEdit, QGridLayout, @@ -414,6 +415,7 @@ def __init__( self._current_mirror_idx = 0 self._laser_window_transparency = 0.7 self.scaling_matrix = np.eye(3) + self.T_mirror_cam_matrix = np.eye(3) self.calibration_thread = CalibrationThread( self.photom_assembly, self._current_mirror_idx ) @@ -421,8 +423,8 @@ def __init__( if DEMO_MODE: self.demo_window = demo_window - self.initialize_UI() self.initializer_laser_marker_window() + self.initialize_UI() def initializer_laser_marker_window(self): # Making the photom_window a square and display right besides the control UI @@ -449,8 +451,6 @@ def initializer_laser_marker_window(self): self.scaling_matrix = np.array( [[self.scaling_factor_x, 0, 1], [0, self.scaling_factor_y, 1], [0, 0, 1]] ) - print(f'scaling factor x: {self.scaling_factor_x}') - print(f'scaling factor y: {self.scaling_factor_y}') self.photom_window.windowClosed.connect( self.closeAllWindows ) # Connect the signal to slot @@ -484,6 +484,18 @@ def initialize_UI(self): # Add a QLabel to display the current percent transparency value self.transparency_label = QLabel(f"Transparency: 100%") transparency_layout.addWidget(self.transparency_label) + + # Resize QSpinBox + self.resize_spinbox = QSpinBox() + self.resize_spinbox.setRange(50, 200) # Set range from 50% to 200% + self.resize_spinbox.setSuffix("%") # Add a percentage sign as suffix + self.resize_spinbox.setValue(100) # Default value is 100% + self.resize_spinbox.valueChanged.connect(self.resize_laser_marker_window) + self.resize_spinbox.editingFinished.connect(self.resize_laser_marker_window) + transparency_layout.addWidget(QLabel("Resize Window:")) + transparency_layout.addWidget(self.resize_spinbox) + + # Set the transparency group layout transparency_group.setLayout(transparency_layout) # Adding a group box for the lasers @@ -560,6 +572,53 @@ def initialize_UI(self): self.setCentralWidget(main_widget) self.show() + def resize_laser_marker_window(self): + # Retrieve the selected resize percentage from the QSpinBox + percentage = self.resize_spinbox.value() / 100.0 + + # Calculate the new width based on the selected percentage + new_width = int(self.photom_window_size_x * percentage) + # Calculate the new height to maintain the aspect ratio + new_height = int(new_width / self.photom_window.aspect_ratio) + + # Resize the LaserMarkerWindow + self.photom_window.setFixedSize(new_width, new_height) + self.photom_window.shooting_scene.setSceneRect(0, 0, new_width, new_height) + self.photom_window.calibration_scene.setSceneRect(0, 0, new_width, new_height) + # Resize and reposition the rectangle within the scene + # Assuming you have a reference to the rectangle as self.photom_window.myRectangle + rect_width = new_width * 2 / 3 # Example: 2/3 of the new width + rect_height = new_height * 2 / 3 # Example: 2/3 of the new height + rect_x = (new_width - rect_width) / 2 + rect_y = (new_height - rect_height) / 2 + + self.photom_window.dashed_rectangle.setRect( + rect_x, rect_y, rect_width, rect_height + ) + pen = QPen( + QColor(0, 0, 0), 2, Qt.DashLine + ) # Example: black color, width 2, dashed line + self.photom_window.dashed_rectangle.setPen(pen) + + # Re-center the "X" marker + marker_center_x = new_width / 2 + marker_center_y = new_height / 2 + # self.photom_window.marker.setPos(marker_center_x, marker_center_y) + self.photom_window.display_marker_center( + self.photom_window.marker, (marker_center_x, marker_center_y) + ) + + # Update the scaling transform matrix + self.scaling_factor_x = self.photom_sensor_size_yx[1] / new_width + self.scaling_factor_y = self.photom_sensor_size_yx[0] / new_height + self.scaling_matrix = np.array( + [[self.scaling_factor_x, 0, 1], [0, self.scaling_factor_y, 1], [0, 0, 1]] + ) + T_compose_mat = self.T_mirror_cam_matrix @ self.scaling_matrix + self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.set_affine_matrix(T_compose_mat) + def mirror_dropdown_changed(self, index): print(f"Mirror dropdown changed to index {index}") self._current_mirror_idx = index @@ -619,11 +678,11 @@ def load_calibration(self): f'Loaded matrix:{self.photom_assembly.mirror[self._current_mirror_idx].affine_transform_obj.T_affine}' ) # Scale the matrix calculated from calibration to match the photom laser window - T_mirror_cam_matrix = self.photom_assembly.mirror[ + self.T_mirror_cam_matrix = self.photom_assembly.mirror[ self._current_mirror_idx ].affine_transform_obj.get_affine_matrix() - T_compose_mat = T_mirror_cam_matrix @ self.scaling_matrix - T_mirror_cam_matrix = self.photom_assembly.mirror[ + T_compose_mat = self.T_mirror_cam_matrix @ self.scaling_matrix + self.photom_assembly.mirror[ self._current_mirror_idx ].affine_transform_obj.set_affine_matrix(T_compose_mat) print("Scaled matrix:", T_compose_mat) @@ -892,11 +951,13 @@ def initMarker(self): pen.setWidth(2) # Set the pen width # Create the rectangle with no fill (transparent) - rect_item = QGraphicsRectItem(rect_x, rect_y, rect_width, rect_height) - rect_item.setPen(pen) - rect_item.setBrush(QBrush(Qt.transparent)) # Transparent fill + self.dashed_rectangle = QGraphicsRectItem( + rect_x, rect_y, rect_width, rect_height + ) + self.dashed_rectangle.setPen(pen) + self.dashed_rectangle.setBrush(QBrush(Qt.transparent)) # Transparent fill # Add the rectangle to the scene - self.shooting_scene.addItem(rect_item) + self.shooting_scene.addItem(self.dashed_rectangle) # Add the view to the QStackedWidget self.stacked_widget.addWidget(self.shooting_view) From 87b59a424667cc42988660c24a4b270196421978 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Mon, 4 Mar 2024 11:47:30 -0800 Subject: [PATCH 47/60] - bug on affine_transform returning T_affine - plot_centroid for qtthreads - calibration through gui using camera --- .../photom/demo/photom_calibration.py | 196 +++++++++++------- copylot/assemblies/photom/photom.py | 62 ++---- .../photom/utils/affine_transform.py | 2 +- .../assemblies/photom/utils/image_analysis.py | 9 +- 4 files changed, 140 insertions(+), 129 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 4d669a06..2831e5bb 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -26,7 +26,8 @@ QProgressBar, QGraphicsRectItem, ) -from PyQt5.QtGui import QColor, QPen, QFont, QFontMetricsF, QMouseEvent, QBrush +from PyQt5.QtGui import QColor, QPen, QFont, QFontMetricsF, QMouseEvent, QBrush, QPixmap +from pathlib import Path from copylot.assemblies.photom.utils.scanning_algorithms import ( calculate_rectangle_corners, ) @@ -36,14 +37,14 @@ from typing import Any, Tuple import time +from copylot.hardware.cameras.flir.flir_camera import FlirCamera + # DEMO_MODE = True DEMO_MODE = False -# TODO fix the right click releaser # TODO: deal with the logic when clicking calibrate. Mirror dropdown -# TODO: update the mock laser and mirror -# TODO: replace the entry boxes tos et the laser powers -# TODO: resizeable laser marker window with aspect ratio of the camera sensor +# TODO: if camera is in use. unload it before calibrating +# TODO:make sure to update the affine_mat after calibration with the dimensions of laserwindow class LaserWidget(QWidget): @@ -419,7 +420,11 @@ def __init__( self.calibration_thread = CalibrationThread( self.photom_assembly, self._current_mirror_idx ) - + self.calibration_w_cam_thread = CalibrationWithCameraThread( + self.photom_assembly, self._current_mirror_idx + ) + self.calibration_w_cam_thread.finished.connect(self.done_calibration) + self.imageWindows = [] if DEMO_MODE: self.demo_window = demo_window @@ -548,19 +553,13 @@ def initialize_UI(self): main_layout.addWidget(self.recenter_marker_button) self.calibrate_button = QPushButton("Calibrate") - self.calibrate_button.clicked.connect(self.calibrate) + self.calibrate_button.clicked.connect(self.calibrate_w_camera) main_layout.addWidget(self.calibrate_button) self.load_calibration_button = QPushButton("Load Calibration") self.load_calibration_button.clicked.connect(self.load_calibration) main_layout.addWidget(self.load_calibration_button) - # Add a "Done Calibration" button (initially hidden) - self.done_calibration_button = QPushButton("Done Calibration") - self.done_calibration_button.clicked.connect(self.done_calibration) - self.done_calibration_button.hide() - main_layout.addWidget(self.done_calibration_button) - # Add a "Cancel Calibration" button (initially hidden) self.cancel_calibration_button = QPushButton("Cancel Calibration") self.cancel_calibration_button.clicked.connect(self.cancel_calibration) @@ -632,33 +631,33 @@ def recenter_marker(self): (self.photom_window.canvas_width / 2, self.photom_window.canvas_height / 2), ) - def calibrate(self): - # Implement your calibration function here - print("Calibrating...") + def calibrate_w_camera(self): + print("Calibrating with camera...") # Hide the calibrate button self.calibrate_button.hide() self.load_calibration_button.hide() # Show the "Cancel Calibration" button self.cancel_calibration_button.show() - # Display the rectangle - self.display_rectangle() - # Show the "Done Calibration" button - self.done_calibration_button.show() - # Get the mirror idx - selected_mirror_name = self.mirror_dropdown.currentText() - self._current_mirror_idx = next( - i - for i, mirror in enumerate(self.mirrors) - if mirror.name == selected_mirror_name - ) if DEMO_MODE: print(f'Calibrating mirror: {self._current_mirror_idx}') else: - self.photom_assembly._calibrating = True - self.calibration_thread.start() + # TODO: Hardcoding the camera and coordinates of mirror for calib. Change this after + self.setup_calibration() self.photom_assembly.mirror[ self._current_mirror_idx ].affine_transform_obj.reset_T_affine() + self.calibration_w_cam_thread.start() + + # TODO: these parameters are currently hardcoded + def setup_calibration(self): + # Open the camera and add it to the assembly + cam = FlirCamera() + cam.open() + self.photom_assembly.camera = [cam] + + self.photom_assembly.laser[0].power = 0.0 + self.photom_assembly.laser[0].toggle_emission = True + self.photom_assembly.laser[0].power = 30.0 def load_calibration(self): self.photom_assembly._calibrating = False @@ -690,12 +689,8 @@ def load_calibration(self): self.photom_window.marker.show() def cancel_calibration(self): - self.photom_assembly._calibrating = False - # Implement your cancel calibration function here print("Canceling calibration...") - # Hide the "Done Calibration" button - self.done_calibration_button.hide() # Show the "Calibrate" button self.calibrate_button.show() self.load_calibration_button.show() @@ -706,35 +701,21 @@ def cancel_calibration(self): # Switch back to the shooting scene self.photom_window.switch_to_shooting_scene() - def done_calibration(self): - self.photom_assembly._calibrating = False - # TODO: Logic to return to some position + def display_saved_plot(self, plot_path): + # This function assumes that a QApplication instance is already running + image_window = ImageWindow(plot_path) + image_window.show() + self.imageWindows.append(image_window) + # return image_window - ## Perform any necessary actions after calibration is done - # Get the mirror (target) positions - self.target_pts = self.photom_window.get_coordinates() + def done_calibration(self, T_affine, plot_save_path): + # Unload the camera + self.photom_assembly.camera[0].close() + self.photom_assembly.camera = [] - # Mirror calibration size - mirror_calib_size = self.photom_assembly._calibration_rectangle_boundaries - origin = np.array( - [[pt.x(), pt.y()] for pt in self.target_pts], - dtype=np.float32, - ) - # TODO make the dest points from the mirror calibration size - mirror_x = mirror_calib_size[0] / 2 - mirror_y = mirror_calib_size[1] / 2 - dest = np.array( - [ - [-mirror_x, -mirror_y], - [mirror_x, -mirror_y], - [mirror_x, mirror_y], - [-mirror_x, mirror_y], - ] - ) - T_affine = self.photom_assembly.mirror[ - self._current_mirror_idx - ].affine_transform_obj.compute_affine_matrix(origin, dest) - print(f"Affine matrix: {T_affine}") + # Show plot and update matrix + self.display_saved_plot(plot_save_path) + self.T_mirror_cam_matrix = T_affine # Save the affine matrix to a file typed_filename, _ = QFileDialog.getSaveFileName( @@ -748,35 +729,43 @@ def done_calibration(self): self.photom_assembly.mirror[ self._current_mirror_idx ].affine_transform_obj.save_matrix( - matrix=T_affine, config_file=typed_filename + matrix=self.T_mirror_cam_matrix, config_file=typed_filename ) self.photom_window.switch_to_shooting_scene() self.photom_window.marker.show() # Hide the "Done Calibration" button - self.done_calibration_button.hide() self.calibrate_button.show() self.cancel_calibration_button.hide() + # Update the affine to match the photom laser window + self.update_laser_window_affine() + if DEMO_MODE: - print(f'origin: {origin}') - print(f'dest: {dest}') - # transformed_coords = self.affine_trans_obj.apply_affine(dest) - transformed_coords = self.photom_assembly.mirror[ - self._current_mirror_idx - ].affine_transform_obj.apply_affine(dest) - print(transformed_coords) - coords_list = self.photom_assembly.mirror[ - self._current_mirror_idx - ].affine_transform_obj.trans_pointwise(transformed_coords) - print(coords_list) - self.demo_window.updateVertices(coords_list) - return + NotImplementedError("Demo Mode: Calibration not implemented yet.") else: - print("No file selected") + print("No file selected. Skiping Saving the calibration matrix.") # Show dialog box saying no file selected print("Calibration done") + def update_laser_window_affine(self): + # Update the scaling transform matrix + print('updating laser window affine') + self.scaling_factor_x = ( + self.photom_sensor_size_yx[1] / self.photom_window_size_x + ) + self.scaling_factor_y = ( + self.photom_sensor_size_yx[0] / self.photom_window_size_x + ) + self.scaling_matrix = np.array( + [[self.scaling_factor_x, 0, 1], [0, self.scaling_factor_y, 1], [0, 0, 1]] + ) + T_compose_mat = self.T_mirror_cam_matrix @ self.scaling_matrix + self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.set_affine_matrix(T_compose_mat) + print(f'Updated affine matrix: {T_compose_mat}') + def update_transparency(self, value): transparency_percent = value self.transparency_label.setText(f"Transparency: {transparency_percent}%") @@ -796,6 +785,37 @@ def closeAllWindows(self): QApplication.quit() # Quit the application +class ImageWindow(QMainWindow): + def __init__(self, image_path, parent=None): + super().__init__(parent) + self.setWindowTitle("Calibration Overlay of laser grid and MIP points") + + # Create a label and set its pixmap to the image at image_path + self.label = QLabel(self) + self.pixmap = QPixmap(image_path) + + # Resize the label to fit the pixmap + self.label.setPixmap(self.pixmap) + self.label.resize(self.pixmap.width(), self.pixmap.height()) + + # Resize the window to fit the label (plus a little margin if desired) + self.resize( + self.pixmap.width() + 20, self.pixmap.height() + 20 + ) # Adding a 20-pixel margin + + # Optionally, center the window on the screen + self.center() + + def center(self): + frameGm = self.frameGeometry() + screen = QApplication.desktop().screenNumber( + QApplication.desktop().cursor().pos() + ) + centerPoint = QApplication.desktop().screenGeometry(screen).center() + frameGm.moveCenter(centerPoint) + self.move(frameGm.topLeft()) + + class PWMWorker(QThread): finished = pyqtSignal() progress = pyqtSignal(int) # Signal to report progress @@ -840,7 +860,29 @@ def run(self): rectangle_size_xy=self.photom_assembly._calibration_rectangle_size_xy, center=[0.000, 0.000], ) - self.finished.emit() + + +class CalibrationWithCameraThread(QThread): + finished = pyqtSignal(np.ndarray, str) + + def __init__(self, photom_assembly, current_mirror_idx): + super().__init__() + self.photom_assembly = photom_assembly + self.current_mirror_idx = current_mirror_idx + + def run(self): + # TODO: hardcoding the camera for now + mirror_roi = [[-0.01, 0.0], [0.015, 0.018]] # [x,y] + T_mirror_cam_matrix, plot_save_path = self.photom_assembly.calibrate_w_camera( + mirror_index=self.current_mirror_idx, + camera_index=0, + rectangle_boundaries=mirror_roi, + grid_n_points=5, + # config_file="calib_config.yml", + save_calib_stack_path=r"C:\Users\ZebraPhysics\Documents\tmp\test_calib", + verbose=True, + ) + self.finished.emit(T_mirror_cam_matrix, str(plot_save_path)) class LaserMarkerWindow(QMainWindow): diff --git a/copylot/assemblies/photom/photom.py b/copylot/assemblies/photom/photom.py index 29f3ee2d..97b41a6f 100644 --- a/copylot/assemblies/photom/photom.py +++ b/copylot/assemblies/photom/photom.py @@ -1,6 +1,7 @@ from re import T from matplotlib import pyplot as plt +from vispy import config from copylot.hardware.cameras.abstract_camera import AbstractCamera from copylot.hardware.mirrors.abstract_mirror import AbstractMirror from copylot.hardware.lasers.abstract_laser import AbstractLaser @@ -40,21 +41,16 @@ def __init__( self.mirror = mirror self.DAC = dac self.affine_matrix_path = affine_matrix_path - self._calibrating = False # TODO: These are hardcoded values. Unsure if they should come from a config file - self._calibration_rectangle_boundaries = None self.init_mirrors() def init_mirrors(self): assert len(self.mirror) == len(self.affine_matrix_path) - - self._calibration_rectangle_boundaries = np.zeros((len(self.mirror), 2, 2)) # Apply AffineTransform to each mirror for i in range(len(self.mirror)): self.mirror[i].affine_transform_obj = AffineTransform( config_file=self.affine_matrix_path[i] ) - # self._calibration_rectangle_boundaries[i] = [[, ], [, ]] # TODO probably will replace the camera with zyx or yx image array input ## Camera Functions @@ -62,12 +58,6 @@ def capture(self, camera_index: int, exposure_time: float) -> list: pass ## Mirror Functions - def stop_mirror(self, mirror_index: int): - if mirror_index < len(self.mirror): - self._calibrating = False - else: - raise IndexError("Mirror index out of range.") - def get_position(self, mirror_index: int) -> list[float]: if mirror_index < len(self.mirror): if self.DAC is not None: @@ -98,40 +88,17 @@ def set_position(self, mirror_index: int, position: list[float]): else: raise IndexError("Mirror index out of range.") - def calibrate( - self, mirror_index: int, rectangle_size_xy: tuple[int, int], center=[0.0, 0.0] - ): - if mirror_index < len(self.mirror): - print("Calibrating mirror...") - rectangle_coords = calculate_rectangle_corners(rectangle_size_xy, center) - # offset the rectangle coords by the center - # iterate over each corner and move the mirror - i = 0 - while self._calibrating: - # Logic for calibrating the mirror - self.set_position(mirror_index, rectangle_coords[i]) - time.sleep(1) - i += 1 - if i == 4: - i = 0 - time.sleep(1) - # moving the mirror in a rectangle - else: - raise IndexError("Mirror index out of range.") - def calibrate_w_camera( self, mirror_index: int, camera_index: int, rectangle_boundaries: Tuple[Tuple[int, int], Tuple[int, int]], grid_n_points: int = 5, - config_file: Path = './affine_matrix.yml', + config_file: Path = None, save_calib_stack_path: Path = None, verbose: bool = False, - ): + ) -> np.ndarray: assert self.camera is not None - assert config_file.endswith('.yml') or config_file.endswith('.yaml') - # self._calibration_rectangle_boundaries[mirror_index] = rectangle_boundaries x_min, x_max, y_min, y_max = self.camera[camera_index].image_size_limits # assuming the minimum is always zero, which is typically that case @@ -184,15 +151,11 @@ def calibrate_w_camera( if verbose: if save_calib_stack_path is None: save_calib_stack_path = Path.cwd() - # Plot the centroids with the MIP of the image sequence + + plot_save_path = save_calib_stack_path / f'calibration_plot_{timestamp}.png' ia.plot_centroids( - img_sequence, - peak_coords, - mip=True, - save_path=save_calib_stack_path / f'calibration_plot_{timestamp}.png' - if save_calib_stack_path is not None - else './calibration_plot.png', + img_sequence, peak_coords, mip=True, save_path=plot_save_path ) ## Save the points @@ -210,10 +173,15 @@ def calibrate_w_camera( ) print(f"Affine matrix: {T_affine}") - # Save the matrix - self.mirror[mirror_index].affine_transform_obj.save_matrix( - matrix=T_affine, config_file=config_file - ) + # Save if path is provided + if config_file is not None: + config_file = Path(config_file) + assert config_file.endswith('.yml') or config_file.endswith('.yaml') + # Save the matrix + self.mirror[mirror_index].affine_transform_obj.save_matrix( + matrix=T_affine, config_file=config_file + ) + return T_affine, plot_save_path ## LASER Fuctions def get_laser_power(self, laser_index: int) -> float: diff --git a/copylot/assemblies/photom/utils/affine_transform.py b/copylot/assemblies/photom/utils/affine_transform.py index eb2bf972..9bf3f9d7 100644 --- a/copylot/assemblies/photom/utils/affine_transform.py +++ b/copylot/assemblies/photom/utils/affine_transform.py @@ -73,7 +73,7 @@ def compute_affine_matrix(self, origin, dest): raise ValueError("dest needs 3 coordinates.") self.T_affine = transform.estimate_transform( "affine", np.float32(origin), np.float32(dest) - ) + ).params return self.T_affine def get_affine_matrix(self): diff --git a/copylot/assemblies/photom/utils/image_analysis.py b/copylot/assemblies/photom/utils/image_analysis.py index cef0707f..0c8314b9 100644 --- a/copylot/assemblies/photom/utils/image_analysis.py +++ b/copylot/assemblies/photom/utils/image_analysis.py @@ -38,6 +38,7 @@ def find_objects_centroids( return coordinates +# TODO:this function does not support multithread in qt as it is spawned as subchild process. def plot_centroids(image_sequence, centroids, mip=True, save_path=None): """ Plot the centroids of objects on top of the image sequence. @@ -49,7 +50,7 @@ def plot_centroids(image_sequence, centroids, mip=True, save_path=None): import matplotlib.pyplot as plt if mip: - plt.figure() + fig = plt.figure() plt.imshow(np.max(image_sequence, axis=0), cmap="gray") for i, centroid in enumerate(centroids): plt.scatter(centroid[1], centroid[0], color="red") @@ -57,14 +58,14 @@ def plot_centroids(image_sequence, centroids, mip=True, save_path=None): if save_path is not None: plt.savefig(save_path) - plt.show() - + # plt.show() else: for frame, frame_centroids in zip(image_sequence, centroids): plt.figure() plt.imshow(frame, cmap="gray") plt.scatter(frame_centroids[1], frame_centroids[0], color="red") - plt.show() + # plt.show() + plt.close(fig) def calculate_centroids(image_sequence, sigma=5, threshold_rel=0.5, min_distance=10): From 371c4daedeed0bcf7da3bd58bf5bf0e6bca421d4 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Mon, 4 Mar 2024 12:03:35 -0800 Subject: [PATCH 48/60] automatically set laser to pulse mode if using arduino --- .../photom/demo/photom_calibration.py | 86 ++++++++----------- 1 file changed, 36 insertions(+), 50 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 2831e5bb..dc9bffc6 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -229,13 +229,14 @@ def check_mirror_limits(self): class ArduinoPWMWidget(QWidget): - def __init__(self, photom_assembly, arduino_pwm): + def __init__(self, photom_assembly, arduino_pwm, parent): super().__init__() + self.parent = parent self.arduino_pwm = arduino_pwm self.photom_assembly = photom_assembly # default values self.duty_cycle = 50 # [%] (0-100) - self.time_period_ms = 10 # [ms] + self.time_period_ms = 100 # [ms] self.frequency = 1000.0 / self.time_period_ms # [Hz] self.duration = 5000 # [ms] self.repetitions = 1 # By default it runs once @@ -243,6 +244,8 @@ def __init__(self, photom_assembly, arduino_pwm): self.command = f"U,{self.duty_cycle},{self.frequency},{self.duration}" + self._curr_laser_idx = 0 + self.initialize_UI() def initialize_UI(self): @@ -254,6 +257,7 @@ def initialize_UI(self): self.laser_dropdown.addItem(laser.name) layout.addWidget(QLabel("Select Laser:"), 0, 0) layout.addWidget(self.laser_dropdown, 0, 1) + self.laser_dropdown.setCurrentIndex(self._curr_laser_idx) self.laser_dropdown.currentIndexChanged.connect(self.current_laser_changed) # Duty Cycle @@ -357,9 +361,17 @@ def update_command(self): self.arduino_pwm.send_command(self.command) def start_pwm(self): + if not self.parent.laser_widgets[self._curr_laser_idx]._curr_laser_pulse_mode: + print('Setup laser as pulse mode...') + self.parent.laser_widgets[self._curr_laser_idx].laser_pulse_mode() + print("Starting PWM...") self.pwm_worker = PWMWorker( - self.arduino_pwm, 'S', self.repetitions, self.time_interval_s, self.duration + self.arduino_pwm, + 'S', + self.repetitions, + self.time_interval_s, + self.duration, ) # Rest Progress Bar self.progressBar.setValue(0) @@ -530,7 +542,7 @@ def initialize_UI(self): arduino_layout = QVBoxLayout() self.arduino_pwm_widgets = [] for arduino in self.arduino_pwm: - arduino_pwm_widget = ArduinoPWMWidget(self.photom_assembly, arduino) + arduino_pwm_widget = ArduinoPWMWidget(self.photom_assembly, arduino, self) self.arduino_pwm_widgets.append(arduino_pwm_widget) arduino_layout.addWidget(arduino_pwm_widget) arduino_group.setLayout(arduino_layout) @@ -923,10 +935,6 @@ def __init__( self.switch_to_shooting_scene() - # Flags for mouse tracking - # NOTE: these are variables inherited from the photom_controls - self.calibration_mode = self.photom_controls.photom_assembly._calibrating - # show the window self.show() @@ -1076,10 +1084,9 @@ def update_vertices(self, new_coordinates): def eventFilter(self, source, event): "The mouse movements do not work without this function" - self.calibration_mode = self.photom_controls.photom_assembly._calibrating if event.type() == QMouseEvent.MouseMove: pass - if self._left_click_hold and not self.calibration_mode: + if self._left_click_hold: # Move the mirror around if the left button is clicked self._move_marker_and_update_sliders() # Debugging statements @@ -1093,47 +1100,26 @@ def eventFilter(self, source, event): # print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') elif event.type() == QMouseEvent.MouseButtonPress: print('mouse button pressed') - if self.calibration_mode: - print('calibration mode') - if event.buttons() == Qt.LeftButton: - self._left_click_hold = True - print('left button pressed') - # print(f'x: {event.posF().x()}, y: {event.posF().y()}') - print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') - elif event.buttons() == Qt.RightButton: - self._right_click_hold = True - print('right button pressed') - else: - print('shooting mode') - if event.buttons() == Qt.LeftButton: - self._left_click_hold = True - print('left button pressed') - print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') - self._move_marker_and_update_sliders() - elif event.buttons() == Qt.RightButton: - self._right_click_hold = True - self.photom_controls.photom_assembly.laser[0].toggle_emission = True - print('right button pressed') + print('shooting mode') + if event.buttons() == Qt.LeftButton: + self._left_click_hold = True + print('left button pressed') + print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') + self._move_marker_and_update_sliders() + elif event.buttons() == Qt.RightButton: + self._right_click_hold = True + self.photom_controls.photom_assembly.laser[0].toggle_emission = True + print('right button pressed') elif event.type() == QMouseEvent.MouseButtonRelease: - if self.calibration_mode: - if event.button() == Qt.LeftButton: - self._left_click_hold = False - print('left button released') - elif event.button() == Qt.RightButton: - self._right_click_hold = False - print('right button released') - else: - print('mouse button released') - if event.button() == Qt.LeftButton: - print('left button released') - self._left_click_hold = False - elif event.button() == Qt.RightButton: - self._right_click_hold = False - self.photom_controls.photom_assembly.laser[ - 0 - ].toggle_emission = False - time.sleep(0.5) - print('right button released') + print('mouse button released') + if event.button() == Qt.LeftButton: + print('left button released') + self._left_click_hold = False + elif event.button() == Qt.RightButton: + self._right_click_hold = False + self.photom_controls.photom_assembly.laser[0].toggle_emission = False + time.sleep(0.5) + print('right button released') return super(LaserMarkerWindow, self).eventFilter(source, event) From 2b75d83053fdb69d5c88b0cfab88cd3d1f062dfe Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Mon, 4 Mar 2024 12:20:59 -0800 Subject: [PATCH 49/60] -fix marker recentering --- .../photom/demo/photom_calibration.py | 100 ++++++++++++------ 1 file changed, 70 insertions(+), 30 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index dc9bffc6..b5e1b46f 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -592,32 +592,33 @@ def resize_laser_marker_window(self): # Calculate the new height to maintain the aspect ratio new_height = int(new_width / self.photom_window.aspect_ratio) - # Resize the LaserMarkerWindow - self.photom_window.setFixedSize(new_width, new_height) - self.photom_window.shooting_scene.setSceneRect(0, 0, new_width, new_height) - self.photom_window.calibration_scene.setSceneRect(0, 0, new_width, new_height) - # Resize and reposition the rectangle within the scene - # Assuming you have a reference to the rectangle as self.photom_window.myRectangle - rect_width = new_width * 2 / 3 # Example: 2/3 of the new width - rect_height = new_height * 2 / 3 # Example: 2/3 of the new height - rect_x = (new_width - rect_width) / 2 - rect_y = (new_height - rect_height) / 2 - - self.photom_window.dashed_rectangle.setRect( - rect_x, rect_y, rect_width, rect_height - ) - pen = QPen( - QColor(0, 0, 0), 2, Qt.DashLine - ) # Example: black color, width 2, dashed line - self.photom_window.dashed_rectangle.setPen(pen) - - # Re-center the "X" marker - marker_center_x = new_width / 2 - marker_center_y = new_height / 2 - # self.photom_window.marker.setPos(marker_center_x, marker_center_y) - self.photom_window.display_marker_center( - self.photom_window.marker, (marker_center_x, marker_center_y) - ) + self.photom_window.update_window_geometry(new_width, new_height) + # # Resize the LaserMarkerWindow + # self.photom_window.setFixedSize(new_width, new_height) + # self.photom_window.shooting_scene.setSceneRect(0, 0, new_width, new_height) + # self.photom_window.calibration_scene.setSceneRect(0, 0, new_width, new_height) + # # Resize and reposition the rectangle within the scene + # # Assuming you have a reference to the rectangle as self.photom_window.myRectangle + # rect_width = new_width * 2 / 3 # Example: 2/3 of the new width + # rect_height = new_height * 2 / 3 # Example: 2/3 of the new height + # rect_x = (new_width - rect_width) / 2 + # rect_y = (new_height - rect_height) / 2 + + # self.photom_window.dashed_rectangle.setRect( + # rect_x, rect_y, rect_width, rect_height + # ) + # pen = QPen( + # QColor(0, 0, 0), 2, Qt.DashLine + # ) # Example: black color, width 2, dashed line + # self.photom_window.dashed_rectangle.setPen(pen) + + # # Re-center the "X" marker + # marker_center_x = new_width / 2 + # marker_center_y = new_height / 2 + # # self.photom_window.marker.setPos(marker_center_x, marker_center_y) + # self.photom_window.display_marker_center( + # self.photom_window.marker, (marker_center_x, marker_center_y) + # ) # Update the scaling transform matrix self.scaling_factor_x = self.photom_sensor_size_yx[1] / new_width @@ -638,10 +639,7 @@ def mirror_dropdown_changed(self, index): self.photom_assembly.mirror[self._current_mirror_idx].position = [0, 0] def recenter_marker(self): - self.photom_window.display_marker_center( - self.photom_window.marker, - (self.photom_window.canvas_width / 2, self.photom_window.canvas_height / 2), - ) + self.photom_window.recenter_marker() def calibrate_w_camera(self): print("Calibrating with camera...") @@ -959,6 +957,48 @@ def initialize_UI(self): print(f'sidebar size: {self.sidebar_size}, topbar size: {self.topbar_size}') print(f'canvas width: {self.canvas_width}, canvas height: {self.canvas_height}') + def update_window_geometry(self, new_width, new_height): + self.setFixedSize(new_width, new_height) + self.shooting_scene.setSceneRect(0, 0, new_width, new_height) + self.calibration_scene.setSceneRect(0, 0, new_width, new_height) + + rect_width = new_width * 2 / 3 # Example: 2/3 of the new width + rect_height = new_height * 2 / 3 # Example: 2/3 of the new height + rect_x = (new_width - rect_width) / 2 + rect_y = (new_height - rect_height) / 2 + + # resize the rectangle + self.dashed_rectangle.setRect(rect_x, rect_y, rect_width, rect_height) + pen = QPen( + QColor(0, 0, 0), 2, Qt.DashLine + ) # Example: black color, width 2, dashed line + self.dashed_rectangle.setPen(pen) + + # Re-center the "X" marker + marker_center_x = new_width / 2 + marker_center_y = new_height / 2 + + self.window_geometry = ( + self.frameGeometry().x(), + self.frameGeometry().y(), + self.frameGeometry().width(), + self.frameGeometry().height(), + ) + # self.sidebar_size = self.frameGeometry().width() - self.window_geometry[2] + # self.topbar_size = self.frameGeometry().height() - self.window_geometry[3] + # self.canvas_width = self.frameGeometry().width() - self.sidebar_size + # self.canvas_height = self.frameGeometry().height() - self.topbar_size + self.canvas_width = new_width + self.canvas_height = new_height + + self.recenter_marker() + + def recenter_marker(self): + self.display_marker_center( + self.marker, + (self.canvas_width / 2, self.canvas_height / 2), + ) + def initMarker(self): # Generate the shooting scene self.shooting_scene = QGraphicsScene(self) From 165aece6fd5ff0d2ccf307c496381f47228bb202 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Mon, 4 Mar 2024 14:40:39 -0800 Subject: [PATCH 50/60] fixing marker recentering issue --- .../photom/demo/photom_calibration.py | 174 ++++++++---------- 1 file changed, 76 insertions(+), 98 deletions(-) diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index b5e1b46f..75fb25dd 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -26,7 +26,16 @@ QProgressBar, QGraphicsRectItem, ) -from PyQt5.QtGui import QColor, QPen, QFont, QFontMetricsF, QMouseEvent, QBrush, QPixmap +from PyQt5.QtGui import ( + QColor, + QPen, + QFont, + QFontMetricsF, + QMouseEvent, + QBrush, + QPixmap, + QResizeEvent, +) from pathlib import Path from copylot.assemblies.photom.utils.scanning_algorithms import ( calculate_rectangle_corners, @@ -593,34 +602,8 @@ def resize_laser_marker_window(self): new_height = int(new_width / self.photom_window.aspect_ratio) self.photom_window.update_window_geometry(new_width, new_height) - # # Resize the LaserMarkerWindow - # self.photom_window.setFixedSize(new_width, new_height) - # self.photom_window.shooting_scene.setSceneRect(0, 0, new_width, new_height) - # self.photom_window.calibration_scene.setSceneRect(0, 0, new_width, new_height) - # # Resize and reposition the rectangle within the scene - # # Assuming you have a reference to the rectangle as self.photom_window.myRectangle - # rect_width = new_width * 2 / 3 # Example: 2/3 of the new width - # rect_height = new_height * 2 / 3 # Example: 2/3 of the new height - # rect_x = (new_width - rect_width) / 2 - # rect_y = (new_height - rect_height) / 2 - - # self.photom_window.dashed_rectangle.setRect( - # rect_x, rect_y, rect_width, rect_height - # ) - # pen = QPen( - # QColor(0, 0, 0), 2, Qt.DashLine - # ) # Example: black color, width 2, dashed line - # self.photom_window.dashed_rectangle.setPen(pen) - - # # Re-center the "X" marker - # marker_center_x = new_width / 2 - # marker_center_y = new_height / 2 - # # self.photom_window.marker.setPos(marker_center_x, marker_center_y) - # self.photom_window.display_marker_center( - # self.photom_window.marker, (marker_center_x, marker_center_y) - # ) - # Update the scaling transform matrix + # Update the scaling transform matrix based on window size self.scaling_factor_x = self.photom_sensor_size_yx[1] / new_width self.scaling_factor_y = self.photom_sensor_size_yx[0] / new_height self.scaling_matrix = np.array( @@ -912,7 +895,8 @@ def __init__( self.aspect_ratio = sensor_size_yx[1] / sensor_size_yx[0] self.fixed_width = fixed_width calculated_height = int(self.fixed_width / self.aspect_ratio) - self.window_geometry = window_pos + (self.fixed_width, calculated_height) + self.window_pos = window_pos + self.window_geometry = self.window_pos + (self.fixed_width, calculated_height) self.setMouseTracking(True) self.setWindowOpacity(self.photom_controls._laser_window_transparency) @@ -924,8 +908,8 @@ def __init__( self.initMarker() tetragon_coords = calculate_rectangle_corners( - [self.canvas_width / 5, self.canvas_height / 5], - center=[self.canvas_width / 2, self.canvas_height / 2], + [self.window_geometry[-2] / 5, self.window_geometry[-1] / 5], + center=[self.window_geometry[-2] / 2, self.window_geometry[-1] / 2], ) self.init_tetragon(tetragon_coords=tetragon_coords) @@ -949,92 +933,66 @@ def initialize_UI(self): self.window_geometry[2], self.window_geometry[3], ) - self.sidebar_size = self.frameGeometry().width() - self.window_geometry[2] - self.topbar_size = self.frameGeometry().height() - self.window_geometry[3] - self.canvas_width = self.frameGeometry().width() - self.sidebar_size - self.canvas_height = self.frameGeometry().height() - self.topbar_size - - print(f'sidebar size: {self.sidebar_size}, topbar size: {self.topbar_size}') - print(f'canvas width: {self.canvas_width}, canvas height: {self.canvas_height}') - - def update_window_geometry(self, new_width, new_height): - self.setFixedSize(new_width, new_height) - self.shooting_scene.setSceneRect(0, 0, new_width, new_height) - self.calibration_scene.setSceneRect(0, 0, new_width, new_height) - - rect_width = new_width * 2 / 3 # Example: 2/3 of the new width - rect_height = new_height * 2 / 3 # Example: 2/3 of the new height - rect_x = (new_width - rect_width) / 2 - rect_y = (new_height - rect_height) / 2 - - # resize the rectangle - self.dashed_rectangle.setRect(rect_x, rect_y, rect_width, rect_height) - pen = QPen( - QColor(0, 0, 0), 2, Qt.DashLine - ) # Example: black color, width 2, dashed line - self.dashed_rectangle.setPen(pen) - - # Re-center the "X" marker - marker_center_x = new_width / 2 - marker_center_y = new_height / 2 - - self.window_geometry = ( - self.frameGeometry().x(), - self.frameGeometry().y(), - self.frameGeometry().width(), - self.frameGeometry().height(), - ) # self.sidebar_size = self.frameGeometry().width() - self.window_geometry[2] # self.topbar_size = self.frameGeometry().height() - self.window_geometry[3] # self.canvas_width = self.frameGeometry().width() - self.sidebar_size # self.canvas_height = self.frameGeometry().height() - self.topbar_size - self.canvas_width = new_width - self.canvas_height = new_height - self.recenter_marker() + # print(f'sidebar size: {self.sidebar_size}, topbar size: {self.topbar_size}') + # print(f'canvas width: {self.canvas_width}, canvas height: {self.canvas_height}') + + def update_window_geometry(self, new_width, new_height): + self.window_geometry = self.window_pos + (new_width, new_height) + self.shooting_view.setFixedSize(new_width, new_height) + self.shooting_scene.setSceneRect(0, 0, new_width, new_height) + self.setFixedSize(new_width, new_height) def recenter_marker(self): self.display_marker_center( self.marker, - (self.canvas_width / 2, self.canvas_height / 2), + (self.window_geometry[-2] / 2, self.window_geometry[-1] / 2), ) def initMarker(self): # Generate the shooting scene self.shooting_scene = QGraphicsScene(self) - self.shooting_scene.setSceneRect(0, 0, self.canvas_width, self.canvas_height) + self.shooting_scene.setSceneRect( + 0, 0, self.window_geometry[-2], self.window_geometry[-1] + ) # Generate the shooting view self.shooting_view = QGraphicsView(self.shooting_scene) self.shooting_view.setMouseTracking(True) self.setCentralWidget(self.shooting_view) self.shooting_view.setAlignment(Qt.AlignTop | Qt.AlignLeft) - self.shooting_view.setFixedSize(self.canvas_width, self.canvas_height) + self.shooting_view.setFixedSize( + self.window_geometry[-2], self.window_geometry[-1] + ) # Mouse tracking self.shooting_view.installEventFilter(self) self.setMouseTracking(True) - self.marker = QGraphicsSimpleTextItem("X") + self.marker = QGraphicsSimpleTextItem("+") self.marker.setBrush(QColor(255, 0, 0)) self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) self.shooting_view.viewport().installEventFilter(self) # Set larger font size font = self.marker.font() - font.setPointSize(10) + font.setPointSize(30) self.marker.setFont(font) # Position the marker self.display_marker_center( - self.marker, (self.canvas_width / 2, self.canvas_height / 2) + self.marker, (self.window_geometry[-2] / 2, self.window_geometry[-1] / 2) ) self.shooting_scene.addItem(self.marker) ## Add the rectangle - rect_width = 2 * self.canvas_width / 3 - rect_height = 2 * self.canvas_height / 3 - rect_x = (self.canvas_width - rect_width) / 2 - rect_y = (self.canvas_height - rect_height) / 2 + rect_width = 2 * self.window_geometry[-2] / 3 + rect_height = 2 * self.window_geometry[-1] / 3 + rect_x = (self.window_geometry[-2] - rect_width) / 2 + rect_y = (self.window_geometry[-1] - rect_height) / 2 # Continue from the previous code in initMarker... pen = QPen(QColor(0, 0, 0)) pen.setStyle(Qt.DashLine) # Dashed line style @@ -1061,7 +1019,9 @@ def init_tetragon( ): # Generate the calibration scene self.calibration_scene = QGraphicsScene(self) - self.calibration_scene.setSceneRect(0, 0, self.canvas_width, self.canvas_height) + self.calibration_scene.setSceneRect( + 0, 0, self.window_geometry[-2], self.window_geometry[-1] + ) # Generate the calibration view self.calibration_view = QGraphicsView(self.calibration_scene) @@ -1163,6 +1123,34 @@ def eventFilter(self, source, event): return super(LaserMarkerWindow, self).eventFilter(source, event) + # Triggered after manual resizing or calling window.setGeometry() + def resizeEvent(self, a0: QResizeEvent | None) -> None: + super().resizeEvent(a0) + rect = self.shooting_view.sceneRect() + self.shooting_scene.setSceneRect(0, 0, rect.width(), rect.height()) + print(f'resize event: {rect.width()}, {rect.height()}') + self._update_scene_items(rect.width(), rect.height()) + + def _update_scene_items(self, new_width, new_height): + # Dahsed rectangle + rect_width = new_width * 2 / 3 # Example: 2/3 of the new width + rect_height = new_height * 2 / 3 # Example: 2/3 of the new height + rect_x = (new_width - rect_width) / 2 + rect_y = (new_height - rect_height) / 2 + + # Re-center the "X" marker + marker_center_x = new_width / 2 + marker_center_y = new_height / 2 + self.recenter_marker() + + # resize the rectangle + self.dashed_rectangle.setRect(rect_x, rect_y, rect_width, rect_height) + pen = QPen( + QColor(0, 0, 0), 2, Qt.DashLine + ) # Example: black color, width 2, dashed line + self.dashed_rectangle.setPen(pen) + self.shooting_view.update() + def _move_marker_and_update_sliders(self): # Update the mirror slider values if self.photom_controls is not None: @@ -1177,28 +1165,18 @@ def _move_marker_and_update_sliders(self): self.photom_controls._current_mirror_idx ].mirror_y_slider.setValue(new_coords[1][0]) - def get_marker_center(self, marker): - fm = QFontMetricsF(QFont()) - boundingRect = fm.tightBoundingRect(marker.text()) - mergintop = fm.ascent() + boundingRect.top() - x = marker.pos().x() + boundingRect.left() + boundingRect.width() / 2 - y = marker.pos().y() + mergintop + boundingRect.height() / 2 - return x, y - def display_marker_center(self, marker, coords=None): if coords is None: coords = (marker.x(), marker.y()) - - if coords is None: - coords = (marker.x(), marker.y()) - fm = QFontMetricsF(QFont()) + # Obtain font metrics and bounding rectangle + fm = QFontMetricsF(marker.font()) boundingRect = fm.tightBoundingRect(marker.text()) - margintop = fm.ascent() + boundingRect.top() - marker.setPos( - coords[0] - boundingRect.left() - boundingRect.width() / 2, - coords[1] - margintop - boundingRect.height() / 2, - ) - return marker + + # Adjust position based on the bounding rectangle + adjustedX = coords[0] - boundingRect.width() / 2 + adjustedY = coords[1] - boundingRect.height() / 2 + + marker.setPos(adjustedX, adjustedY) def closeEvent(self, event): self.windowClosed.emit() # Emit the signal when the window is about to close From 70587456091dcf1d6a47b3b43bf0c9ff1b38fb40 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Mon, 4 Mar 2024 22:53:44 -0800 Subject: [PATCH 51/60] splittting gui into file tree. --- .../photom/demo/photom_calibration.py | 2 +- copylot/assemblies/photom/gui/photom_gui.py | 122 +++ copylot/assemblies/photom/gui/utils.py | 94 +++ copylot/assemblies/photom/gui/widgets.py | 385 +++++++++ copylot/assemblies/photom/gui/windows.py | 754 ++++++++++++++++++ 5 files changed, 1356 insertions(+), 1 deletion(-) create mode 100644 copylot/assemblies/photom/gui/photom_gui.py create mode 100644 copylot/assemblies/photom/gui/utils.py create mode 100644 copylot/assemblies/photom/gui/widgets.py create mode 100644 copylot/assemblies/photom/gui/windows.py diff --git a/copylot/assemblies/photom/demo/photom_calibration.py b/copylot/assemblies/photom/demo/photom_calibration.py index 75fb25dd..694f6406 100644 --- a/copylot/assemblies/photom/demo/photom_calibration.py +++ b/copylot/assemblies/photom/demo/photom_calibration.py @@ -40,7 +40,7 @@ from copylot.assemblies.photom.utils.scanning_algorithms import ( calculate_rectangle_corners, ) -from copylot.assemblies.photom.utils.qt_utils import DoubleSlider +from copylot.assemblies.photom.gui.utils import DoubleSlider import numpy as np from copylot.assemblies.photom.photom import PhotomAssembly from typing import Any, Tuple diff --git a/copylot/assemblies/photom/gui/photom_gui.py b/copylot/assemblies/photom/gui/photom_gui.py new file mode 100644 index 00000000..f7845df5 --- /dev/null +++ b/copylot/assemblies/photom/gui/photom_gui.py @@ -0,0 +1,122 @@ +import sys +import yaml + +from copylot.assemblies.photom.utils.scanning_algorithms import ( + calculate_rectangle_corners, +) +from copylot.assemblies.photom.photom import PhotomAssembly + +from copylot.assemblies.photom.gui.windows import LaserMarkerWindow, PhotomApp +from PyQt5.QtWidgets import QApplication + + +def load_config(config: str): + assert config.endswith(".yml"), "Config file must be a .yml file" + # TODO: this should be a function that parses the config_file and returns the photom_assembly + # Load the config file and parse it + with open(config_path, "r") as config_file: + config = yaml.load(config_file, Loader=yaml.FullLoader) + return config + + +def make_photom_assembly(config): + lasers = [ + Laser( + name=laser_data["name"], + port=laser_data["COM_port"], + ) + for laser_data in config["lasers"] + ] + mirrors = [ + Mirror( + name=mirror_data["name"], + com_port=mirror_data["COM_port"], + pos_x=mirror_data["x_position"], + pos_y=mirror_data["y_position"], + ) + for mirror_data in config["mirrors"] + ] # Initial mirror position + affine_matrix_paths = [mirror['affine_matrix_path'] for mirror in config['mirrors']] + + # Check that the number of mirrors and affine matrices match + assert len(mirrors) == len(affine_matrix_paths) + + # Load photom assembly + photom_assembly = PhotomAssembly( + laser=lasers, mirror=mirrors, affine_matrix_path=affine_matrix_paths + ) + return photom_assembly + + +if __name__ == "__main__": + DEMO_MODE = False + # TODO: grab the actual value if the camera is connected to photom_assmebly + CAMERA_SENSOR_YX = (2048, 2448) + + if DEMO_MODE: + from copylot.assemblies.photom.photom_mock_devices import ( + MockLaser, + MockMirror, + MockArduinoPWM, + ) + + Laser = MockLaser + Mirror = MockMirror + ArduinoPWM = MockArduinoPWM + else: + from copylot.hardware.mirrors.optotune.mirror import OptoMirror as Mirror + from copylot.hardware.lasers.vortran.vortran import VortranLaser as Laser + from copylot.assemblies.photom.utils.arduino import ArduinoPWM as ArduinoPWM + + config_path = r"./copylot/assemblies/photom/demo/photom_VIS_config.yml" + config = load_config(config_path) + photom_assembly = make_photom_assembly(config) + + # QT APP + app = QApplication(sys.argv) + # Define the positions and sizes for the windows + screen_width = app.desktop().screenGeometry().width() + screen_height = app.desktop().screenGeometry().height() + ctrl_window_width = screen_width // 3 # Adjust the width as needed + ctrl_window_height = screen_height // 3 # Use the full screen height + + arduino = [ArduinoPWM(serial_port='COM10', baud_rate=115200)] + + if DEMO_MODE: + camera_window = LaserMarkerWindow( + name="Mock laser dots", + sensor_size_yx=(2048, 2048), + window_pos=(100, 100), + fixed_width=ctrl_window_width, + ) # Set the positions of the windows + ctrl_window = PhotomApp( + photom_assembly=photom_assembly, + photom_sensor_size_yx=CAMERA_SENSOR_YX, + photom_window_size_x=ctrl_window_width, + photom_window_pos=(100, 100), + demo_window=camera_window, + arduino=arduino, + ) + # Set the camera window to the calibration scene + camera_window.switch_to_calibration_scene() + rectangle_scaling = 0.2 + window_size = (camera_window.width(), camera_window.height()) + rectangle_size = ( + (window_size[0] * rectangle_scaling), + (window_size[1] * rectangle_scaling), + ) + rectangle_coords = calculate_rectangle_corners(rectangle_size) + # translate each coordinate by the offset + rectangle_coords = [(x + 30, y) for x, y in rectangle_coords] + camera_window.update_vertices(rectangle_coords) + else: + # Set the positions of the windows + ctrl_window = PhotomApp( + photom_assembly=photom_assembly, + photom_sensor_size_yx=CAMERA_SENSOR_YX, + photom_window_size_x=ctrl_window_width, + photom_window_pos=(100, 100), + arduino=arduino, + ) + + sys.exit(app.exec_()) diff --git a/copylot/assemblies/photom/gui/utils.py b/copylot/assemblies/photom/gui/utils.py new file mode 100644 index 00000000..ac475eee --- /dev/null +++ b/copylot/assemblies/photom/gui/utils.py @@ -0,0 +1,94 @@ +from PyQt5.QtCore import Qt, QThread, pyqtSignal +import time +import numpy as np + + +class PWMWorker(QThread): + finished = pyqtSignal() + progress = pyqtSignal(int) # Signal to report progress + _stop_requested = False + + def request_stop(self): + self._stop_requested = True + + def __init__(self, arduino_pwm, command, repetitions, time_interval_s, duration): + super().__init__() + self.arduino_pwm = arduino_pwm + self.command = command + self.repetitions = repetitions + self.time_interval_s = time_interval_s + self.duration = duration + + def run(self): + # Simulate sending the command and waiting (replace with actual logic) + for i in range(self.repetitions): + if self._stop_requested: + break + self.arduino_pwm.send_command(self.command) + # TODO: replace when using a better microcontroller since we dont get signals back rn + time.sleep(self.duration / 1000) + self.progress.emit(int((i + 1) / self.repetitions * 100)) + time.sleep(self.time_interval_s) # Simulate time interval + + self.finished.emit() + + +class CalibrationWithCameraThread(QThread): + finished = pyqtSignal(np.ndarray, str) + + def __init__(self, photom_assembly, current_mirror_idx): + super().__init__() + self.photom_assembly = photom_assembly + self.current_mirror_idx = current_mirror_idx + + def run(self): + # TODO: hardcoding the camera for now + mirror_roi = [[-0.01, 0.0], [0.015, 0.018]] # [x,y] + T_mirror_cam_matrix, plot_save_path = self.photom_assembly.calibrate_w_camera( + mirror_index=self.current_mirror_idx, + camera_index=0, + rectangle_boundaries=mirror_roi, + grid_n_points=5, + # config_file="calib_config.yml", + save_calib_stack_path=r"C:\Users\ZebraPhysics\Documents\tmp\test_calib", + verbose=True, + ) + self.finished.emit(T_mirror_cam_matrix, str(plot_save_path)) + + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QSlider, QWidget, QVBoxLayout, QDoubleSpinBox, QLabel +from PyQt5.QtCore import pyqtSignal + + +class DoubleSlider(QSlider): + # create our our signal that we can connect to if necessary + doubleValueChanged = pyqtSignal(float) + + def __init__(self, decimals=5, *args, **kargs): + super(DoubleSlider, self).__init__(*args, **kargs) + self._multi = 10**decimals + + self.valueChanged.connect(self.emitDoubleValueChanged) + + def emitDoubleValueChanged(self): + value = float(super(DoubleSlider, self).value()) / self._multi + self.doubleValueChanged.emit(value) + + def value(self): + return float(super(DoubleSlider, self).value()) / self._multi + + def setMinimum(self, value): + return super(DoubleSlider, self).setMinimum(int(value * self._multi)) + + def setMaximum(self, value): + return super(DoubleSlider, self).setMaximum(int(value * self._multi)) + + def setSingleStep(self, value): + return super(DoubleSlider, self).setSingleStep(value * self._multi) + + def singleStep(self): + return float(super(DoubleSlider, self).singleStep()) / self._multi + + def setValue(self, value): + super(DoubleSlider, self).setValue(int(value * self._multi)) diff --git a/copylot/assemblies/photom/gui/widgets.py b/copylot/assemblies/photom/gui/widgets.py new file mode 100644 index 00000000..55daadf3 --- /dev/null +++ b/copylot/assemblies/photom/gui/widgets.py @@ -0,0 +1,385 @@ +from PyQt5.QtWidgets import ( + QApplication, + QMainWindow, + QWidget, + QPushButton, + QLabel, + QSlider, + QVBoxLayout, + QGraphicsView, + QGroupBox, + QGraphicsScene, + QGraphicsSimpleTextItem, + QGraphicsItem, + QGraphicsEllipseItem, + QStackedWidget, + QComboBox, + QSpinBox, + QFileDialog, + QLineEdit, + QGridLayout, + QProgressBar, + QGraphicsRectItem, +) + +from copylot.assemblies.photom.utils.qt_utils import DoubleSlider +from PyQt5.QtCore import Qt, pyqtSignal, QThread +import numpy as np +import time +import yaml +from copylot.assemblies.photom.gui.utils import PWMWorker + + +class LaserWidget(QWidget): + def __init__(self, laser): + super().__init__() + self.laser = laser + + self.emission_state = 0 # 0 = off, 1 = on + self.emission_delay = 0 # 0 =off ,1= 5 sec delay + + self._curr_power = 0 + self._slider_decimal = 1 + self._curr_laser_pulse_mode = False + + self.initializer_laser() + self.initialize_UI() + + def initializer_laser(self): + # Set the power to 0 + self.laser.toggle_emission = 0 + self.laser.emission_delay = self.emission_delay + self.laser.pulse_power = self._curr_power + self.laser.power = self._curr_power + + # Make sure laser is in continuous mode + if self.laser.pulse_mode == 1: + self.laser.toggle_emission = 1 + time.sleep(0.2) + self.laser.pulse_mode = self._curr_laser_pulse_mode ^ 1 + time.sleep(0.2) + self.laser.toggle_emission = 0 + + def initialize_UI(self): + layout = QVBoxLayout() + + self.laser_label = QLabel(self.laser.name) + layout.addWidget(self.laser_label) + + self.laser_power_slider = DoubleSlider( + orientation=Qt.Horizontal, decimals=self._slider_decimal + ) + self.laser_power_slider.setMinimum(0) + self.laser_power_slider.setMaximum(100) + self.laser_power_slider.setValue(self.laser.power) + self.laser_power_slider.valueChanged.connect(self.update_power) + layout.addWidget(self.laser_power_slider) + + # Add a QLabel to display the power value + self.power_edit = QLineEdit(f"{self._curr_power:.2f}") # Changed to QLineEdit + self.power_edit.returnPressed.connect( + self.edit_power + ) # Connect the returnPressed signal + layout.addWidget(self.power_edit) + + # Set Pulse Mode Button + self.pulse_mode_button = QPushButton("Pulse Mode") + self.pulse_mode_button.clicked.connect(self.laser_pulse_mode) + layout.addWidget(self.pulse_mode_button) + self.pulse_mode_button.setStyleSheet("background-color: magenta") + + self.laser_toggle_button = QPushButton("Toggle") + self.laser_toggle_button.clicked.connect(self.toggle_laser) + # make it background red if laser is off + if self.emission_state == 0: + self.laser_toggle_button.setStyleSheet("background-color: magenta") + layout.addWidget(self.laser_toggle_button) + + self.setLayout(layout) + + def toggle_laser(self): + self.emission_state = self.emission_state ^ 1 + self.laser.toggle_emission = self.emission_state + if self.emission_state == 0: + self.laser_toggle_button.setStyleSheet("background-color: magenta") + else: + self.laser_toggle_button.setStyleSheet("background-color: green") + + def update_power(self, value): + self._curr_power = value / (10**self._slider_decimal) + if self._curr_laser_pulse_mode: + self.laser.pulse_power = self._curr_power + else: + self.laser.power = self._curr_power + + # Update the QLabel with the new power value + self.power_edit.setText(f"{self._curr_power:.2f}") + + def edit_power(self): + try: + # Extract the numerical value from the QLineEdit text + power_value_str = self.power_edit.text() + power_value = float(power_value_str) + + if ( + 0 <= power_value <= 100 + ): # Assuming the power range is 0 to 100 percentages + self._curr_power = power_value + self.laser.power = self._curr_power + self.laser_power_slider.setValue(self._curr_power) + self.power_edit.setText(f"{self._curr_power:.2f}") + else: + self.power_edit.setText(f"{self._curr_power:.2f}") + print(f"Power: {self._curr_power}") + except ValueError: + self.power_edit.setText(f"{self._curr_power:.2f}") + + def laser_pulse_mode(self): + self._curr_laser_pulse_mode = not self._curr_laser_pulse_mode + self.laser.toggle_emission = 1 + if self._curr_laser_pulse_mode: + self.pulse_mode_button.setStyleSheet("background-color: green") + self.laser.pulse_power = self._curr_power + self.laser.pulse_mode = 1 + time.sleep(0.2) + else: + self.laser.power = self._curr_power + self.laser.pulse_mode = 0 + self.pulse_mode_button.setStyleSheet("background-color: magenta") + time.sleep(0.2) + self.laser_toggle_button.setStyleSheet("background-color: magenta") + self.laser.toggle_emission = 0 + self.emission_state = 0 + + print(f'pulse mode bool: {self._curr_laser_pulse_mode}') + print(f'digital modulation = {self.laser.pulse_mode}') + + +# TODO: connect widget to actual abstract mirror calls +class MirrorWidget(QWidget): + def __init__(self, mirror): + super().__init__() + self.mirror = mirror + + self.check_mirror_limits() + self.initialize_UI() + + def initialize_UI(self): + layout = QVBoxLayout() + + mirror_x_label = QLabel("Mirror X Position") + layout.addWidget(mirror_x_label) + + self.mirror_x_slider = DoubleSlider(orientation=Qt.Horizontal) + self.mirror_x_slider.setMinimum(self.movement_limits_x[0]) + self.mirror_x_slider.setMaximum(self.movement_limits_x[1]) + self.mirror_x_slider.doubleValueChanged.connect(self.update_mirror_x) + layout.addWidget(self.mirror_x_slider) + + # Add a QLabel to display the mirror X value + self.mirror_x_label = QLabel(f"X: {self.mirror.position_x}") + layout.addWidget(self.mirror_x_label) + + mirror_y_label = QLabel("Mirror Y Position") + layout.addWidget(mirror_y_label) + + self.mirror_y_slider = DoubleSlider(orientation=Qt.Horizontal) + self.mirror_y_slider.setMinimum(self.movement_limits_y[0]) + self.mirror_y_slider.setMaximum(self.movement_limits_y[1]) + self.mirror_y_slider.doubleValueChanged.connect(self.update_mirror_y) + layout.addWidget(self.mirror_y_slider) + + # Add a QLabel to display the mirror Y value + self.mirror_y_label = QLabel(f"Y: {self.mirror.position_y}") + layout.addWidget(self.mirror_y_label) + + self.setLayout(layout) + + def update_mirror_x(self, value): + self.mirror.position_x = value + # Update the QLabel with the new X value + self.mirror_x_label.setText(f"X: {value}") + + def update_mirror_y(self, value): + self.mirror.position_y = value + # Update the QLabel with the new Y value + self.mirror_y_label.setText(f"Y: {value}") + + def check_mirror_limits(self): + movement_limits = self.mirror.movement_limits + self.movement_limits_x = movement_limits[0:2] + self.movement_limits_y = movement_limits[2:4] + + +class ArduinoPWMWidget(QWidget): + def __init__(self, photom_assembly, arduino_pwm, parent): + super().__init__() + self.parent = parent + self.arduino_pwm = arduino_pwm + self.photom_assembly = photom_assembly + # default values + self.duty_cycle = 50 # [%] (0-100) + self.time_period_ms = 100 # [ms] + self.frequency = 1000.0 / self.time_period_ms # [Hz] + self.duration = 5000 # [ms] + self.repetitions = 1 # By default it runs once + self.time_interval_s = 0 # [s] + + self.command = f"U,{self.duty_cycle},{self.frequency},{self.duration}" + + self._curr_laser_idx = 0 + + self.initialize_UI() + + def initialize_UI(self): + layout = QGridLayout() # Use QGridLayout + + # Laser Dropdown Menu + self.laser_dropdown = QComboBox() + for laser in self.photom_assembly.laser: + self.laser_dropdown.addItem(laser.name) + layout.addWidget(QLabel("Select Laser:"), 0, 0) + layout.addWidget(self.laser_dropdown, 0, 1) + self.laser_dropdown.setCurrentIndex(self._curr_laser_idx) + self.laser_dropdown.currentIndexChanged.connect(self.current_laser_changed) + + # Duty Cycle + layout.addWidget(QLabel("Duty Cycle [%]:"), 1, 0) + self.duty_cycle_edit = QLineEdit(f"{self.duty_cycle}") + self.duty_cycle_edit.returnPressed.connect(self.edit_duty_cycle) + layout.addWidget(self.duty_cycle_edit, 1, 1) + + # Time Period + layout.addWidget(QLabel("Time Period [ms]:"), 2, 0) + self.time_period_edit = QLineEdit(f"{self.time_period_ms}") + self.time_period_edit.returnPressed.connect(self.edit_time_period) + layout.addWidget(self.time_period_edit, 2, 1) + + # Duration + layout.addWidget(QLabel("Duration [ms]:"), 3, 0) + self.duration_edit = QLineEdit(f"{self.duration}") + self.duration_edit.returnPressed.connect(self.edit_duration) + layout.addWidget(self.duration_edit, 3, 1) + + # Repetitions + layout.addWidget(QLabel("Repetitions:"), 4, 0) + self.repetitions_edit = QLineEdit(f"{self.repetitions}") + self.repetitions_edit.textChanged.connect(self.edit_repetitions) + layout.addWidget(self.repetitions_edit, 4, 1) + + # Time interval + layout.addWidget(QLabel("Time interval [s]:"), 5, 0) + self.time_interval_edit = QLineEdit(f"{self.time_interval_s}") + self.time_interval_edit.textChanged.connect(self.edit_time_interval) + layout.addWidget(self.time_interval_edit, 5, 1) + + # Apply Button + self.apply_button = QPushButton("Apply") + self.apply_button.clicked.connect(self.apply_settings) + layout.addWidget(self.apply_button, 6, 0, 1, 2) + + # Start Button + self.start_button = QPushButton("Start PWM") + self.start_button.clicked.connect(self.start_pwm) + layout.addWidget(self.start_button, 7, 0, 1, 2) + + # Add Stop Button + self.stop_button = QPushButton("Stop PWM") + self.stop_button.clicked.connect(self.stop_pwm) + layout.addWidget(self.stop_button, 8, 0, 1, 2) # Adjust position as needed + + # Add Progress Bar + self.progressBar = QProgressBar(self) + self.progressBar.setMaximum(100) # Set the maximum value + layout.addWidget(self.progressBar, 9, 0, 1, 2) # Adjust position as needed + + self.setLayout(layout) + + def edit_duty_cycle(self): + try: + value = float(self.duty_cycle_edit.text()) + self.duty_cycle = value + self.update_command() + except ValueError: + self.duty_cycle_edit.setText(f"{self.duty_cycle}") + + def edit_time_period(self): + try: + value = float(self.time_period_edit.text()) + self.time_period_ms = value + self.frequency = 1000.0 / self.time_period_ms + self.update_command() + except ValueError: + self.time_period_edit.setText(f"{self.time_period}") + + def edit_duration(self): + try: + value = float(self.duration_edit.text()) + self.duration = value + self.update_command() + except ValueError: + self.duration_edit.setText(f"{self.duration}") + + def edit_repetitions(self): + try: + value = int(self.repetitions_edit.text()) + self.repetitions = value + except ValueError: + self.repetitions_edit.setText( + f"{self.repetitions}" + ) # Reset to last valid value + + def edit_time_interval(self): + try: + value = float(self.time_interval_edit.text()) + self.time_interval_s = value + except ValueError: + self.time_interval_edit.setText( + f"{self.time_interval_s}" + ) # Reset to last valid value + + def update_command(self): + self.command = f"U,{self.duty_cycle},{self.frequency},{self.duration}" + print(f"arduino out: {self.command}") + self.arduino_pwm.send_command(self.command) + + def start_pwm(self): + if not self.parent.laser_widgets[self._curr_laser_idx]._curr_laser_pulse_mode: + print('Setup laser as pulse mode...') + self.parent.laser_widgets[self._curr_laser_idx].laser_pulse_mode() + + print("Starting PWM...") + self.pwm_worker = PWMWorker( + self.arduino_pwm, + 'S', + self.repetitions, + self.time_interval_s, + self.duration, + ) + # Rest Progress Bar + self.progressBar.setValue(0) + self.pwm_worker.finished.connect(self.on_pwm_finished) + self.pwm_worker.progress.connect(self.update_progress_bar) + self.pwm_worker.start() + + def update_progress_bar(self, value): + self.progressBar.setValue(value) # Update the progress bar with the new value + + def stop_pwm(self): + if hasattr(self, 'pwm_worker') and self.pwm_worker.isRunning(): + self.pwm_worker.request_stop() + + def on_pwm_finished(self): + print("PWM operation completed.") + # self.progressBar.setValue(0) # Reset the progress bar + + def current_laser_changed(self, index): + self._curr_laser_idx = index + self.apply_settings() + + def apply_settings(self): + # Implement functionality to apply settings to the selected laser + self._curr_laser_idx = self.laser_dropdown.currentIndex() + # TODO: Need to modify the data struct for command for multiple lasers + if hasattr(self, 'command'): + self.arduino_pwm.send_command(self.command) diff --git a/copylot/assemblies/photom/gui/windows.py b/copylot/assemblies/photom/gui/windows.py new file mode 100644 index 00000000..92563cdf --- /dev/null +++ b/copylot/assemblies/photom/gui/windows.py @@ -0,0 +1,754 @@ +from PyQt5.QtWidgets import ( + QApplication, + QMainWindow, + QWidget, + QPushButton, + QLabel, + QSlider, + QVBoxLayout, + QGraphicsView, + QGroupBox, + QGraphicsScene, + QGraphicsSimpleTextItem, + QGraphicsItem, + QGraphicsEllipseItem, + QStackedWidget, + QComboBox, + QSpinBox, + QFileDialog, + QLineEdit, + QGridLayout, + QProgressBar, + QGraphicsRectItem, +) +from PyQt5.QtGui import ( + QColor, + QPen, + QFont, + QFontMetricsF, + QMouseEvent, + QBrush, + QPixmap, + QResizeEvent, +) +from PyQt5.QtCore import Qt, QThread, pyqtSignal + +from copylot.assemblies.photom.photom import PhotomAssembly +from copylot.assemblies.photom.gui.utils import CalibrationWithCameraThread + +from typing import Tuple +import numpy as np +from copylot.assemblies.photom.gui.widgets import ( + LaserWidget, + MirrorWidget, + ArduinoPWMWidget, +) + +from copylot.assemblies.photom.utils.scanning_algorithms import ( + calculate_rectangle_corners, +) + + +# TODO: this one is hardcoded for now +from copylot.hardware.cameras.flir.flir_camera import FlirCamera + + +class PhotomApp(QMainWindow): + def __init__( + self, + photom_assembly: PhotomAssembly, + photom_sensor_size_yx: Tuple[int, int] = (2048, 2448), + photom_window_size_x: int = 800, + photom_window_pos: Tuple[int, int] = (100, 100), + demo_window=None, + arduino=[], + ): + super().__init__() + # TODO:temporary for arduino. remove when we replace with dac + self.arduino_pwm = arduino + + self.photom_window = None + self.photom_controls_window = None + + self.photom_assembly = photom_assembly + self.lasers = self.photom_assembly.laser + self.mirrors = self.photom_assembly.mirror + self.photom_window_size_x = photom_window_size_x + self.photom_sensor_size_yx = photom_sensor_size_yx + self.photom_window_pos = photom_window_pos + self._current_mirror_idx = 0 + self._laser_window_transparency = 0.7 + self.scaling_matrix = np.eye(3) + self.T_mirror_cam_matrix = np.eye(3) + self.calibration_w_cam_thread = CalibrationWithCameraThread( + self.photom_assembly, self._current_mirror_idx + ) + self.calibration_w_cam_thread.finished.connect(self.done_calibration) + self.imageWindows = [] + + # if DEMO_MODE: + # self.demo_window = demo_window + + self.initializer_laser_marker_window() + self.initialize_UI() + + def initializer_laser_marker_window(self): + # Making the photom_window a square and display right besides the control UI + window_pos = ( + self.photom_window_size_x + self.photom_window_pos[0], + self.photom_window_pos[1], + ) + self.photom_window = LaserMarkerWindow( + photom_controls=self, + name='Laser Marker', + sensor_size_yx=self.photom_sensor_size_yx, + fixed_width=self.photom_window_size_x, + window_pos=window_pos, + ) + self.aspect_ratio = ( + self.photom_sensor_size_yx[1] / self.photom_sensor_size_yx[0] + ) + calculated_height = self.photom_window_size_x / self.aspect_ratio + self.scaling_factor_x = ( + self.photom_sensor_size_yx[1] / self.photom_window_size_x + ) + self.scaling_factor_y = self.photom_sensor_size_yx[0] / calculated_height + # TODO: the affine transforms are in XY coordinates. Need to change to YX + self.scaling_matrix = np.array( + [[self.scaling_factor_x, 0, 1], [0, self.scaling_factor_y, 1], [0, 0, 1]] + ) + self.photom_window.windowClosed.connect( + self.closeAllWindows + ) # Connect the signal to slot + + def initialize_UI(self): + """ + Initialize the UI. + + """ + self.setGeometry( + self.photom_window_pos[0], + self.photom_window_pos[1], + self.photom_window_size_x, + self.photom_window_size_x, + ) + self.setWindowTitle("Laser and Mirror Control App") + + # Adding slider to adjust transparency + transparency_group = QGroupBox("Photom Transparency") + transparency_layout = QVBoxLayout() + # Create a slider to adjust the transparency + self.transparency_slider = QSlider(Qt.Horizontal) + self.transparency_slider.setMinimum(0) + self.transparency_slider.setMaximum(100) + self.transparency_slider.setValue( + int(self._laser_window_transparency * 100) + ) # Initial value is fully opaque + self.transparency_slider.valueChanged.connect(self.update_transparency) + transparency_layout.addWidget(self.transparency_slider) + + # Add a QLabel to display the current percent transparency value + self.transparency_label = QLabel(f"Transparency: 100%") + transparency_layout.addWidget(self.transparency_label) + + # Resize QSpinBox + self.resize_spinbox = QSpinBox() + self.resize_spinbox.setRange(50, 200) # Set range from 50% to 200% + self.resize_spinbox.setSuffix("%") # Add a percentage sign as suffix + self.resize_spinbox.setValue(100) # Default value is 100% + self.resize_spinbox.valueChanged.connect(self.resize_laser_marker_window) + self.resize_spinbox.editingFinished.connect(self.resize_laser_marker_window) + transparency_layout.addWidget(QLabel("Resize Window:")) + transparency_layout.addWidget(self.resize_spinbox) + + # Set the transparency group layout + transparency_group.setLayout(transparency_layout) + + # Adding a group box for the lasers + laser_group = QGroupBox("Lasers") + laser_layout = QVBoxLayout() + self.laser_widgets = [] + for laser in self.lasers: + laser_widget = LaserWidget(laser) + self.laser_widgets.append(laser_widget) + laser_layout.addWidget(laser_widget) + laser_group.setLayout(laser_layout) + + # Adding a group box for the mirror + mirror_group = QGroupBox("Mirror") + mirror_layout = QVBoxLayout() + + self.mirror_widgets = [] + for idx, mirror in enumerate(self.mirrors): + mirror_widget = MirrorWidget(mirror) + self.mirror_widgets.append(mirror_widget) + mirror_layout.addWidget(mirror_widget) + mirror_group.setLayout(mirror_layout) + + # TODO remove if arduino is removed + # Adding group for arduino PWM + arduino_group = QGroupBox("Arduino PWM") + arduino_layout = QVBoxLayout() + self.arduino_pwm_widgets = [] + for arduino in self.arduino_pwm: + arduino_pwm_widget = ArduinoPWMWidget(self.photom_assembly, arduino, self) + self.arduino_pwm_widgets.append(arduino_pwm_widget) + arduino_layout.addWidget(arduino_pwm_widget) + arduino_group.setLayout(arduino_layout) + + # Add the laser and mirror group boxes to the main layout + main_layout = QVBoxLayout() + main_layout.addWidget(transparency_group) + main_layout.addWidget(laser_group) + main_layout.addWidget(mirror_group) + main_layout.addWidget(arduino_group) # TODO remove if arduino is removed + + self.mirror_dropdown = QComboBox() + self.mirror_dropdown.addItems([mirror.name for mirror in self.mirrors]) + main_layout.addWidget(self.mirror_dropdown) + self.mirror_dropdown.setCurrentIndex(self._current_mirror_idx) + self.mirror_dropdown.currentIndexChanged.connect(self.mirror_dropdown_changed) + + self.recenter_marker_button = QPushButton("Recenter Marker") + self.recenter_marker_button.clicked.connect(self.recenter_marker) + main_layout.addWidget(self.recenter_marker_button) + + self.calibrate_button = QPushButton("Calibrate") + self.calibrate_button.clicked.connect(self.calibrate_w_camera) + main_layout.addWidget(self.calibrate_button) + + self.load_calibration_button = QPushButton("Load Calibration") + self.load_calibration_button.clicked.connect(self.load_calibration) + main_layout.addWidget(self.load_calibration_button) + + # Add a "Cancel Calibration" button (initially hidden) + self.cancel_calibration_button = QPushButton("Cancel Calibration") + self.cancel_calibration_button.clicked.connect(self.cancel_calibration) + self.cancel_calibration_button.hide() + main_layout.addWidget(self.cancel_calibration_button) + main_widget = QWidget(self) + main_widget.setLayout(main_layout) + + self.setCentralWidget(main_widget) + self.show() + + def resize_laser_marker_window(self): + # Retrieve the selected resize percentage from the QSpinBox + percentage = self.resize_spinbox.value() / 100.0 + + # Calculate the new width based on the selected percentage + new_width = int(self.photom_window_size_x * percentage) + # Calculate the new height to maintain the aspect ratio + new_height = int(new_width / self.photom_window.aspect_ratio) + + self.photom_window.update_window_geometry(new_width, new_height) + + # Update the scaling transform matrix based on window size + self.scaling_factor_x = self.photom_sensor_size_yx[1] / new_width + self.scaling_factor_y = self.photom_sensor_size_yx[0] / new_height + self.scaling_matrix = np.array( + [[self.scaling_factor_x, 0, 1], [0, self.scaling_factor_y, 1], [0, 0, 1]] + ) + T_compose_mat = self.T_mirror_cam_matrix @ self.scaling_matrix + self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.set_affine_matrix(T_compose_mat) + + def mirror_dropdown_changed(self, index): + print(f"Mirror dropdown changed to index {index}") + self._current_mirror_idx = index + + # Reset to (0,0) position + self.photom_assembly.mirror[self._current_mirror_idx].position = [0, 0] + + def recenter_marker(self): + self.photom_window.recenter_marker() + + def calibrate_w_camera(self): + print("Calibrating with camera...") + # Hide the calibrate button + self.calibrate_button.hide() + self.load_calibration_button.hide() + # Show the "Cancel Calibration" button + self.cancel_calibration_button.show() + + # if DEMO_MODE: + # print(f'Calibrating mirror: {self._current_mirror_idx}') + # else: + # TODO: Hardcoding the camera and coordinates of mirror for calib. Change this after + self.setup_calibration() + self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.reset_T_affine() + self.calibration_w_cam_thread.start() + + # TODO: these parameters are currently hardcoded + def setup_calibration(self): + # Open the camera and add it to the assembly + cam = FlirCamera() + cam.open() + self.photom_assembly.camera = [cam] + + self.photom_assembly.laser[0].power = 0.0 + self.photom_assembly.laser[0].toggle_emission = True + self.photom_assembly.laser[0].power = 30.0 + + def load_calibration(self): + print("Loading calibration...") + # Prompt the user to select a file + typed_filename, _ = QFileDialog.getOpenFileName( + self, "Open Calibration File", "", "YAML Files (*.yml)" + ) + if typed_filename: + assert typed_filename.endswith(".yml") + print("Selected file:", typed_filename) + # Load the matrix + self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.load_matrix(config_file=typed_filename) + print( + f'Loaded matrix:{self.photom_assembly.mirror[self._current_mirror_idx].affine_transform_obj.T_affine}' + ) + # Scale the matrix calculated from calibration to match the photom laser window + self.T_mirror_cam_matrix = self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.get_affine_matrix() + T_compose_mat = self.T_mirror_cam_matrix @ self.scaling_matrix + self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.set_affine_matrix(T_compose_mat) + print("Scaled matrix:", T_compose_mat) + self.photom_window.switch_to_shooting_scene() + self.photom_window.marker.show() + + def cancel_calibration(self): + # Implement your cancel calibration function here + print("Canceling calibration...") + # Show the "Calibrate" button + self.calibrate_button.show() + self.load_calibration_button.show() + # Show the "X" marker in photom_window + self.photom_window.marker.show() + + self.cancel_calibration_button.hide() + # Switch back to the shooting scene + self.photom_window.switch_to_shooting_scene() + + def display_saved_plot(self, plot_path): + # This function assumes that a QApplication instance is already running + image_window = ImageWindow(plot_path) + image_window.show() + self.imageWindows.append(image_window) + # return image_window + + def done_calibration(self, T_affine, plot_save_path): + # Unload the camera + self.photom_assembly.camera[0].close() + self.photom_assembly.camera = [] + + # Show plot and update matrix + self.display_saved_plot(plot_save_path) + self.T_mirror_cam_matrix = T_affine + + # Save the affine matrix to a file + typed_filename, _ = QFileDialog.getSaveFileName( + self, "Save File", "", "YAML Files (*.yml)" + ) + if typed_filename: + if not typed_filename.endswith(".yml"): + typed_filename += ".yml" + print("Selected file:", typed_filename) + # Save the matrix + self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.save_matrix( + matrix=self.T_mirror_cam_matrix, config_file=typed_filename + ) + self.photom_window.switch_to_shooting_scene() + self.photom_window.marker.show() + + # Hide the "Done Calibration" button + self.calibrate_button.show() + self.cancel_calibration_button.hide() + + # Update the affine to match the photom laser window + self.update_laser_window_affine() + + # if DEMO_MODE: + # NotImplementedError("Demo Mode: Calibration not implemented yet.") + else: + print("No file selected. Skiping Saving the calibration matrix.") + # Show dialog box saying no file selected + print("Calibration done") + + def update_laser_window_affine(self): + # Update the scaling transform matrix + print('updating laser window affine') + self.scaling_factor_x = ( + self.photom_sensor_size_yx[1] / self.photom_window_size_x + ) + self.scaling_factor_y = ( + self.photom_sensor_size_yx[0] / self.photom_window_size_x + ) + self.scaling_matrix = np.array( + [[self.scaling_factor_x, 0, 1], [0, self.scaling_factor_y, 1], [0, 0, 1]] + ) + T_compose_mat = self.T_mirror_cam_matrix @ self.scaling_matrix + self.photom_assembly.mirror[ + self._current_mirror_idx + ].affine_transform_obj.set_affine_matrix(T_compose_mat) + print(f'Updated affine matrix: {T_compose_mat}') + + def update_transparency(self, value): + transparency_percent = value + self.transparency_label.setText(f"Transparency: {transparency_percent}%") + opacity = 1.0 - (transparency_percent / 100.0) # Calculate opacity (0.0 to 1.0) + self.photom_window.setWindowOpacity(opacity) # Update photom_window opacity + + def display_rectangle(self): + self.photom_window.switch_to_calibration_scene() + + def closeEvent(self, event): + self.closeAllWindows() # Ensure closing main window closes everything + super().closeEvent(event) + + def closeAllWindows(self): + self.photom_window.close() + self.close() + QApplication.quit() # Quit the application + + +class LaserMarkerWindow(QMainWindow): + windowClosed = pyqtSignal() # Define the signal + + def __init__( + self, + photom_controls: QMainWindow = None, + name="Laser Marker", + sensor_size_yx: Tuple = (2048, 2048), + window_pos: Tuple = (100, 100), + fixed_width: int = 800, + ): + super().__init__() + self.photom_controls = photom_controls + self.window_name = name + self.aspect_ratio = sensor_size_yx[1] / sensor_size_yx[0] + self.fixed_width = fixed_width + calculated_height = int(self.fixed_width / self.aspect_ratio) + self.window_pos = window_pos + self.window_geometry = self.window_pos + (self.fixed_width, calculated_height) + self.setMouseTracking(True) + self.setWindowOpacity(self.photom_controls._laser_window_transparency) + + # Create a QStackedWidget + # TODO: do we need the stacked widget? + self.stacked_widget = QStackedWidget() + # Set the QStackedWidget as the central widget + self.initialize_UI() + self.initMarker() + + tetragon_coords = calculate_rectangle_corners( + [self.window_geometry[-2] / 5, self.window_geometry[-1] / 5], + center=[self.window_geometry[-2] / 2, self.window_geometry[-1] / 2], + ) + self.init_tetragon(tetragon_coords=tetragon_coords) + + self.setCentralWidget(self.stacked_widget) + + self.switch_to_shooting_scene() + + # show the window + self.show() + + # FLAGS + self._right_click_hold = False + self._left_click_hold = False + + def initialize_UI(self): + print(f'window geometry: {self.window_geometry}') + self.setWindowTitle(self.window_name) + + # Fix the size of the window + self.setFixedSize( + self.window_geometry[2], + self.window_geometry[3], + ) + # self.sidebar_size = self.frameGeometry().width() - self.window_geometry[2] + # self.topbar_size = self.frameGeometry().height() - self.window_geometry[3] + # self.canvas_width = self.frameGeometry().width() - self.sidebar_size + # self.canvas_height = self.frameGeometry().height() - self.topbar_size + + # print(f'sidebar size: {self.sidebar_size}, topbar size: {self.topbar_size}') + # print(f'canvas width: {self.canvas_width}, canvas height: {self.canvas_height}') + + def update_window_geometry(self, new_width, new_height): + self.window_geometry = self.window_pos + (new_width, new_height) + self.shooting_view.setFixedSize(new_width, new_height) + self.shooting_scene.setSceneRect(0, 0, new_width, new_height) + self.setFixedSize(new_width, new_height) + + def recenter_marker(self): + self.display_marker_center( + self.marker, + (self.window_geometry[-2] / 2, self.window_geometry[-1] / 2), + ) + + def initMarker(self): + # Generate the shooting scene + self.shooting_scene = QGraphicsScene(self) + self.shooting_scene.setSceneRect( + 0, 0, self.window_geometry[-2], self.window_geometry[-1] + ) + + # Generate the shooting view + self.shooting_view = QGraphicsView(self.shooting_scene) + self.shooting_view.setMouseTracking(True) + self.setCentralWidget(self.shooting_view) + self.shooting_view.setAlignment(Qt.AlignTop | Qt.AlignLeft) + self.shooting_view.setFixedSize( + self.window_geometry[-2], self.window_geometry[-1] + ) + + # Mouse tracking + self.shooting_view.installEventFilter(self) + self.setMouseTracking(True) + self.marker = QGraphicsSimpleTextItem("+") + self.marker.setBrush(QColor(255, 0, 0)) + self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) + self.shooting_view.viewport().installEventFilter(self) + # Set larger font size + font = self.marker.font() + font.setPointSize(30) + self.marker.setFont(font) + + # Position the marker + self.display_marker_center( + self.marker, (self.window_geometry[-2] / 2, self.window_geometry[-1] / 2) + ) + + self.shooting_scene.addItem(self.marker) + + ## Add the rectangle + rect_width = 2 * self.window_geometry[-2] / 3 + rect_height = 2 * self.window_geometry[-1] / 3 + rect_x = (self.window_geometry[-2] - rect_width) / 2 + rect_y = (self.window_geometry[-1] - rect_height) / 2 + # Continue from the previous code in initMarker... + pen = QPen(QColor(0, 0, 0)) + pen.setStyle(Qt.DashLine) # Dashed line style + pen.setWidth(2) # Set the pen width + + # Create the rectangle with no fill (transparent) + self.dashed_rectangle = QGraphicsRectItem( + rect_x, rect_y, rect_width, rect_height + ) + self.dashed_rectangle.setPen(pen) + self.dashed_rectangle.setBrush(QBrush(Qt.transparent)) # Transparent fill + # Add the rectangle to the scene + self.shooting_scene.addItem(self.dashed_rectangle) + + # Add the view to the QStackedWidget + self.stacked_widget.addWidget(self.shooting_view) + + # Disable scrollbars + self.shooting_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.shooting_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + def init_tetragon( + self, tetragon_coords: list = [(100, 100), (200, 100), (200, 200), (100, 200)] + ): + # Generate the calibration scene + self.calibration_scene = QGraphicsScene(self) + self.calibration_scene.setSceneRect( + 0, 0, self.window_geometry[-2], self.window_geometry[-1] + ) + + # Generate the calibration view + self.calibration_view = QGraphicsView(self.calibration_scene) + self.calibration_view.setAlignment(Qt.AlignTop | Qt.AlignLeft) + + # Disable scrollbars + self.calibration_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.calibration_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + # Add the tetragon to the calibration scene + self.vertices = [] + for x, y in tetragon_coords: + vertex = QGraphicsEllipseItem(0, 0, 10, 10) + vertex.setBrush(Qt.red) + vertex.setFlag(QGraphicsEllipseItem.ItemIsMovable) + vertex.setPos(x, y) + self.vertices.append(vertex) + self.calibration_scene.addItem(vertex) + print(f"Vertex added at: ({x}, {y})") # Debugging statement + + print( + f"Scene Rect: {self.calibration_scene.sceneRect()}" + ) # Debugging statement + + # Mouse tracking + self.calibration_view.installEventFilter(self) + self.setMouseTracking(True) + + # Add the view to the QStackedWidget + self.stacked_widget.addWidget(self.calibration_view) + + def switch_to_shooting_scene(self): + self.stacked_widget.setCurrentWidget(self.shooting_view) + + def switch_to_calibration_scene(self): + self.stacked_widget.setCurrentWidget(self.calibration_view) + + def get_coordinates(self): + return [vertex.pos() for vertex in self.vertices] + + def create_tetragon(self, tetragon_coords): + # Add the tetragon to the calibration scene + self.vertices = [] + for x, y in tetragon_coords: + vertex = QGraphicsEllipseItem(x - 5, y - 5, 10, 10) + vertex.setBrush(Qt.red) + vertex.setFlag(QGraphicsEllipseItem.ItemIsMovable) + self.vertices.append(vertex) + vertex.setVisible(True) # Show the item + self.calibration_scene.addItem(vertex) + + def update_vertices(self, new_coordinates): + # Check if the lengths of vertices and new_coordinates match + if len(self.vertices) != len(new_coordinates): + print("Error: Mismatch in the number of vertices and new coordinates") + return + for vertex, (x, y) in zip(self.vertices, new_coordinates): + vertex.setPos(x, y) + print(f'vertex pos: {vertex.pos()}') + + def eventFilter(self, source, event): + "The mouse movements do not work without this function" + if event.type() == QMouseEvent.MouseMove: + pass + if self._left_click_hold: + # Move the mirror around if the left button is clicked + self._move_marker_and_update_sliders() + # Debugging statements + # print('mouse move') + # print(f'x1: {event.screenPos().x()}, y1: {event.screenPos().y()}') + # print(f'x: {event.posF().x()}, y: {event.posF().y()}') + # print(f'x: {event.localPosF().x()}, y: {event.localPosF().y()}') + # print(f'x: {event.windowPosF().x()}, y: {event.windowPosF().y()}') + # print(f'x: {event.screenPosF().x()}, y: {event.screenPosF().y()}') + # print(f'x: {event.globalPosF().x()}, y: {event.globalPosF().y()}') + # print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') + elif event.type() == QMouseEvent.MouseButtonPress: + print('mouse button pressed') + print('shooting mode') + if event.buttons() == Qt.LeftButton: + self._left_click_hold = True + print('left button pressed') + print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') + self._move_marker_and_update_sliders() + elif event.buttons() == Qt.RightButton: + self._right_click_hold = True + self.photom_controls.photom_assembly.laser[0].toggle_emission = True + print('right button pressed') + elif event.type() == QMouseEvent.MouseButtonRelease: + print('mouse button released') + if event.button() == Qt.LeftButton: + print('left button released') + self._left_click_hold = False + elif event.button() == Qt.RightButton: + self._right_click_hold = False + self.photom_controls.photom_assembly.laser[0].toggle_emission = False + time.sleep(0.5) + print('right button released') + + return super(LaserMarkerWindow, self).eventFilter(source, event) + + # Triggered after manual resizing or calling window.setGeometry() + def resizeEvent(self, a0: QResizeEvent | None) -> None: + super().resizeEvent(a0) + rect = self.shooting_view.sceneRect() + self.shooting_scene.setSceneRect(0, 0, rect.width(), rect.height()) + print(f'resize event: {rect.width()}, {rect.height()}') + self._update_scene_items(rect.width(), rect.height()) + + def _update_scene_items(self, new_width, new_height): + # Dahsed rectangle + rect_width = new_width * 2 / 3 # Example: 2/3 of the new width + rect_height = new_height * 2 / 3 # Example: 2/3 of the new height + rect_x = (new_width - rect_width) / 2 + rect_y = (new_height - rect_height) / 2 + + # Re-center the "X" marker + marker_center_x = new_width / 2 + marker_center_y = new_height / 2 + self.recenter_marker() + + # resize the rectangle + self.dashed_rectangle.setRect(rect_x, rect_y, rect_width, rect_height) + pen = QPen( + QColor(0, 0, 0), 2, Qt.DashLine + ) # Example: black color, width 2, dashed line + self.dashed_rectangle.setPen(pen) + self.shooting_view.update() + + def _move_marker_and_update_sliders(self): + # Update the mirror slider values + if self.photom_controls is not None: + marker_position = [self.marker.pos().x(), self.marker.pos().y()] + new_coords = self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror.affine_transform_obj.apply_affine(marker_position) + self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror_x_slider.setValue(new_coords[0][0]) + self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror_y_slider.setValue(new_coords[1][0]) + + def display_marker_center(self, marker, coords=None): + if coords is None: + coords = (marker.x(), marker.y()) + # Obtain font metrics and bounding rectangle + fm = QFontMetricsF(marker.font()) + boundingRect = fm.tightBoundingRect(marker.text()) + + # Adjust position based on the bounding rectangle + adjustedX = coords[0] - boundingRect.width() / 2 + adjustedY = coords[1] - boundingRect.height() / 2 + + marker.setPos(adjustedX, adjustedY) + + def closeEvent(self, event): + self.windowClosed.emit() # Emit the signal when the window is about to close + super().closeEvent(event) # Proceed with the default close event + + +class ImageWindow(QMainWindow): + def __init__(self, image_path, parent=None): + super().__init__(parent) + self.setWindowTitle("Calibration Overlay of laser grid and MIP points") + + # Create a label and set its pixmap to the image at image_path + self.label = QLabel(self) + self.pixmap = QPixmap(image_path) + + # Resize the label to fit the pixmap + self.label.setPixmap(self.pixmap) + self.label.resize(self.pixmap.width(), self.pixmap.height()) + + # Resize the window to fit the label (plus a little margin if desired) + self.resize( + self.pixmap.width() + 20, self.pixmap.height() + 20 + ) # Adding a 20-pixel margin + + # Optionally, center the window on the screen + self.center() + + def center(self): + frameGm = self.frameGeometry() + screen = QApplication.desktop().screenNumber( + QApplication.desktop().cursor().pos() + ) + centerPoint = QApplication.desktop().screenGeometry(screen).center() + frameGm.moveCenter(centerPoint) + self.move(frameGm.topLeft()) From b020e9596c8204bbc88125837ba326febe9317f7 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Tue, 5 Mar 2024 15:56:28 -0800 Subject: [PATCH 52/60] fixing marker offset replacing marker with crosshair --- copylot/assemblies/photom/gui/utils.py | 2 +- copylot/assemblies/photom/gui/widgets.py | 2 +- copylot/assemblies/photom/gui/windows.py | 51 +++++++++++------- .../photom/utils/images/hit_marker.png | Bin 0 -> 156921 bytes .../photom/utils/images/hit_marker_red.png | Bin 0 -> 22800 bytes copylot/assemblies/photom/utils/qt_utils.py | 36 ------------- 6 files changed, 35 insertions(+), 56 deletions(-) create mode 100644 copylot/assemblies/photom/utils/images/hit_marker.png create mode 100644 copylot/assemblies/photom/utils/images/hit_marker_red.png delete mode 100644 copylot/assemblies/photom/utils/qt_utils.py diff --git a/copylot/assemblies/photom/gui/utils.py b/copylot/assemblies/photom/gui/utils.py index ac475eee..1285950f 100644 --- a/copylot/assemblies/photom/gui/utils.py +++ b/copylot/assemblies/photom/gui/utils.py @@ -43,7 +43,7 @@ def __init__(self, photom_assembly, current_mirror_idx): def run(self): # TODO: hardcoding the camera for now - mirror_roi = [[-0.01, 0.0], [0.015, 0.018]] # [x,y] + mirror_roi = [[-0.008, -0.02], [0.019, 0.0]] # [x,y] T_mirror_cam_matrix, plot_save_path = self.photom_assembly.calibrate_w_camera( mirror_index=self.current_mirror_idx, camera_index=0, diff --git a/copylot/assemblies/photom/gui/widgets.py b/copylot/assemblies/photom/gui/widgets.py index 55daadf3..5c6c6af0 100644 --- a/copylot/assemblies/photom/gui/widgets.py +++ b/copylot/assemblies/photom/gui/widgets.py @@ -22,12 +22,12 @@ QGraphicsRectItem, ) -from copylot.assemblies.photom.utils.qt_utils import DoubleSlider from PyQt5.QtCore import Qt, pyqtSignal, QThread import numpy as np import time import yaml from copylot.assemblies.photom.gui.utils import PWMWorker +from copylot.assemblies.photom.gui.utils import DoubleSlider class LaserWidget(QWidget): diff --git a/copylot/assemblies/photom/gui/windows.py b/copylot/assemblies/photom/gui/windows.py index 92563cdf..a76c0391 100644 --- a/copylot/assemblies/photom/gui/windows.py +++ b/copylot/assemblies/photom/gui/windows.py @@ -20,6 +20,7 @@ QGridLayout, QProgressBar, QGraphicsRectItem, + QGraphicsPixmapItem, ) from PyQt5.QtGui import ( QColor, @@ -30,8 +31,10 @@ QBrush, QPixmap, QResizeEvent, + QPixmap, ) from PyQt5.QtCore import Qt, QThread, pyqtSignal +from networkx import center from copylot.assemblies.photom.photom import PhotomAssembly from copylot.assemblies.photom.gui.utils import CalibrationWithCameraThread @@ -43,7 +46,7 @@ MirrorWidget, ArduinoPWMWidget, ) - +import time from copylot.assemblies.photom.utils.scanning_algorithms import ( calculate_rectangle_corners, ) @@ -511,15 +514,23 @@ def initMarker(self): # Mouse tracking self.shooting_view.installEventFilter(self) + self.shooting_view.viewport().installEventFilter(self) self.setMouseTracking(True) - self.marker = QGraphicsSimpleTextItem("+") - self.marker.setBrush(QColor(255, 0, 0)) + # self.marker = QGraphicsSimpleTextItem("+") + # self.marker.setBrush(QColor(255, 0, 0)) + # Load the PNG image + pixmap = QPixmap(r'./copylot/assemblies/photom/utils/images/hit_marker_red.png') + assert pixmap.isNull() == False + pixmap = pixmap.scaled(40, 40, Qt.KeepAspectRatio, Qt.SmoothTransformation) + + # Create a QGraphicsPixmapItem with the loaded image + self.marker = QGraphicsPixmapItem(pixmap) self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) - self.shooting_view.viewport().installEventFilter(self) - # Set larger font size - font = self.marker.font() - font.setPointSize(30) - self.marker.setFont(font) + + # # Set larger font size + # font = self.marker.font() + # font.setPointSize(30) + # self.marker.setFont(font) # Position the marker self.display_marker_center( @@ -694,7 +705,10 @@ def _update_scene_items(self, new_width, new_height): def _move_marker_and_update_sliders(self): # Update the mirror slider values if self.photom_controls is not None: - marker_position = [self.marker.pos().x(), self.marker.pos().y()] + marker_position = self.get_marker_center( + self.marker, coords=(self.marker.pos().x(), self.marker.pos().y()) + ) + # marker_position = [self.marker.pos().x(), self.marker.pos().y()] new_coords = self.photom_controls.mirror_widgets[ self.photom_controls._current_mirror_idx ].mirror.affine_transform_obj.apply_affine(marker_position) @@ -705,18 +719,19 @@ def _move_marker_and_update_sliders(self): self.photom_controls._current_mirror_idx ].mirror_y_slider.setValue(new_coords[1][0]) - def display_marker_center(self, marker, coords=None): + def get_marker_center(self, marker, coords=None): if coords is None: coords = (marker.x(), marker.y()) - # Obtain font metrics and bounding rectangle - fm = QFontMetricsF(marker.font()) - boundingRect = fm.tightBoundingRect(marker.text()) - - # Adjust position based on the bounding rectangle - adjustedX = coords[0] - boundingRect.width() / 2 - adjustedY = coords[1] - boundingRect.height() / 2 + center_x = coords[0] + marker.pixmap().width() / 2 + center_y = coords[1] + marker.pixmap().height() / 2 + return [center_x, center_y] - marker.setPos(adjustedX, adjustedY) + def display_marker_center(self, marker, coords=None): + if coords is None: + coords = (marker.x(), marker.y()) + center_x = coords[0] - marker.pixmap().width() / 2 + center_y = coords[1] - marker.pixmap().height() / 2 + marker.setPos(center_x, center_y) def closeEvent(self, event): self.windowClosed.emit() # Emit the signal when the window is about to close diff --git a/copylot/assemblies/photom/utils/images/hit_marker.png b/copylot/assemblies/photom/utils/images/hit_marker.png new file mode 100644 index 0000000000000000000000000000000000000000..ac9603e57bde26b7bb64b82370b82ee39060540c GIT binary patch literal 156921 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

+3q*Z9Ff=qGk7Q?x*M5g@* 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 Ij+*%V7yCXaVE_OC literal 0 HcmV?d00001 diff --git a/copylot/assemblies/photom/utils/images/hit_marker_red.png b/copylot/assemblies/photom/utils/images/hit_marker_red.png new file mode 100644 index 0000000000000000000000000000000000000000..c30702eb59b0418b9b9832e363cda9112e445114 GIT binary patch literal 22800 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ýsAuKdZ$J)Td!p2xDqX8WUOjB8T)Keg|U3FL17 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 z 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 Date: Tue, 5 Mar 2024 16:31:00 -0800 Subject: [PATCH 53/60] added a game button --- copylot/assemblies/photom/gui/windows.py | 40 ++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/copylot/assemblies/photom/gui/windows.py b/copylot/assemblies/photom/gui/windows.py index a76c0391..9d1905e3 100644 --- a/copylot/assemblies/photom/gui/windows.py +++ b/copylot/assemblies/photom/gui/windows.py @@ -33,7 +33,7 @@ QResizeEvent, QPixmap, ) -from PyQt5.QtCore import Qt, QThread, pyqtSignal +from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer from networkx import center from copylot.assemblies.photom.photom import PhotomAssembly @@ -201,6 +201,12 @@ def initialize_UI(self): # Add the laser and mirror group boxes to the main layout main_layout = QVBoxLayout() + + self.game_mode_button = QPushButton("Game Mode: OFF", self) + self.game_mode_button.setCheckable(True) # Make the button toggleable + self.game_mode_button.clicked.connect(self.toggle_game_mode) + main_layout.addWidget(self.game_mode_button) + main_layout.addWidget(transparency_group) main_layout.addWidget(laser_group) main_layout.addWidget(mirror_group) @@ -235,6 +241,14 @@ def initialize_UI(self): self.setCentralWidget(main_widget) self.show() + def toggle_game_mode(self, checked): + if checked: + self.game_mode_button.setText("Game Mode: ON") + self.photom_window.game_mode = True + else: + self.game_mode_button.setText("Game Mode: OFF") + self.photom_window.game_mode = False + def resize_laser_marker_window(self): # Retrieve the selected resize percentage from the QSpinBox percentage = self.resize_spinbox.value() / 100.0 @@ -442,7 +456,7 @@ def __init__( self.window_geometry = self.window_pos + (self.fixed_width, calculated_height) self.setMouseTracking(True) self.setWindowOpacity(self.photom_controls._laser_window_transparency) - + self.game_mode = False # Default to off # Create a QStackedWidget # TODO: do we need the stacked widget? self.stacked_widget = QStackedWidget() @@ -669,7 +683,9 @@ def eventFilter(self, source, event): elif event.button() == Qt.RightButton: self._right_click_hold = False self.photom_controls.photom_assembly.laser[0].toggle_emission = False - time.sleep(0.5) + if self.game_mode: + self._game_mode_marker(event) + time.sleep(0.3) print('right button released') return super(LaserMarkerWindow, self).eventFilter(source, event) @@ -682,6 +698,24 @@ def resizeEvent(self, a0: QResizeEvent | None) -> None: print(f'resize event: {rect.width()}, {rect.height()}') self._update_scene_items(rect.width(), rect.height()) + def remove_score_text(self, text_item): + self.shooting_scene.removeItem(text_item) + + def _game_mode_marker(self, event: QMouseEvent): + # Show "+100" at click position + score_text = QGraphicsSimpleTextItem("+100") + score_text.setBrush(QColor(255, 255, 0)) # Yellow color for visibility + # Set a larger font size + font = QFont() + font.setPointSize(30) # Set the font size to 24 points + score_text.setFont(font) + score_text.setPos( + event.pos().x() + 15, event.pos().y() - 70 + ) # Position at click + self.shooting_scene.addItem(score_text) + # Create a QTimer to remove the "+100" after 1 second + QTimer.singleShot(1000, lambda: self.remove_score_text(score_text)) + def _update_scene_items(self, new_width, new_height): # Dahsed rectangle rect_width = new_width * 2 / 3 # Example: 2/3 of the new width From d43081ecee4bfd0c33c0f4c0dad29a38809aae03 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Fri, 8 Mar 2024 14:59:03 -0800 Subject: [PATCH 54/60] -fix bug setting arduino settings --- copylot/assemblies/photom/gui/widgets.py | 29 +++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/copylot/assemblies/photom/gui/widgets.py b/copylot/assemblies/photom/gui/widgets.py index 5c6c6af0..60c2c333 100644 --- a/copylot/assemblies/photom/gui/widgets.py +++ b/copylot/assemblies/photom/gui/widgets.py @@ -72,7 +72,8 @@ def initialize_UI(self): self.laser_power_slider.setMinimum(0) self.laser_power_slider.setMaximum(100) self.laser_power_slider.setValue(self.laser.power) - self.laser_power_slider.valueChanged.connect(self.update_power) + self.laser_power_slider.sliderReleased.connect(self.on_slider_released) + self.laser_power_slider.valueChanged.connect(self.update_displayed_power) layout.addWidget(self.laser_power_slider) # Add a QLabel to display the power value @@ -106,13 +107,20 @@ def toggle_laser(self): self.laser_toggle_button.setStyleSheet("background-color: green") def update_power(self, value): - self._curr_power = value / (10**self._slider_decimal) if self._curr_laser_pulse_mode: self.laser.pulse_power = self._curr_power else: self.laser.power = self._curr_power + self.power_edit.setText(f"{self._curr_power:.2f}") + + def on_slider_released(self): + value = self.laser_power_slider.value() + self._curr_power = value + self.update_power(self._curr_power) - # Update the QLabel with the new power value + def update_displayed_power(self, value): + # Update the displayed power value (e.g., in a QLabel) without calling the laser power update function + self._curr_power = value / (10**self._slider_decimal) self.power_edit.setText(f"{self._curr_power:.2f}") def edit_power(self): @@ -125,9 +133,7 @@ def edit_power(self): 0 <= power_value <= 100 ): # Assuming the power range is 0 to 100 percentages self._curr_power = power_value - self.laser.power = self._curr_power - self.laser_power_slider.setValue(self._curr_power) - self.power_edit.setText(f"{self._curr_power:.2f}") + self.update_power(self._curr_power) else: self.power_edit.setText(f"{self._curr_power:.2f}") print(f"Power: {self._curr_power}") @@ -338,6 +344,14 @@ def edit_time_interval(self): f"{self.time_interval_s}" ) # Reset to last valid value + def get_current_parameters_from_gui(self): + self.duty_cycle = float(self.duty_cycle_edit.text()) + self.time_period_ms = float(self.time_period_edit.text()) + self.frequency = 1000.0 / self.time_period_ms + self.duration = float(self.duration_edit.text()) + self.repetitions = int(self.repetitions_edit.text()) + self.time_interval_s = float(self.time_interval_edit.text()) + def update_command(self): self.command = f"U,{self.duty_cycle},{self.frequency},{self.duration}" print(f"arduino out: {self.command}") @@ -380,6 +394,9 @@ def current_laser_changed(self, index): def apply_settings(self): # Implement functionality to apply settings to the selected laser self._curr_laser_idx = self.laser_dropdown.currentIndex() + # Update the command with the current settings + self.get_current_parameters_from_gui() + self.update_command() # TODO: Need to modify the data struct for command for multiple lasers if hasattr(self, 'command'): self.arduino_pwm.send_command(self.command) From ffc5c4f5eebbb28e5cc2e7f3d9e44ed5dd3d9bf4 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Fri, 8 Mar 2024 16:10:21 -0800 Subject: [PATCH 55/60] fixing moving marker clickable areas --- copylot/assemblies/photom/gui/utils.py | 32 +++++++++++++++++++----- copylot/assemblies/photom/gui/windows.py | 12 +++++++-- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/copylot/assemblies/photom/gui/utils.py b/copylot/assemblies/photom/gui/utils.py index 1285950f..6d5bfd21 100644 --- a/copylot/assemblies/photom/gui/utils.py +++ b/copylot/assemblies/photom/gui/utils.py @@ -1,6 +1,15 @@ -from PyQt5.QtCore import Qt, QThread, pyqtSignal +from PyQt5.QtCore import Qt, QThread, pyqtSignal, QRectF import time import numpy as np +from PyQt5.QtWidgets import ( + QSlider, + QWidget, + QVBoxLayout, + QDoubleSpinBox, + QLabel, + QGraphicsPixmapItem, +) +from PyQt5.QtGui import QPainterPath class PWMWorker(QThread): @@ -56,11 +65,6 @@ def run(self): self.finished.emit(T_mirror_cam_matrix, str(plot_save_path)) -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QSlider, QWidget, QVBoxLayout, QDoubleSpinBox, QLabel -from PyQt5.QtCore import pyqtSignal - - class DoubleSlider(QSlider): # create our our signal that we can connect to if necessary doubleValueChanged = pyqtSignal(float) @@ -92,3 +96,19 @@ def singleStep(self): def setValue(self, value): super(DoubleSlider, self).setValue(int(value * self._multi)) + + +class ClickablePixmapItem(QGraphicsPixmapItem): + def __init__(self, pixmap): + super().__init__(pixmap) + self.setFlag(QGraphicsPixmapItem.ItemIsMovable, True) # Make the item movable + + def boundingRect(self): + # Override boundingRect to make the whole pixmap area clickable and draggable + return QRectF(self.pixmap().rect()) + + def shape(self): + # Override shape to define the interactable area as the entire bounding rectangle + path = QPainterPath() + path.addRect(self.boundingRect()) + return path diff --git a/copylot/assemblies/photom/gui/windows.py b/copylot/assemblies/photom/gui/windows.py index 9d1905e3..c5d8a090 100644 --- a/copylot/assemblies/photom/gui/windows.py +++ b/copylot/assemblies/photom/gui/windows.py @@ -37,7 +37,10 @@ from networkx import center from copylot.assemblies.photom.photom import PhotomAssembly -from copylot.assemblies.photom.gui.utils import CalibrationWithCameraThread +from copylot.assemblies.photom.gui.utils import ( + CalibrationWithCameraThread, + ClickablePixmapItem, +) from typing import Tuple import numpy as np @@ -397,6 +400,11 @@ def done_calibration(self, T_affine, plot_save_path): print("No file selected. Skiping Saving the calibration matrix.") # Show dialog box saying no file selected print("Calibration done") + center_coords = [ + self.photom_sensor_size_yx[0] / 2, + self.photom_sensor_size_yx[1] / 2, + ] + self.photom_assembly.set_position(self._current_mirror_idx, center_coords) def update_laser_window_affine(self): # Update the scaling transform matrix @@ -538,7 +546,7 @@ def initMarker(self): pixmap = pixmap.scaled(40, 40, Qt.KeepAspectRatio, Qt.SmoothTransformation) # Create a QGraphicsPixmapItem with the loaded image - self.marker = QGraphicsPixmapItem(pixmap) + self.marker = ClickablePixmapItem(pixmap) self.marker.setFlag(QGraphicsItem.ItemIsMovable, True) # # Set larger font size From 349e1b01cfc0e20decbee9647d7609d829bf9204 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Fri, 8 Mar 2024 17:17:44 -0800 Subject: [PATCH 56/60] -renaming pwm settings for better comprehension --- copylot/assemblies/photom/gui/widgets.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/copylot/assemblies/photom/gui/widgets.py b/copylot/assemblies/photom/gui/widgets.py index 60c2c333..bac65fb0 100644 --- a/copylot/assemblies/photom/gui/widgets.py +++ b/copylot/assemblies/photom/gui/widgets.py @@ -227,7 +227,7 @@ def __init__(self, photom_assembly, arduino_pwm, parent): self.duty_cycle = 50 # [%] (0-100) self.time_period_ms = 100 # [ms] self.frequency = 1000.0 / self.time_period_ms # [Hz] - self.duration = 5000 # [ms] + self.duration = 1 # number of reps self.repetitions = 1 # By default it runs once self.time_interval_s = 0 # [s] @@ -256,25 +256,25 @@ def initialize_UI(self): layout.addWidget(self.duty_cycle_edit, 1, 1) # Time Period - layout.addWidget(QLabel("Time Period [ms]:"), 2, 0) + layout.addWidget(QLabel("Pulse Duration [ms]:"), 2, 0) self.time_period_edit = QLineEdit(f"{self.time_period_ms}") self.time_period_edit.returnPressed.connect(self.edit_time_period) layout.addWidget(self.time_period_edit, 2, 1) # Duration - layout.addWidget(QLabel("Duration [ms]:"), 3, 0) + layout.addWidget(QLabel("Pulse repetitions:"), 3, 0) self.duration_edit = QLineEdit(f"{self.duration}") self.duration_edit.returnPressed.connect(self.edit_duration) layout.addWidget(self.duration_edit, 3, 1) # Repetitions - layout.addWidget(QLabel("Repetitions:"), 4, 0) + layout.addWidget(QLabel("Timelapse - number of timepoints:"), 4, 0) self.repetitions_edit = QLineEdit(f"{self.repetitions}") self.repetitions_edit.textChanged.connect(self.edit_repetitions) layout.addWidget(self.repetitions_edit, 4, 1) # Time interval - layout.addWidget(QLabel("Time interval [s]:"), 5, 0) + layout.addWidget(QLabel("Timelapse - Time interval [s]:"), 5, 0) self.time_interval_edit = QLineEdit(f"{self.time_interval_s}") self.time_interval_edit.textChanged.connect(self.edit_time_interval) layout.addWidget(self.time_interval_edit, 5, 1) @@ -321,6 +321,7 @@ def edit_time_period(self): def edit_duration(self): try: value = float(self.duration_edit.text()) + value = self.time_period_ms * value self.duration = value self.update_command() except ValueError: @@ -348,7 +349,7 @@ def get_current_parameters_from_gui(self): self.duty_cycle = float(self.duty_cycle_edit.text()) self.time_period_ms = float(self.time_period_edit.text()) self.frequency = 1000.0 / self.time_period_ms - self.duration = float(self.duration_edit.text()) + self.duration = float(self.duration_edit.text()) * self.time_period_ms self.repetitions = int(self.repetitions_edit.text()) self.time_interval_s = float(self.time_interval_edit.text()) From 2a1856fb0f880fc6443b022575d9cb816952adf6 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Fri, 8 Mar 2024 20:43:47 -0800 Subject: [PATCH 57/60] adding tracing prototype --- copylot/assemblies/photom/gui/windows.py | 124 ++++++++++++++++++++++- 1 file changed, 119 insertions(+), 5 deletions(-) diff --git a/copylot/assemblies/photom/gui/windows.py b/copylot/assemblies/photom/gui/windows.py index c5d8a090..21113854 100644 --- a/copylot/assemblies/photom/gui/windows.py +++ b/copylot/assemblies/photom/gui/windows.py @@ -21,6 +21,7 @@ QProgressBar, QGraphicsRectItem, QGraphicsPixmapItem, + QGraphicsPathItem, ) from PyQt5.QtGui import ( QColor, @@ -32,6 +33,8 @@ QPixmap, QResizeEvent, QPixmap, + QPainterPath, + QPainter, ) from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer from networkx import center @@ -91,6 +94,8 @@ def __init__( ) self.calibration_w_cam_thread.finished.connect(self.done_calibration) self.imageWindows = [] + self.drawingTraces = [] # List to store traces + self.currentTrace = [] # Current trace being drawn # if DEMO_MODE: # self.demo_window = demo_window @@ -208,9 +213,17 @@ def initialize_UI(self): self.game_mode_button = QPushButton("Game Mode: OFF", self) self.game_mode_button.setCheckable(True) # Make the button toggleable self.game_mode_button.clicked.connect(self.toggle_game_mode) - main_layout.addWidget(self.game_mode_button) + + self.toggle_drawing_mode_button = QPushButton("Toggle Drawing Mode", self) + self.toggle_drawing_mode_button.clicked.connect(self.toggleDrawingMode) + self.playButton = QPushButton("Play", self) + self.playButton.clicked.connect(self.play_drawing) + self.playButton.hide() # Initially hide the Play button main_layout.addWidget(transparency_group) + main_layout.addWidget(self.game_mode_button) + main_layout.addWidget(self.toggle_drawing_mode_button) + main_layout.addWidget(self.playButton) main_layout.addWidget(laser_group) main_layout.addWidget(mirror_group) main_layout.addWidget(arduino_group) # TODO remove if arduino is removed @@ -252,6 +265,19 @@ def toggle_game_mode(self, checked): self.game_mode_button.setText("Game Mode: OFF") self.photom_window.game_mode = False + def toggleDrawingMode(self): + # Call the method to toggle the drawing scene on the LaserMarkerWindow + if ( + self.photom_window.stacked_widget.currentWidget() + == self.photom_window.drawing_view + ): + # If in drawing mode, clear the drawing before switching + self.photom_window.clearDrawing() + self.playButton.hide() + else: + self.playButton.show() + self.photom_window.toggleDrawingScene() + def resize_laser_marker_window(self): # Retrieve the selected resize percentage from the QSpinBox percentage = self.resize_spinbox.value() / 100.0 @@ -430,6 +456,11 @@ def update_transparency(self, value): opacity = 1.0 - (transparency_percent / 100.0) # Calculate opacity (0.0 to 1.0) self.photom_window.setWindowOpacity(opacity) # Update photom_window opacity + def play_drawing(self): + for trace in self.photom_window.drawingTraces: + print(f'Playing trace: {trace}') + self.photom_assembly.set_position(self._current_mirror_idx, trace) + def display_rectangle(self): self.photom_window.switch_to_calibration_scene() @@ -471,6 +502,12 @@ def __init__( # Set the QStackedWidget as the central widget self.initialize_UI() self.initMarker() + self.initDrawingScene() + + self.lastPoint = None + self.drawing = False + self.drawingTraces = [] # To store lists of traces + self.currentTrace = [] # To store points of the current trace being drawn tetragon_coords = calculate_rectangle_corners( [self.window_geometry[-2] / 5, self.window_geometry[-1] / 5], @@ -626,12 +663,67 @@ def init_tetragon( # Add the view to the QStackedWidget self.stacked_widget.addWidget(self.calibration_view) + def initDrawingScene(self): + # Initialize the drawing scene + self.drawing_scene = QGraphicsScene() + self.drawing_scene.setSceneRect( + 0, 0, self.window_geometry[-2], self.window_geometry[-1] + ) + + # Initialize the drawing view + self.drawing_view = QGraphicsView(self.drawing_scene) + self.drawing_view.setFixedSize( + self.window_geometry[-2], self.window_geometry[-1] + ) + + # Disable scrollbars + self.drawing_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.drawing_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + # Mouse tracking + self.drawing_view.setMouseTracking(True) + self.drawing_view.installEventFilter(self) + self.drawing_view.viewport().installEventFilter(self) + + # Add a drawable pixmap item to the scene + self.drawablePixmapItem = QGraphicsPixmapItem() + self.drawing_scene.addItem(self.drawablePixmapItem) + # Initialize the pixmap with a transparent background of the same size as the scene + self.drawablePixmap = QPixmap( + self.window_geometry[-2], self.window_geometry[-1] + ) + self.drawablePixmap.fill(Qt.transparent) # Fill with transparent color + self.drawablePixmapItem.setPixmap(self.drawablePixmap) + + # Add the drawing view to the stacked widget + self.stacked_widget.addWidget(self.drawing_view) + + def switchToDrawingScene(self): + # Method to switch to the drawing scene + self.stacked_widget.setCurrentWidget(self.drawing_view) + def switch_to_shooting_scene(self): self.stacked_widget.setCurrentWidget(self.shooting_view) def switch_to_calibration_scene(self): self.stacked_widget.setCurrentWidget(self.calibration_view) + def toggleDrawingScene(self): + # Check which scene is currently active and switch to the other + if self.stacked_widget.currentWidget() == self.drawing_view: + self.switch_to_shooting_scene() # Assuming this is the method to switch back to your original scene + else: + self.switchToDrawingScene() + + def clearDrawing(self): + # Clear the drawing by filling the pixmap with transparent color + self.drawablePixmap.fill(Qt.transparent) + self.drawablePixmapItem.setPixmap(self.drawablePixmap) + + # Reset the traces + self.drawingTraces = [] + self.currentTrace = [] + def get_coordinates(self): return [vertex.pos() for vertex in self.vertices] @@ -660,8 +752,22 @@ def eventFilter(self, source, event): if event.type() == QMouseEvent.MouseMove: pass if self._left_click_hold: - # Move the mirror around if the left button is clicked - self._move_marker_and_update_sliders() + if source == self.shooting_view.viewport(): + self._move_marker_and_update_sliders() + elif self.drawing and source == self.drawing_view.viewport(): + painter = QPainter(self.drawablePixmap) + pen = QPen(Qt.black, 2, Qt.SolidLine) + painter.setPen(pen) + # Convert event positions to scene positions + lastScenePos = self.drawing_view.mapToScene(self.lastPoint) + currentScenePos = self.drawing_view.mapToScene(event.pos()) + painter.drawLine(lastScenePos, currentScenePos) + painter.end() # End the painter to apply the drawing + + self.lastPoint = event.pos() + self.drawablePixmapItem.setPixmap(self.drawablePixmap) + self.currentTrace.append((currentScenePos.x(), currentScenePos.y())) + # Debugging statements # print('mouse move') # print(f'x1: {event.screenPos().x()}, y1: {event.screenPos().y()}') @@ -675,10 +781,14 @@ def eventFilter(self, source, event): print('mouse button pressed') print('shooting mode') if event.buttons() == Qt.LeftButton: - self._left_click_hold = True print('left button pressed') print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') - self._move_marker_and_update_sliders() + self._left_click_hold = True + if self.stacked_widget.currentWidget() == self.drawing_view: + self.drawing = True + self.lastPoint = event.pos() + elif self.stacked_widget.currentWidget() == self.shooting_view: + self._move_marker_and_update_sliders() elif event.buttons() == Qt.RightButton: self._right_click_hold = True self.photom_controls.photom_assembly.laser[0].toggle_emission = True @@ -688,6 +798,10 @@ def eventFilter(self, source, event): if event.button() == Qt.LeftButton: print('left button released') self._left_click_hold = False + if self.drawing: + self.drawing = False + self.drawingTraces.append(self.currentTrace) + self.currentTrace = [] elif event.button() == Qt.RightButton: self._right_click_hold = False self.photom_controls.photom_assembly.laser[0].toggle_emission = False From c9a982d644d84d8020291b7791f89003d0fffc33 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Tue, 26 Mar 2024 12:01:30 -0700 Subject: [PATCH 58/60] relative import based on package structure --- copylot/assemblies/photom/gui/photom_gui.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/copylot/assemblies/photom/gui/photom_gui.py b/copylot/assemblies/photom/gui/photom_gui.py index f7845df5..d841175f 100644 --- a/copylot/assemblies/photom/gui/photom_gui.py +++ b/copylot/assemblies/photom/gui/photom_gui.py @@ -8,6 +8,7 @@ from copylot.assemblies.photom.gui.windows import LaserMarkerWindow, PhotomApp from PyQt5.QtWidgets import QApplication +import os def load_config(config: str): @@ -68,7 +69,8 @@ def make_photom_assembly(config): from copylot.hardware.lasers.vortran.vortran import VortranLaser as Laser from copylot.assemblies.photom.utils.arduino import ArduinoPWM as ArduinoPWM - config_path = r"./copylot/assemblies/photom/demo/photom_VIS_config.yml" + current_dir = os.path.dirname(os.path.abspath(__file__)) + config_path = os.path.join(current_dir, "..", "demo", "photom_VIS_config.yml") config = load_config(config_path) photom_assembly = make_photom_assembly(config) From bc79429ea6ecf0098ae73645f6313101dfb63dcd Mon Sep 17 00:00:00 2001 From: Aaron Alvarez Date: Fri, 26 Jul 2024 14:22:42 -0700 Subject: [PATCH 59/60] photom v2 roi selection for ablation demo (#185) * implement ROI selection for ablation via gui demo * move Shape class to utils/pattern_tracing * add docstrings and logging statements * add roi selection coloring in the GUI * fix roi selection coloring bug * add shape deletion logic and button * remove unnecessary imports and update docstrings * add default behavior for overly large spacing configuration * adding changes to be able to run demo_mode * Integrate ROI selection to Photom Added: - drawing mode group in control window, includes roi selection and pattern dropdown, pattern spacing boxes, and deletion of shapes - scrollable widget behavior for laser control window - attributes to LaserMarkerWindow class to contain shapes Changed: - how displaying shapes are handled. shape/drawings are displayed by calling self.update() now - shapesUpdate signal to notify of new shapes added - updated mouse event handling to track and store Shape objects * remove hardcoding control window height * use relative path for hit marker png * fix comparison check if there is a selected shape id * add pattern_delay time b/w positions for mirror movement * format with black * -adding changes to make these relative imports. -relative import for the crosshair * extending the range of mockmirrodevice * refactor Shape class to ShapeTrace * make config_file a path object * add n_points option for pattern generation on a shape * formatted with black * add spiral pattern generation code for ROIs * add gap and points per cycle attributes to ShapeTrace * remove spacing option for spiral pattern in gui * update _pattern_spiral to generate n equidistant points in a spiral * update bidirectional pattern names for better spacing * implement bfs-style bidirectional pattern application * change pattern_points from set to list for ordered points * add greyscale pattern tracing, change pattern_points from set to list * add time delay parameter in GUI * remove unnecessary code and comments * fix pattern drawing bug * update breadth first search to add points clockwise to queue * fix bidirectional pattern bug that allowed for repeated (x, y) coordinates --------- Co-authored-by: Eduardo Hirata-Miyasaki --- copylot/assemblies/photom/demo/demo_roi.py | 314 +++++++++++++ .../photom/demo/photom_VIS_config.yml | 2 +- copylot/assemblies/photom/gui/photom_gui.py | 22 +- copylot/assemblies/photom/gui/windows.py | 417 +++++++++++++++--- .../assemblies/photom/photom_mock_devices.py | 393 ++++++++++++++--- .../photom/utils/affine_transform.py | 1 + .../photom/utils/pattern_tracing.py | 141 ++++++ 7 files changed, 1164 insertions(+), 126 deletions(-) create mode 100644 copylot/assemblies/photom/demo/demo_roi.py create mode 100644 copylot/assemblies/photom/utils/pattern_tracing.py diff --git a/copylot/assemblies/photom/demo/demo_roi.py b/copylot/assemblies/photom/demo/demo_roi.py new file mode 100644 index 00000000..4635ba1a --- /dev/null +++ b/copylot/assemblies/photom/demo/demo_roi.py @@ -0,0 +1,314 @@ +import sys +import logging +from PyQt5.QtWidgets import QApplication, QMainWindow, QComboBox, QVBoxLayout, QWidget, QPushButton, QLabel, QLineEdit, QHBoxLayout, QSpacerItem, QSizePolicy +from PyQt5.QtCore import Qt, QPoint, QRect, pyqtSignal +from PyQt5.QtGui import QPainter, QPen, QPainterPath, QPolygon, QMouseEvent, QPaintEvent +from copylot.assemblies.photom.utils.pattern_tracing import Shape + +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') + + +class ViewerWindow(QMainWindow): + shapesUpdated = pyqtSignal() + + def __init__(self) -> None: + """initializes the Viewer Window for drawing and viewing shapes. + """ + super().__init__() + self.setWindowTitle("Main Window") + self.setGeometry(450, 100, 400, 300) + + # class variables + self.drawing = False + self.shapes = {} + self.curr_shape_points = [] + self.curr_shape_id = 0 + self.selected_shape_id = None + + def mousePressEvent(self, event: QMouseEvent) -> None: + """records left mouse press for drawing shapes. + + Args: + event (QMouseEvent): the mouse event that triggered the press. + """ + if event.button() == Qt.LeftButton: + point = event.pos() + if not self.drawing: + self.drawing = True + self.curr_shape_points.append(point) + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + """records mouse movement while drawing a shape. + + Args: + event (QMouseEvent): the mouse event that triggered the movement. + """ + if self.drawing: + point = event.pos() + self.curr_shape_points.append(point) + self.update() + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + """handles the release of the left mouse button, completing the drawing of a shape. + + Args: + event (QMouseEvent): the mouse event that triggered the release. + """ + if event.button() == Qt.LeftButton: + if self.drawing: + point = event.pos() + self.curr_shape_points.append(point) + + self.curr_shape_points.append(self.curr_shape_points[0]) # connecting the final points in case not connected + self.shapes[self.curr_shape_id] = Shape(self.curr_shape_points) + self.shapesUpdated.emit() + + self.curr_shape_points = [] + self.drawing = False + self.curr_shape_id += 1 + self.update() + + def paintEvent(self, event: QPaintEvent) -> None: + """handles the paint event, drawing shapes and patterns on the widget. + + Args: + event (QPaintEvent): the paint event that triggered the redrawing. + """ + self.draw_shapes() + self.draw_patterns() + + def draw_shapes(self) -> None: + """draws all the shapes on the widget. + """ + painter = QPainter(self) + + for shape_id, shape in self.shapes.items(): + if shape_id == self.selected_shape_id: + pen = QPen(Qt.red, 2, Qt.SolidLine) + else: + pen = QPen(Qt.black, 2, Qt.SolidLine) + painter.setPen(pen) + + border_points = shape.border_points + for i in range(len(border_points) - 1): + painter.drawLine(border_points[i], border_points[i + 1]) + + if self.drawing and len(self.curr_shape_points) > 1: + pen = QPen(Qt.black, 2, Qt.SolidLine) + painter.setPen(pen) + for i in range(len(self.curr_shape_points) - 1): + painter.drawLine(self.curr_shape_points[i], self.curr_shape_points[i + 1]) + + def draw_patterns(self) -> None: + """draws all the patterns in the shapes on the widget. + """ + painter = QPainter(self) + pen = QPen(Qt.green, 2, Qt.SolidLine) + painter.setPen(pen) + + for shape_id, shape in self.shapes.items(): + if shape.pattern_points: + pattern_points = shape.pattern_points + for point in pattern_points: + point = QPoint(point[0], point[1]) + painter.drawPoint(point) + +class CtrlWindow(QMainWindow): + def __init__(self, viewer_window: QMainWindow) -> None: + """initializes the control window. + + Args: + viewer_window (QMainWindow): the viewer window (main window) associated with the control window. + """ + super().__init__() + self.viewer_window = viewer_window + self.windowGeo = (500, 500, 500, 500) + self.buttonSize = (200, 100) + self.patterns = ['Bidirectional'] + self.initUI() + + def initUI(self) -> None: + """initializes the user interface for the control window. + """ + central_widget = QWidget() + self.setCentralWidget(central_widget) + layout = QVBoxLayout(central_widget) + + self.setGeometry( + self.windowGeo[0], + self.windowGeo[1], + self.windowGeo[2], + self.windowGeo[3], + ) + self.setWindowTitle('Control panel') + self.setFixedSize( + self.windowGeo[2], + self.windowGeo[3], + ) + + # roi dropdown box + self.roi_dropdown= QComboBox(self) + self.roi_dropdown.addItem("Select ROI") + self.viewer_window.shapesUpdated.connect(self.updateRoiDropdown) + self.roi_dropdown.currentIndexChanged.connect(self.onRoiSelected) + layout.addWidget(self.roi_dropdown) + + # pattern dropdown box + self.pattern_dropdown = QComboBox(self) + self.pattern_dropdown.addItem("Select Pattern") + self.addPatternDropdownItems() + layout.addWidget(self.pattern_dropdown) + self.pattern_dropdown.currentIndexChanged.connect(self.onPatternSelected) + + # horizontal spacing box + spacing_boxes = QHBoxLayout() + self.horizontal_spacing_label = QLabel("Horizontal Spacing:", self) + spacing_boxes.addWidget(self.horizontal_spacing_label) + self.horizontal_spacing_input = QLineEdit(self) + spacing_boxes.addWidget(self.horizontal_spacing_input) + + # vertical spacing box + self.vertical_spacing_label = QLabel("Vertical Spacing:", self) + spacing_boxes.addWidget(self.vertical_spacing_label) + self.vertical_spacing_input = QLineEdit(self) + spacing_boxes.addWidget(self.vertical_spacing_input) + layout.addLayout(spacing_boxes) + + # apply pattern button + pattern_and_delete_buttons= QHBoxLayout() + self.apply_pattern_button = QPushButton("Apply Pattern", self) + self.apply_pattern_button.setMinimumSize(200, 50) + self.apply_pattern_button.clicked.connect(self.onApplyPatternClick) + pattern_and_delete_buttons.addWidget(self.apply_pattern_button) + + # show/hide the horizontal / vertical spacing input + self.show_or_hide_spacing_input(False) + + # delete button + self.delete_button = QPushButton("Delete", self) + self.delete_button.setMinimumSize(200, 50) + self.delete_button.clicked.connect(self.onDeleteClick) + pattern_and_delete_buttons.addWidget(self.delete_button) + + pattern_and_delete_widget= QWidget() + pattern_and_delete_widget.setLayout(pattern_and_delete_buttons) + + # vertical layout for button group, and ablate button + main_buttons_layout = QVBoxLayout() + main_buttons_layout.addWidget(pattern_and_delete_widget) + + # send to ablate button + self.ablate_button = QPushButton("Ablate", self) + self.ablate_button.setMinimumSize(200, 50) + self.ablate_button.setStyleSheet("background-color: red; color: white;") + self.ablate_button.clicked.connect(self.onAblateClick) + main_buttons_layout.addWidget(self.ablate_button) + + layout.addLayout(main_buttons_layout) + + def updateRoiDropdown(self) -> None: + """updates the ROI dropdown with the current shapes. + """ + self.roi_dropdown.clear() + self.roi_dropdown.addItem("Select ROI") + for roi in self.viewer_window.shapes.keys(): + self.roi_dropdown.addItem(f"Shape {roi + 1}") + + def addPatternDropdownItems(self) -> None: + """adds the patterns to the pattern dropdown. + """ + for pattern in self.patterns: + self.pattern_dropdown.addItem(pattern) + + def onApplyPatternClick(self) -> None: + """applies the selected pattern to the selected ROI. + """ + selected_roi = self.roi_dropdown.currentText() + if selected_roi == "Select ROI": + return + roi_number = int(selected_roi.split(" ")[1]) - 1 + selected_pattern = self.pattern_dropdown.currentText() + + if selected_pattern == 'Bidirectional': + try: + horizontal_spacing = int(self.horizontal_spacing_input.text()) + vertical_spacing = int(self.vertical_spacing_input.text()) + shape = self.viewer_window.shapes[roi_number] + shape.pattern_points.clear() + self.viewer_window.shapes[roi_number]._pattern_bidirectional(vertical_spacing, horizontal_spacing) + except ValueError: + logging.error("Invalid spacing input") + + self.viewer_window.update() + + def show_or_hide_spacing_input(self, show) -> None: + """shows or hides the spacing input fields. + + Args: + show (bool): a boolean indicating whether to show (True) or hide (False) the spacing input fields. + """ + self.horizontal_spacing_label.setVisible(show) + self.horizontal_spacing_input.setVisible(show) + self.vertical_spacing_label.setVisible(show) + self.vertical_spacing_input.setVisible(show) + + def onPatternSelected(self) -> None: + """handles the selection of a pattern from the dropdown. + """ + selected_pattern = self.pattern_dropdown.currentText() + if selected_pattern == "Bidirectional": + self.show_or_hide_spacing_input(True) + else: + self.show_or_hide_spacing_input(False) + + def onAblateClick(self) -> list: + """collects and returns the ablation coordinates from all the shapes. + + Returns: + list: a list of lists containing the ablation coordinates for each shape. + """ + ablate_coords = [] + for shape in self.viewer_window.shapes.values(): + if shape.pattern_points: + curr_ablation_coords = [] + for coord in shape.pattern_points: + curr_ablation_coords.append(coord) + ablate_coords.append(curr_ablation_coords) + + logging.debug(f"Ablation coordinates: \n {ablate_coords}") + return ablate_coords + + def onRoiSelected(self) -> None: + """handles the selection of an ROI from the dropdown. + """ + selected_roi = self.roi_dropdown.currentText() + if selected_roi: + if selected_roi == "Select ROI": + self.viewer_window.selected_shape_id = None + else: + roi_id = int(selected_roi.split(" ")[1]) - 1 + self.viewer_window.selected_shape_id = roi_id + self.viewer_window.update() + + def onDeleteClick(self) -> None: + """handles the deletion of shape when the delete button is clicked. + """ + selected_roi = self.roi_dropdown.currentText() + if selected_roi and selected_roi != 'Select ROI': + roi_id = int(selected_roi.split(" ")[1]) - 1 + del self.viewer_window.shapes[roi_id] + logging.debug(f"roi {roi_id} removed.") + self.viewer_window.update() + self.updateRoiDropdown() + + + +if __name__ == "__main__": + import os + + app = QApplication(sys.argv) + dac = ViewerWindow() + ctrl = CtrlWindow(dac) + dac.show() + ctrl.show() + sys.exit(app.exec_()) diff --git a/copylot/assemblies/photom/demo/photom_VIS_config.yml b/copylot/assemblies/photom/demo/photom_VIS_config.yml index f1342a26..0863a08a 100644 --- a/copylot/assemblies/photom/demo/photom_VIS_config.yml +++ b/copylot/assemblies/photom/demo/photom_VIS_config.yml @@ -7,4 +7,4 @@ mirrors: COM_port: COM8 x_position: 0 y_position: 0 - affine_matrix_path: ./copylot/assemblies/photom/demo/affine_T.yml \ No newline at end of file + affine_matrix_path: null \ No newline at end of file diff --git a/copylot/assemblies/photom/gui/photom_gui.py b/copylot/assemblies/photom/gui/photom_gui.py index d841175f..98d5ea9d 100644 --- a/copylot/assemblies/photom/gui/photom_gui.py +++ b/copylot/assemblies/photom/gui/photom_gui.py @@ -50,7 +50,7 @@ def make_photom_assembly(config): if __name__ == "__main__": - DEMO_MODE = False + DEMO_MODE = True # TODO: grab the actual value if the camera is connected to photom_assmebly CAMERA_SENSOR_YX = (2048, 2448) @@ -85,32 +85,14 @@ def make_photom_assembly(config): arduino = [ArduinoPWM(serial_port='COM10', baud_rate=115200)] if DEMO_MODE: - camera_window = LaserMarkerWindow( - name="Mock laser dots", - sensor_size_yx=(2048, 2048), - window_pos=(100, 100), - fixed_width=ctrl_window_width, - ) # Set the positions of the windows ctrl_window = PhotomApp( photom_assembly=photom_assembly, photom_sensor_size_yx=CAMERA_SENSOR_YX, photom_window_size_x=ctrl_window_width, photom_window_pos=(100, 100), - demo_window=camera_window, + demo_mode=DEMO_MODE, arduino=arduino, ) - # Set the camera window to the calibration scene - camera_window.switch_to_calibration_scene() - rectangle_scaling = 0.2 - window_size = (camera_window.width(), camera_window.height()) - rectangle_size = ( - (window_size[0] * rectangle_scaling), - (window_size[1] * rectangle_scaling), - ) - rectangle_coords = calculate_rectangle_corners(rectangle_size) - # translate each coordinate by the offset - rectangle_coords = [(x + 30, y) for x, y in rectangle_coords] - camera_window.update_vertices(rectangle_coords) else: # Set the positions of the windows ctrl_window = PhotomApp( diff --git a/copylot/assemblies/photom/gui/windows.py b/copylot/assemblies/photom/gui/windows.py index 21113854..a038d17e 100644 --- a/copylot/assemblies/photom/gui/windows.py +++ b/copylot/assemblies/photom/gui/windows.py @@ -22,6 +22,8 @@ QGraphicsRectItem, QGraphicsPixmapItem, QGraphicsPathItem, + QHBoxLayout, + QScrollArea, ) from PyQt5.QtGui import ( QColor, @@ -36,7 +38,7 @@ QPainterPath, QPainter, ) -from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer +from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer, QPoint from networkx import center from copylot.assemblies.photom.photom import PhotomAssembly @@ -56,10 +58,9 @@ from copylot.assemblies.photom.utils.scanning_algorithms import ( calculate_rectangle_corners, ) - - -# TODO: this one is hardcoded for now -from copylot.hardware.cameras.flir.flir_camera import FlirCamera +from copylot.assemblies.photom.utils.pattern_tracing import ShapeTrace +import os +from datetime import date, datetime class PhotomApp(QMainWindow): @@ -69,7 +70,7 @@ def __init__( photom_sensor_size_yx: Tuple[int, int] = (2048, 2448), photom_window_size_x: int = 800, photom_window_pos: Tuple[int, int] = (100, 100), - demo_window=None, + demo_mode=False, arduino=[], ): super().__init__() @@ -97,8 +98,7 @@ def __init__( self.drawingTraces = [] # List to store traces self.currentTrace = [] # Current trace being drawn - # if DEMO_MODE: - # self.demo_window = demo_window + self.demo_mode = demo_mode self.initializer_laser_marker_window() self.initialize_UI() @@ -220,10 +220,111 @@ def initialize_UI(self): self.playButton.clicked.connect(self.play_drawing) self.playButton.hide() # Initially hide the Play button + # dropdowns for drawing mode + self.drawing_dropdowns = QHBoxLayout() + self.drawing_dropdowns_widget = QWidget() + + # roi dropdown box + self.roi_dropdown = QComboBox(self) + self.roi_dropdown.addItem("Select ROI") + self.photom_window.shapesUpdated.connect(self.updateRoiDropdown) + self.roi_dropdown.currentIndexChanged.connect(self.onRoiSelected) + self.drawing_dropdowns.addWidget(self.roi_dropdown) + + # pattern dropdown box + self.patterns = ['Bidirectional', 'Spiral'] + self.pattern_dropdown = QComboBox(self) + self.pattern_dropdown.addItem("Select Pattern") + self.addPatternDropdownItems() + self.pattern_dropdown.currentIndexChanged.connect(self.onPatternSelected) + self.drawing_dropdowns.addWidget(self.pattern_dropdown) + self.drawing_dropdowns_widget.setLayout(self.drawing_dropdowns) + + # horizontal spacing box for bidirectional pattern + self.bidirectional_params = QHBoxLayout() + self.horizontal_spacing_label = QLabel("H Spacing:", self) + self.bidirectional_params.addWidget(self.horizontal_spacing_label) + self.horizontal_spacing_input = QLineEdit(self) + self.bidirectional_params.addWidget(self.horizontal_spacing_input) + + # vertical spacing box for bidirectional pattern + self.vertical_spacing_label = QLabel("V Spacing:", self) + self.bidirectional_params.addWidget(self.vertical_spacing_label) + self.vertical_spacing_input = QLineEdit(self) + self.bidirectional_params.addWidget(self.vertical_spacing_input) + + # n number of points box for bidirectional pattern + self.num_points_label = QLabel("No. Points:", self) + self.bidirectional_params.addWidget(self.num_points_label) + self.num_points_input = QLineEdit(self) + self.bidirectional_params.addWidget(self.num_points_input) + + # widget for spacing boxes + self.bidirectional_params_widget = QWidget() + self.bidirectional_params_widget.setLayout(self.bidirectional_params) + self.bidirectional_params_widget.hide() + + # parameter layout for spiral pattern + self.spiral_params = QHBoxLayout() + + self.spiral_nums_label = QLabel("Number of Points:", self) + self.spiral_params.addWidget(self.spiral_nums_label) + self.spiral_nums_input = QLineEdit(self) + self.spiral_params.addWidget(self.spiral_nums_input) + self.spiral_params_widget = QWidget() + self.spiral_params_widget.setLayout(self.spiral_params) + self.spiral_params_widget.hide() + + # time delay between_points + self.delay_time_widget = QWidget() + self.delay_label = QLabel("Delay (s):", self) + self.delay_input = QLineEdit(self) + self.delay_time_layout = QHBoxLayout() + self.delay_time_layout.addWidget(self.delay_label) + self.delay_time_layout.addWidget(self.delay_input) + self.delay_time_widget.setLayout(self.delay_time_layout) + self.delay_time_widget.hide() + + # apply pattern button + self.pattern_and_delete_buttons = QHBoxLayout() + self.apply_pattern_button = QPushButton("Apply Pattern", self) + self.apply_pattern_button.setMinimumSize(200, 50) + self.apply_pattern_button.clicked.connect(self.onApplyPatternClick) + self.pattern_and_delete_buttons.addWidget(self.apply_pattern_button) + + # delete shape/pattern button + self.delete_button = QPushButton("Delete", self) + self.delete_button.setMinimumSize(200, 50) + self.delete_button.clicked.connect(self.onDeleteClick) + self.pattern_and_delete_buttons.addWidget(self.delete_button) + self.pattern_and_delete_widget = QWidget() + self.pattern_and_delete_widget.setLayout(self.pattern_and_delete_buttons) + + # run button + self.run_button = QPushButton("Run", self) + self.run_button.clicked.connect(self.photom_window._roi_tracing) + + # drawing mode widget + self.drawing_mode_layout = QVBoxLayout() + self.drawing_mode_widget = QWidget() + self.drawing_mode_layout.addWidget(self.playButton) + self.drawing_mode_layout.addWidget(self.drawing_dropdowns_widget) + self.drawing_mode_layout.addWidget(self.bidirectional_params_widget) + self.drawing_mode_layout.addWidget(self.spiral_params_widget) + self.drawing_mode_layout.addWidget(self.delay_time_widget) + self.drawing_mode_layout.addWidget(self.pattern_and_delete_widget) + self.drawing_mode_layout.addWidget(self.run_button) + self.drawing_mode_widget.setLayout(self.drawing_mode_layout) + + self.drawing_mode_group = QGroupBox("Drawing Mode") + self.drawing_mode_group.setLayout(self.drawing_mode_layout) + self.drawing_mode_group.hide() + + # adding subwidgets (sections) to the main layout main_layout.addWidget(transparency_group) main_layout.addWidget(self.game_mode_button) main_layout.addWidget(self.toggle_drawing_mode_button) - main_layout.addWidget(self.playButton) + main_layout.addWidget(self.drawing_mode_group) # drawing mode group main_layout.addWidget(laser_group) main_layout.addWidget(mirror_group) main_layout.addWidget(arduino_group) # TODO remove if arduino is removed @@ -254,7 +355,11 @@ def initialize_UI(self): main_widget = QWidget(self) main_widget.setLayout(main_layout) - self.setCentralWidget(main_widget) + scroll_area = QScrollArea(self) + scroll_area.setWidget(main_widget) + scroll_area.setWidgetResizable(True) + + self.setCentralWidget(scroll_area) self.show() def toggle_game_mode(self, checked): @@ -273,10 +378,11 @@ def toggleDrawingMode(self): ): # If in drawing mode, clear the drawing before switching self.photom_window.clearDrawing() - self.playButton.hide() + self.drawing_mode_group.hide() + self.photom_window.toggleDrawingScene() else: - self.playButton.show() - self.photom_window.toggleDrawingScene() + self.drawing_mode_group.show() + self.photom_window.toggleDrawingScene() def resize_laser_marker_window(self): # Retrieve the selected resize percentage from the QSpinBox @@ -330,10 +436,19 @@ def calibrate_w_camera(self): # TODO: these parameters are currently hardcoded def setup_calibration(self): - # Open the camera and add it to the assembly - cam = FlirCamera() - cam.open() - self.photom_assembly.camera = [cam] + if self.demo_mode: + from copylot.assemblies.photom.photom_mock_devices import MockFlirCamera + + cam = MockFlirCamera() + cam.open() + self.photom_assembly.camera = [cam] + else: + from copylot.hardware.cameras.flir.flir_camera import FlirCamera + + # Open the camera and add it to the assembly + cam = FlirCamera() + cam.open() + self.photom_assembly.camera = [cam] self.photom_assembly.laser[0].power = 0.0 self.photom_assembly.laser[0].toggle_emission = True @@ -473,9 +588,104 @@ def closeAllWindows(self): self.close() QApplication.quit() # Quit the application + def updateRoiDropdown(self) -> None: + """updates the ROI dropdown with the current shapes.""" + self.roi_dropdown.clear() + self.roi_dropdown.addItem("Select ROI") + for roi in self.photom_window.shapes.keys(): + self.roi_dropdown.addItem(f"Shape {roi + 1}") + + def onRoiSelected(self) -> None: + """handles the selection of an ROI from the dropdown.""" + selected_roi = self.roi_dropdown.currentText() + if selected_roi: + if selected_roi == "Select ROI": + self.photom_window.selected_shape_id = None + else: + roi_id = int(selected_roi.split(" ")[1]) - 1 + self.photom_window.selected_shape_id = roi_id + self.photom_window.update() + + def addPatternDropdownItems(self) -> None: + """adds the patterns to the pattern dropdown.""" + for pattern in self.patterns: + self.pattern_dropdown.addItem(pattern) + + def onPatternSelected(self) -> None: + """handles the selection of a pattern from the dropdown.""" + selected_pattern = self.pattern_dropdown.currentText() + if selected_pattern == "Bidirectional": + self.bidirectional_params_widget.show() + self.spiral_params_widget.hide() + self.delay_time_widget.show() + elif selected_pattern == "Spiral": + self.spiral_params_widget.show() + self.bidirectional_params_widget.hide() + self.delay_time_widget.show() + else: + self.bidirectional_params_widget.hide() + self.spiral_params_widget.hide() + self.delay_time_widget.hide() + + def onApplyPatternClick(self) -> None: + """applies the selected pattern to the selected ROI.""" + selected_roi = self.roi_dropdown.currentText() + if selected_roi == "Select ROI": + return + roi_number = int(selected_roi.split(" ")[1]) - 1 + selected_pattern = self.pattern_dropdown.currentText() + + if selected_pattern == 'Bidirectional': + try: + horizontal_spacing = int(self.horizontal_spacing_input.text()) + vertical_spacing = int(self.vertical_spacing_input.text()) + num_points = self.num_points_input.text() + if num_points: + num_points = int(num_points) + else: + num_points = None + + shape = self.photom_window.shapes[roi_number] + shape.pattern_points = [] + shape.ablation_points = [] + self.photom_window.shapes[roi_number]._pattern_bidirectional( + vertical_spacing=vertical_spacing, + horizontal_spacing=horizontal_spacing, + num_points=num_points, + ) + except ValueError: + print('Invalid spacing value') + elif selected_pattern == 'Spiral': + try: + num_points = self.spiral_nums_input.text() + if num_points: + num_points = int(num_points) + else: + num_points = None + + shape = self.photom_window.shapes[roi_number] + shape.ablation_points.clear() + self.photom_window.shapes[roi_number]._pattern_spiral( + num_points=num_points, + ) + except ValueError: + print('Invalid spacing value') + + self.photom_window.update() + + def onDeleteClick(self) -> None: + """handles the deletion of shape when the delete button is clicked.""" + selected_roi = self.roi_dropdown.currentText() + if selected_roi and selected_roi != 'Select ROI': + roi_id = int(selected_roi.split(" ")[1]) - 1 + del self.photom_window.shapes[roi_id] + self.photom_window.update() + self.updateRoiDropdown() + class LaserMarkerWindow(QMainWindow): windowClosed = pyqtSignal() # Define the signal + shapesUpdated = pyqtSignal() def __init__( self, @@ -509,6 +719,13 @@ def __init__( self.drawingTraces = [] # To store lists of traces self.currentTrace = [] # To store points of the current trace being drawn + # drawing class variables + self.drawing = False + self.shapes = {} + self.curr_shape_points = [] + self.curr_shape_id = 0 + self.selected_shape_id = None + tetragon_coords = calculate_rectangle_corners( [self.window_geometry[-2] / 5, self.window_geometry[-1] / 5], center=[self.window_geometry[-2] / 2, self.window_geometry[-1] / 2], @@ -549,6 +766,12 @@ def update_window_geometry(self, new_width, new_height): self.shooting_scene.setSceneRect(0, 0, new_width, new_height) self.setFixedSize(new_width, new_height) + self.drawing_view.setFixedSize(new_width, new_height) + self.drawing_scene.setSceneRect(0, 0, new_width, new_height) + self.drawablePixmap = QPixmap(int(new_width), int(new_height)) + self.drawablePixmap.fill(Qt.transparent) + self.drawablePixmapItem.setPixmap(self.drawablePixmap) + def recenter_marker(self): self.display_marker_center( self.marker, @@ -578,7 +801,14 @@ def initMarker(self): # self.marker = QGraphicsSimpleTextItem("+") # self.marker.setBrush(QColor(255, 0, 0)) # Load the PNG image - pixmap = QPixmap(r'./copylot/assemblies/photom/utils/images/hit_marker_red.png') + # Get the current script directory + current_dir = os.path.dirname(os.path.abspath(__file__)) + # Construct the relative path + image_path = os.path.join( + current_dir, '..', 'utils', 'images', 'hit_marker_red.png' + ) + + pixmap = QPixmap(image_path) assert pixmap.isNull() == False pixmap = pixmap.scaled(40, 40, Qt.KeepAspectRatio, Qt.SmoothTransformation) @@ -749,59 +979,55 @@ def update_vertices(self, new_coordinates): def eventFilter(self, source, event): "The mouse movements do not work without this function" - if event.type() == QMouseEvent.MouseMove: - pass - if self._left_click_hold: - if source == self.shooting_view.viewport(): - self._move_marker_and_update_sliders() - elif self.drawing and source == self.drawing_view.viewport(): - painter = QPainter(self.drawablePixmap) - pen = QPen(Qt.black, 2, Qt.SolidLine) - painter.setPen(pen) - # Convert event positions to scene positions - lastScenePos = self.drawing_view.mapToScene(self.lastPoint) - currentScenePos = self.drawing_view.mapToScene(event.pos()) - painter.drawLine(lastScenePos, currentScenePos) - painter.end() # End the painter to apply the drawing - self.lastPoint = event.pos() - self.drawablePixmapItem.setPixmap(self.drawablePixmap) - self.currentTrace.append((currentScenePos.x(), currentScenePos.y())) - - # Debugging statements - # print('mouse move') - # print(f'x1: {event.screenPos().x()}, y1: {event.screenPos().y()}') - # print(f'x: {event.posF().x()}, y: {event.posF().y()}') - # print(f'x: {event.localPosF().x()}, y: {event.localPosF().y()}') - # print(f'x: {event.windowPosF().x()}, y: {event.windowPosF().y()}') - # print(f'x: {event.screenPosF().x()}, y: {event.screenPosF().y()}') - # print(f'x: {event.globalPosF().x()}, y: {event.globalPosF().y()}') - # print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') - elif event.type() == QMouseEvent.MouseButtonPress: - print('mouse button pressed') - print('shooting mode') + if event.type() == QMouseEvent.MouseButtonPress: if event.buttons() == Qt.LeftButton: - print('left button pressed') - print(f'x2: {event.pos().x()}, y2: {event.pos().y()}') self._left_click_hold = True if self.stacked_widget.currentWidget() == self.drawing_view: self.drawing = True self.lastPoint = event.pos() + point = event.pos() + if not self.drawing: + self.drawing = True + self.curr_shape_points.append(point) + self.update() elif self.stacked_widget.currentWidget() == self.shooting_view: self._move_marker_and_update_sliders() + elif event.buttons() == Qt.RightButton: self._right_click_hold = True self.photom_controls.photom_assembly.laser[0].toggle_emission = True print('right button pressed') + + elif event.type() == QMouseEvent.MouseMove: + pass + if self._left_click_hold: + if source == self.shooting_view.viewport(): + self._move_marker_and_update_sliders() + elif self.drawing and source == self.drawing_view.viewport(): + point = event.pos() + self.curr_shape_points.append(point) + self.update() + elif event.type() == QMouseEvent.MouseButtonRelease: - print('mouse button released') if event.button() == Qt.LeftButton: print('left button released') self._left_click_hold = False + if self.drawing: + point = event.pos() + self.curr_shape_points.append(point) + + self.curr_shape_points.append( + self.curr_shape_points[0] + ) # connecting the final points in case not connected + self.shapes[self.curr_shape_id] = ShapeTrace(self.curr_shape_points) + self.shapesUpdated.emit() + + self.curr_shape_points = [] self.drawing = False - self.drawingTraces.append(self.currentTrace) - self.currentTrace = [] + self.curr_shape_id += 1 + self.update() elif event.button() == Qt.RightButton: self._right_click_hold = False self.photom_controls.photom_assembly.laser[0].toggle_emission = False @@ -817,6 +1043,8 @@ def resizeEvent(self, a0: QResizeEvent | None) -> None: super().resizeEvent(a0) rect = self.shooting_view.sceneRect() self.shooting_scene.setSceneRect(0, 0, rect.width(), rect.height()) + self.drawing_scene.setSceneRect(0, 0, rect.width(), rect.height()) + self.drawing_view.setSceneRect(0, 0, rect.width(), rect.height()) print(f'resize event: {rect.width()}, {rect.height()}') self._update_scene_items(rect.width(), rect.height()) @@ -875,6 +1103,37 @@ def _move_marker_and_update_sliders(self): self.photom_controls._current_mirror_idx ].mirror_y_slider.setValue(new_coords[1][0]) + def _roi_tracing(self, pattern_delay: float = 1.0): + delay_secs = self.photom_controls.delay_input.text() + if delay_secs: + pattern_delay = float(delay_secs) + else: + pattern_delay = 1.0 + print(f"the time delay is {pattern_delay}") + if self.selected_shape_id is not None: + shape = self.shapes[self.selected_shape_id] + new_ablation_points = [] + if shape.ablation_points: + for position in shape.ablation_points: + new_coords = self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror.affine_transform_obj.apply_affine(position) + + self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror_x_slider.setValue(new_coords[0][0]) + + self.photom_controls.mirror_widgets[ + self.photom_controls._current_mirror_idx + ].mirror_y_slider.setValue(new_coords[1][0]) + + time.sleep(pattern_delay) + print(f"time: {datetime.now()}") + # QTimer.singleShot(int(3 * 1000), lambda: None) + + new_ablation_points.append(new_coords) + return new_ablation_points + def get_marker_center(self, marker, coords=None): if coords is None: coords = (marker.x(), marker.y()) @@ -893,6 +1152,58 @@ def closeEvent(self, event): self.windowClosed.emit() # Emit the signal when the window is about to close super().closeEvent(event) # Proceed with the default close event + def draw_shapes(self) -> None: + """draws all the shapes on the widget.""" + self.drawablePixmap.fill(Qt.transparent) + painter = QPainter(self.drawablePixmap) + for shape_id, shape in self.shapes.items(): + if shape_id == self.selected_shape_id: + pen = QPen(Qt.red, 2, Qt.SolidLine) + else: + pen = QPen(Qt.black, 2, Qt.SolidLine) + painter.setPen(pen) + + border_points = shape.border_points + for i in range(len(border_points) - 1): + painter.drawLine(border_points[i], border_points[i + 1]) + + if self.drawing and len(self.curr_shape_points) > 1: + pen = QPen(Qt.black, 2, Qt.SolidLine) + painter.setPen(pen) + for i in range(len(self.curr_shape_points) - 1): + painter.drawLine( + self.curr_shape_points[i], self.curr_shape_points[i + 1] + ) + painter.end() + self.drawablePixmapItem.setPixmap(self.drawablePixmap) + + def draw_patterns(self) -> None: + """draws all the patterns in the shapes on the widget.""" + painter = QPainter(self.drawablePixmap) + brush = QBrush(Qt.green, Qt.SolidPattern) + point_size = 5 + + for shape_id, shape in self.shapes.items(): + if shape.ablation_points: + ablation_points = shape.ablation_points + for point in ablation_points: + point = QPoint(point[0], point[1]) + painter.setBrush(brush) + painter.drawEllipse(point, point_size, point_size) + if shape.pattern_points: + pattern_points = shape.pattern_points + for point in pattern_points: + pen = QPen(Qt.gray, 2, Qt.SolidLine) + painter.setPen(pen) + point = QPoint(point[0], point[1]) + painter.drawPoint(point) + painter.end() + self.drawablePixmapItem.setPixmap(self.drawablePixmap) + + def paintEvent(self, event): + self.draw_shapes() + self.draw_patterns() + class ImageWindow(QMainWindow): def __init__(self, image_path, parent=None): diff --git a/copylot/assemblies/photom/photom_mock_devices.py b/copylot/assemblies/photom/photom_mock_devices.py index b9ea9329..cafea6c3 100644 --- a/copylot/assemblies/photom/photom_mock_devices.py +++ b/copylot/assemblies/photom/photom_mock_devices.py @@ -1,98 +1,225 @@ from typing import Tuple +from copylot.hardware.cameras.abstract_camera import AbstractCamera +from copylot.hardware.lasers.abstract_laser import AbstractLaser +from copylot.hardware.mirrors.abstract_mirror import AbstractMirror +from copylot import logger -class MockLaser: - def __init__(self, name, power=0, **kwargs): - # Initialize the mock laser + +class MockLaser(AbstractLaser): + def __init__(self, name, serial_number=None, port=None, baudrate=19200, timeout=1): self.name = name - self.laser_on = False + self.port = port + self.baudrate = baudrate + self.timeout = timeout + self._device_id = serial_number + self._drive_control_mode = "automatic" + self._toggle_emission = False + self._power = 0.0 + self._pulse_mode = False + self._maximum_power = 100.0 + self._current_control_mode = "automatic" + self._external_power_control = False + self._status = "disconnected" + + @property + def device_id(self): + return self._device_id + + def connect(self): + self._status = "connected" + print("Mock laser connected") + + def disconnect(self): + self._status = "disconnected" + print("Mock laser disconnected") - self.toggle_emission = 0 - self.laser_power = power + @property + def drive_control_mode(self) -> str: + return self._drive_control_mode + + @drive_control_mode.setter + def drive_control_mode(self, value: str): + self._drive_control_mode = value + print(f"Drive control mode set to {value}") @property - def toggle_emission(self): - """ - Toggles Laser Emission On and Off - (1 = On, 0 = Off) - """ - print(f'Toggling laser {self.name} emission') + def toggle_emission(self) -> bool: return self._toggle_emission @toggle_emission.setter - def toggle_emission(self, value): - """ - Toggles Laser Emission On and Off - (1 = On, 0 = Off) - """ - print(f'Laser {self.name} emission set to {value}') + def toggle_emission(self, value: bool): self._toggle_emission = value + print(f"Laser emission {'enabled' if value else 'disabled'}") + + @property + def power(self) -> float: + return self._power + + @power.setter + def power(self, value: float): + if 0.0 <= value <= self._maximum_power: + self._power = value + print(f"Laser power set to {value} mW") + else: + print(f"Power value {value} is out of range") @property - def laser_power(self): - print(f'Laser {self.name} power: {self.power}') - return self.power + def pulse_mode(self) -> bool: + return self._pulse_mode - @laser_power.setter - def laser_power(self, power): - self.power = power - print(f'Laser {self.name} power set to {power}') + @pulse_mode.setter + def pulse_mode(self, value: bool): + self._pulse_mode = value + print(f"Laser pulse mode {'enabled' if value else 'disabled'}") + @property + def maximum_power(self) -> float: + return self._maximum_power -class MockMirror: - def __init__(self, name, pos_x=0, pos_y=0, **kwargs): - # Initialize the mock mirror with the given x and y positions + @property + def current_control_mode(self) -> str: + return self._current_control_mode + + @current_control_mode.setter + def current_control_mode(self, value: str): + self._current_control_mode = value + print(f"Current control mode set to {value}") + + @property + def external_power_control(self) -> bool: + return self._external_power_control + + @external_power_control.setter + def external_power_control(self, value: bool): + self._external_power_control = value + print(f"External power control {'enabled' if value else 'disabled'}") + + @property + def status(self) -> str: + return self._status + + +class MockMirror(AbstractMirror): + def __init__( + self, + name: str = "OPTOTUNE_MIRROR", + com_port: str = None, + pos_x: float = 0.0, + pos_y: float = 0.0, + ): + super().__init__() self.name = name + self.mirror = 'mirror_instane' + self._movement_limits = [-2000.0, 2000.0, -2000.0, 2000.0] + self._position = [0.0, 0.0] + self._relative_position = [0.0, 0.0] + self._step_resolution = 0.01 + self._external_drive_control = "manual" + self._step_resolution = 0.01 + self._external_drive_control = "manual" + self.position_x = pos_x + self.position_y = pos_y - self.pos_x = pos_x - self.pos_y = pos_y + @property + def device_id(self): + return self.name - self.position = (self.pos_x, self.pos_y) - self.movement_limits = [-1, 1, -1, 1] + @device_id.setter + def device_id(self, value: str): + self.name = value @property - def position(self): - print(f'Getting mirror position ({self.pos_x}, {self.pos_y})') - return self.position_x, self.position_y + def position(self) -> list[float, float]: + return self._position @position.setter - def position(self, value: Tuple[float, float]): - self.position_x = value[0] - self.position_y = value[1] - print(f'Mirror {self.name} position set to {value}') + def position(self, value: list[float, float]): + if ( + self._movement_limits[0] <= value[0] <= self._movement_limits[1] + and self._movement_limits[2] <= value[1] <= self._movement_limits[3] + ): + self._position = value + print(f"Mirror position set to {value}") + else: + print("Position out of bounds") @property def position_x(self) -> float: - """Get the current mirror position X""" - print(f'Mirror {self.name} Position_X {self.pos_x}') - return self.pos_x + return self._position[0] @position_x.setter def position_x(self, value: float): - """Set the mirror position X""" - self.pos_x = value - print(f'Mirror {self.name} Position_X {self.pos_x}') + if self._movement_limits[0] <= value <= self._movement_limits[1]: + self._position[0] = value + print(f"Mirror position X set to {value}") + else: + print("Position X out of bounds") @property def position_y(self) -> float: - """Get the current mirror position Y""" - return self.pos_y + return self._position[1] @position_y.setter def position_y(self, value: float): - """Set the mirror position Y""" - self.pos_y = value - print(f'Mirror {self.name} Position_Y {self.pos_y}') + if self._movement_limits[2] <= value <= self._movement_limits[3]: + self._position[1] = value + print(f"Mirror position Y set to {value}") + else: + print("Position Y out of bounds") + + @property + def relative_position(self) -> list[float, float]: + return self._relative_position + + @relative_position.setter + def relative_position(self, value: list[float, float]): + self._relative_position = value + print(f"Relative position set to {value}") @property def movement_limits(self) -> list[float, float, float, float]: - """Get the current mirror movement limits""" return self._movement_limits @movement_limits.setter def movement_limits(self, value: list[float, float, float, float]): - """Set the mirror movement limits""" self._movement_limits = value + print(f"Movement limits set to {value}") + + @property + def step_resolution(self) -> float: + return self._step_resolution + + @step_resolution.setter + def step_resolution(self, value: float): + self._step_resolution = value + print(f"Step resolution set to {value}") + + def set_home(self): + self._position = [0.0, 0.0] + print("Mirror home position set to [0.0, 0.0]") + + def set_origin(self, axis: str): + if axis == 'x': + self._position[0] = 0.0 + elif axis == 'y': + self._position[1] = 0.0 + print(f"Mirror origin set for axis {axis}") + + @property + def external_drive_control(self) -> str: + return self._external_drive_control + + @external_drive_control.setter + def external_drive_control(self, value: bool): + self._external_drive_control = value + print(f"External drive control set to {value}") + + def voltage_to_position(self, voltage: list[float, float]) -> list[float, float]: + # Simple conversion for mock purposes + position = [v * 10 for v in voltage] + print(f"Converted voltage {voltage} to position {position}") + return position class MockArduinoPWM: @@ -121,3 +248,165 @@ def send_command(self, command): # Simulate a response from the Arduino if needed response = "OK" # or simulate different responses based on the command print(f"MockArduinoPWM: Received response: {response}") + + +class MockFlirCamera(AbstractCamera): + def __init__(self): + self._cam = None + self._device_id = "MOCK1234" + self._nodemap_tldevice = None + + @property + def cam(self): + return self._cam + + @cam.setter + def cam(self, val): + self._cam = val + + @property + def nodemap_tldevice(self): + return self._nodemap_tldevice + + @nodemap_tldevice.setter + def nodemap_tldevice(self, val): + self._nodemap_tldevice = val + + @property + def device_id(self): + return self._device_id + + def open(self, index=0): + self._cam = "MockCameraInstance" + self._nodemap_tldevice = "MockNodeMap" + logger.info(f"Mock camera opened with index {index}") + + def initialize(self): + logger.info("Mock camera initialized") + + def close(self): + self._cam = None + self._nodemap_tldevice = None + logger.info("Mock camera closed") + + def list_available_cameras(self): + return ["MockCamera1", "MockCamera2"] + + def save_image(self, all_arrays, image_format='tiff'): + if isinstance(all_arrays, list): + n = len(all_arrays) + else: + n = 1 + logger.info(f"Mock save {n} images in {image_format} format") + + def return_image(self, processor, processing_type, wait_time): + width, height = 640, 480 + image_array = np.random.randint(0, 256, (height, width), dtype=np.uint8) + logger.info(f"Mock return image of size {width}x{height}") + return image_array + + def acquire_images(self, mode, n_images, wait_time, processing, processing_type): + images = [self.return_image(None, None, wait_time) for _ in range(n_images)] + logger.info(f"Mock acquire {n_images} images in mode {mode}") + return images if n_images > 1 else images[0] + + def snap( + self, + n_images=1, + mode='Continuous', + wait_time=1000, + processing=False, + processing_type=None, + ): + result_array = self.acquire_images( + mode, n_images, wait_time, processing, processing_type + ) + return result_array + + @property + def exposure_limits(self): + return 100.0, 10000.0 + + @property + def exposure(self): + return 1000.0 + + @exposure.setter + def exposure(self, exp): + logger.info(f"Mock set exposure to {exp} microseconds") + + @property + def gain_limits(self): + return 0.0, 1.0 + + @property + def gain(self): + return 0.5 + + @gain.setter + def gain(self, g): + logger.info(f"Mock set gain to {g * 18.0} dB") + + @property + def framerate(self): + return 30.0 + + @property + def bitdepth(self): + return 8 + + @bitdepth.setter + def bitdepth(self, bit): + logger.info(f"Mock set bit depth to {bit}") + + @property + def image_size(self): + return 640, 480 + + @image_size.setter + def image_size(self, size): + logger.info(f"Mock set image size to {size}") + + @property + def image_size_limits(self): + return 320, 1280, 240, 960 + + @property + def binning(self): + return 1, 1 + + @binning.setter + def binning(self, val): + logger.info(f"Mock set binning to {val}") + + @property + def shutter_mode(self): + return 1 + + @shutter_mode.setter + def shutter_mode(self, mode): + logger.info(f"Mock set shutter mode to {mode}") + + @property + def flip_sensor_X(self): + return False + + @flip_sensor_X.setter + def flip_sensor_X(self, value): + logger.info(f"Mock set flip sensor X to {value}") + + @property + def flip_sensor_Y(self): + return False + + @flip_sensor_Y.setter + def flip_sensor_Y(self, value): + logger.info(f"Mock set flip sensor Y to {value}") + + @property + def pixel_format(self): + return "Mono8" + + @pixel_format.setter + def pixel_format(self, format_str): + logger.info(f"Mock set pixel format to {format_str}") diff --git a/copylot/assemblies/photom/utils/affine_transform.py b/copylot/assemblies/photom/utils/affine_transform.py index 9bf3f9d7..785cd993 100644 --- a/copylot/assemblies/photom/utils/affine_transform.py +++ b/copylot/assemblies/photom/utils/affine_transform.py @@ -29,6 +29,7 @@ def __init__(self, tx_matrix: np.array = None, config_file: Path = None): self.T_affine = tx_matrix if self.config_file is None: + self.config_file = Path("./affine_transform.yml") self.make_config() else: settings = yaml_to_model(self.config_file, AffineTransformationSettings) diff --git a/copylot/assemblies/photom/utils/pattern_tracing.py b/copylot/assemblies/photom/utils/pattern_tracing.py new file mode 100644 index 00000000..6d4dd56b --- /dev/null +++ b/copylot/assemblies/photom/utils/pattern_tracing.py @@ -0,0 +1,141 @@ +from typing import List, Tuple +from PyQt5.QtCore import Qt, QPoint +from PyQt5.QtGui import QPolygon +import logging +from collections import deque +import numpy as np + + +class ShapeTrace(QPolygon): + def __init__(self, border_points: List[Tuple[float, float]]) -> None: + """initializes a Shape object. + + Args: + border_points (List[Tuple[float, float]]): a list of tuples representing the border of the shape. each tuple is an (x, y) + """ + super().__init__(border_points) + self.border_points = border_points + self.pattern_style = None + self.ablation_points = [] # points to be ablated + self.pattern_points = ( + [] + ) # points to be plotted in gray for pattern visualization + + self.gap = 5 + self.points_per_cycle = 80 + + def _pattern_bidirectional( + self, vertical_spacing: int, horizontal_spacing: int, num_points: int = None + ) -> None: + """adds a bidirectional (snaking) pattern to the shape. + + Args: + vertical_spacing (int): determines how many pixels of space will be between each point in the shape vertically. + horizontal_spacing (int): determines how many pixels of space will be between each point in the shape horizontally. + """ + if horizontal_spacing < self.gap: + logging.warning( + f"horizontal spacing is too small for the gap. defaulting to gap ({self.gap})." + ) + horizontal_spacing = self.gap + if vertical_spacing < self.gap: + logging.warning( + f"vertical spacing is too small for the gap. defaulting to gap ({self.gap})." + ) + vertical_spacing = self.gap + self.border_style = "Bidirectional" + min_x = self.boundingRect().left() + max_x = self.boundingRect().right() + min_y = self.boundingRect().top() + max_y = self.boundingRect().bottom() + + center_x = round((min_x + max_x) / 2) + center_y = round((min_y + max_y) / 2) + + visited = set() + queue = deque([(center_x, center_y)]) + visited.add((center_x, center_y)) + directions = [ + (0, -vertical_spacing), # up + (horizontal_spacing, 0), # right + (0, vertical_spacing), # down + (-horizontal_spacing, 0), # left + ] + + while queue and (num_points is None or len(self.ablation_points) < num_points): + curr_x, curr_y = queue.popleft() + self.ablation_points.append((curr_x, curr_y)) + + for dx, dy in directions: + new_x, new_y = curr_x + dx, curr_y + dy + if ( + self.containsPoint(QPoint(new_x, new_y), Qt.OddEvenFill) + and (new_x, new_y) not in visited + ): + queue.append((new_x, new_y)) + visited.add((new_x, new_y)) + if not self.ablation_points: + self.ablation_points.append((center_x, center_y)) + logging.warning("spacing configuration is too large for the shape.") + + def _pattern_spiral(self, num_points: int = None) -> None: + """adds a spiral pattern to the shape. + + Args: + num_points (int): determines how many points will be added to the shape. + """ + width = self.boundingRect().width() + height = self.boundingRect().height() + + max_radius = min(width, height) / 2 + + # starting with 4 turns at minimum + num_turns = 4 + distance_bt_turns = max_radius / num_turns + + # calculating number of turns based on gap + while distance_bt_turns < self.gap: + num_turns += 1 + distance_bt_turns = max_radius / num_turns + + theta = np.linspace(0, num_turns * np.pi, 1000) + radius = theta + + # converting polar -> cartesian coordinates + x = radius * np.cos(theta) + y = radius * np.sin(theta) + + # normalize coordinates to the bounding box + x = (x - x.min()) / (x.max() - x.min()) * width + y = (y - y.min()) / (y.max() - y.min()) * height + + # calculate cumulative arc length + arc_length = np.cumsum(np.sqrt(np.diff(x) ** 2 + np.diff(y) ** 2)) + arc_length = np.insert(arc_length, 0, 0) + + # finding maximum possible points based on min gap + total_length = arc_length[-1] + max_num_points = int(total_length // self.gap) + + # if num_points is too large for shape, default to max_num_points + if num_points > max_num_points: + print( + f"num_points: {num_points} is greater than max_num_points: {max_num_points}, defaulting to max_num_points." + ) + num_points = max_num_points + + # getting equidistant points along the arc length with at least gap distance b/w + target_lengths = np.linspace(0, total_length, num_points) + indices = np.searchsorted(arc_length, target_lengths) + indices = set(indices) + + for idx in range(len(x)): + plot_x = int(x[idx]) + self.boundingRect().left() + plot_y = int(y[idx]) + self.boundingRect().top() + point = QPoint(plot_x, plot_y) + if idx in indices: + if self.containsPoint(point, Qt.OddEvenFill): + self.ablation_points.append((plot_x, plot_y)) + else: + if self.containsPoint(point, Qt.OddEvenFill): + self.pattern_points.append((plot_x, plot_y)) From 0e82ee343e34b5931ee6fc33540fc7da3f8f1e71 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata Date: Mon, 19 Aug 2024 14:20:57 -0700 Subject: [PATCH 60/60] making the demo mode to False --- copylot/assemblies/photom/gui/photom_gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/copylot/assemblies/photom/gui/photom_gui.py b/copylot/assemblies/photom/gui/photom_gui.py index 98d5ea9d..f9d55c1d 100644 --- a/copylot/assemblies/photom/gui/photom_gui.py +++ b/copylot/assemblies/photom/gui/photom_gui.py @@ -50,7 +50,7 @@ def make_photom_assembly(config): if __name__ == "__main__": - DEMO_MODE = True + DEMO_MODE = False # TODO: grab the actual value if the camera is connected to photom_assmebly CAMERA_SENSOR_YX = (2048, 2448)