diff --git a/.github/workflows/install_package.yml b/.github/workflows/install_package.yml index 18df4cf..3ec1a06 100644 --- a/.github/workflows/install_package.yml +++ b/.github/workflows/install_package.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout glowtracker @@ -27,30 +27,9 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Debug action.. - run: | - echo "runner os: ${{ runner.os }}" - echo "matrix os: ${{ matrix.os }}" - name: Update pip - run: | - # The newest pip version (25.3) force support of PEP 517 and will cause error in pip-tools. - # Have to wait for a newer than version 7.5.1 piptools - python -m pip install --upgrade "pip<25.3" - python -m pip install --upgrade setuptools wheel pip-tools - - name: Compile requirements - # Generate the requirements.txt file listed in pyproject.toml - # NOTE: This step overwrites any previously existing requirements.txt file - run: - python -m piptools compile -o requirements.txt ${{ github.workspace }}/pyproject.toml --upgrade - - name: Install dependencies Linux/macOS - if: runner.os == 'Linux' || runner.os == 'macOS' - run: - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Install dependencies Windows - if: runner.os == 'Windows' - # Windows runner defaults to PowerShell (pwsh) - run: - if (Test-Path -Path requirements.txt) { pip install -r requirements.txt } - - name: Install glowtracker - run: - python -m pip install -e . \ No newline at end of file + run: python -m pip install --upgrade pip + - name: Install glowtracker and test dependencies + run: python -m pip install -e ".[test]" + - name: Run tests + run: python -m pytest -q diff --git a/.github/workflows/publish_package.yml b/.github/workflows/publish_package.yml index ed345bd..4048e6d 100644 --- a/.github/workflows/publish_package.yml +++ b/.github/workflows/publish_package.yml @@ -32,7 +32,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.12' # Updating pip and installing 'build'. - name: Install dependencies run: | @@ -48,4 +48,4 @@ jobs: # Publish to PyPI using Trusted Publisher (GitHub) # REF: https://docs.pypi.org/trusted-publishers/using-a-publisher/ - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 405fa83..6999ae4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,36 @@ -# VSCode .vscode/ +.idea/ +*.swp +*~ + +.DS_Store +._* +Thumbs.db + +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.pytype/ +.coverage +.coverage.* +htmlcov/ + +.venv/ +.venv-*/ +*.venv/ +venv/ +env/ -# Package building related build/ dist/ -glowtracker.egg-info/ - -# Python cache -__pycache__/* +*.egg-info/ +pip-wheel-metadata/ +/requirements.txt -# Convenience -*.log \ No newline at end of file +*.log +*.part.tiff +*-basler_*.tiff +*-coords.txt diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/BUILD.md b/BUILD.md index 3cee5eb..e8dd136 100644 --- a/BUILD.md +++ b/BUILD.md @@ -9,11 +9,11 @@ or ```bash uv build ``` -The resulting packages will be in `dist/`, `glowtracker-[version].tar.gz` and `glowtracker-[version]-py3-none-any.why`. +The resulting packages will be in `dist/` as `glowtracker-[version].tar.gz` and `glowtracker-[version]-py3-none-any.whl`. **Don't forget** to change the version accordingly. Following the convention of Major.Minor.Patch. # Testing installing the package -We can test the package by install directly and locally form +Test the package by installing the local source distribution: ```bash python -m pip install dist/glowtracker-[version].tar.gz ``` @@ -21,7 +21,7 @@ or ```bash uv pip install dist/glowtracker-[version].tar.gz ``` -Add `--python 3.10` to select a specific python version e.g. 3.10 +Add `--python 3.12` to select the recommended Python version. # Running @@ -32,4 +32,4 @@ python -m glowtracker or running the executable wrapper by simply type ```bash glowtracker -``` \ No newline at end of file +``` diff --git a/README.md b/README.md index be711e4..5605a64 100644 --- a/README.md +++ b/README.md @@ -18,61 +18,50 @@ ## Getting started ### Software Setup -1. Create a Python environment using **uv** (recommend) or **venv**. - - Using **uv** (Recommend) - 1. Install uv [[Link]](https://docs.astral.sh/uv/getting-started/installation/). - 2. Create a virtual environment - ```bash - uv venv glowtracker.venv - ``` - - Using **venv** - 1. Create the environment - ```bash - python -m venv glowtracker.venv - ``` - -2. Activate the environment +GlowTracker supports Python 3.11 through 3.13. Python 3.12 is the recommended version. + +1. Install [uv](https://docs.astral.sh/uv/getting-started/installation/). + +2. Install GlowTracker from PyPI: + ```bash - source glowtracker.venv/Scripts/activate + uv venv --python 3.12 + uv pip install glowtracker ``` -3. Install GlowTracker - You can choose to either install GlowTracker from a distributed Python package from PyPI or clone the git repository and run them locally. + Or install a development checkout using the locked dependencies: - - Using `pip` to install from PyPI repository: - ```bash - uv pip install glowtracker - ``` - - Or clone and run the package locally. - 1. Clone the pository - ```bash - git clone https://github.com/scholz-lab/GlowTracker.git - ``` - 2. Update the conda environment to download the dependencies - ```bash - cd Glowtracker; - uv pip install -r pyproject.toml; - ``` - - -4. Install the **BASLER** pylon software and runtime library [[Link]](https://www.baslerweb.com/en/software/pylon/) + ```bash + git clone https://github.com/scholz-lab/GlowTracker.git + cd GlowTracker + uv sync --extra test + uv run pytest -q + ``` + +3. Install the **BASLER** pylon software and runtime library [[Link]](https://www.baslerweb.com/en/software/pylon/) - pylon Camera Software Suite - pylon runtime library -5. (Optional) Install **Zaber Launcher** for inspecting and updating stage firmware [[Link]](https://software.zaber.com/zaber-launcher/download) +4. (Optional) Install **Zaber Launcher** for inspecting and updating stage firmware [[Link]](https://software.zaber.com/zaber-launcher/download) + +5. Start the application. + + - From an activated environment: -6. After finished installation, the software can be started in several ways - - If you have installed it via pip ```bash python -m glowtracker ``` - or simply + + or: + ```bash glowtracker ``` - - If you have installed it by cloning the package and running them locally + + - From a development checkout without activating the environment: + ```bash - python glowtracker/__main__.py + uv run glowtracker ``` ### Device Setup diff --git a/glowtracker/AutoFocus.py b/glowtracker/AutoFocus.py index 7984c94..0fcd18a 100644 --- a/glowtracker/AutoFocus.py +++ b/glowtracker/AutoFocus.py @@ -16,6 +16,8 @@ class FocusEstimationMethod(Enum): ModifiedLaplace = 'ModifiedLaplace' # Sum of High-Frequency DCT Coefficient SumOfHighDCT = 'SumOfHighDCT' + # Sum of strong gradients (Tenengrad thresholded at median * factor) + StrongTenengrad = 'StrongTenengrad' def estimateFocus(focusEstimationMethod: FocusEstimationMethod, image: np.ndarray) -> float: @@ -44,38 +46,52 @@ def estimateFocus(focusEstimationMethod: FocusEstimationMethod, image: np.ndarra shifted = np.roll(image, -2, axis=1) diff = (image - shifted)**2 estimatedFocus = np.sum(diff) - + elif focusEstimationMethod == FocusEstimationMethod.EnergyOfLaplacian: lap = cv2.Laplacian(image, cv2.CV_64F) estimatedFocus = np.sum(np.abs(lap)) - + elif focusEstimationMethod == FocusEstimationMethod.ModifiedLaplace: mlap = cv2.Laplacian(image, cv2.CV_64F, ksize=3) estimatedFocus = np.sum(np.abs(mlap)) - + elif focusEstimationMethod == FocusEstimationMethod.SumOfHighDCT: resized = cv2.resize(image, (32, 32)) # Small for fast DCT dct = cv2.dct(np.float32(resized)) hf_coeffs = dct[8:, 8:] # Keep only high-freq block estimatedFocus = np.sum(np.abs(hf_coeffs)) + elif focusEstimationMethod == FocusEstimationMethod.StrongTenengrad: + gx = cv2.Sobel(image, cv2.CV_64F, 1, 0) + gy = cv2.Sobel(image, cv2.CV_64F, 0, 1) + gmag = np.sqrt(gx**2 + gy**2) + gmag = gmag[gmag != 0] + if gmag.size > 0: + threshold = np.median(gmag) * 10.0 + strong = gmag[gmag > threshold] + estimatedFocus = float(np.sum(strong)) + return estimatedFocus class AutoFocusPID: def __init__( - self, - KP: float = 0.5, + self, + KP: float = 0.5, KI: float = 0.01, KD: float = 0.1, SP: float = 1000, focusEstimationMethod: FocusEstimationMethod = FocusEstimationMethod.SumOfHighDCT, - minStepDist: float = 0.0001, + minStepDist: float = 0.002, integralLifeTime: int = 0, smoothingWindow: int = 1, minStepBeforeChangeDir: int = 0, - acceptableErrorPercentage: float = 0.05 + acceptableErrorPercentage: float = 0.05, + coarseStep: float = 0.02, + peakEpsilonFrac: float = 0.02, + reacquireFraction: float = 0.7, + buffer_n = 5, ) -> None: """Initialize attributes @@ -97,7 +113,7 @@ def __init__( self.KD = KD self.SP: float = SP self.focusEstimationMethod = focusEstimationMethod - self.minStepDist: float = minStepDist + self.minStepDist: float = min(minStepDist, coarseStep / 2) self.integralLifeTime: int = integralLifeTime self.smoothingWindow: int = smoothingWindow # Blending weight for PV smoothing @@ -105,8 +121,13 @@ def __init__( self.WEIHT_MIN = 1 self.minStepBeforeChangeDir: int = minStepBeforeChangeDir self.acceptableErrorPercentage: float = acceptableErrorPercentage - - + + self.coarseStep: float = coarseStep + self.peakEpsilonFrac: float = peakEpsilonFrac + self.reacquireFraction: float = reacquireFraction + self.step: float = self.coarseStep + self.bestFocus: float = 0.0 + self.posLog: List[float] = [] self.focusLog: List[float] = [] self.errorLog: List[float] = [] @@ -114,84 +135,142 @@ def __init__( self.direction: int = 1 self.directionResetCounter = 0 + self.buffer_n: int = buffer_n + self.buffer: List[float] = [] - def executePIDStep(self, image: np.ndarray, pos: float) -> float: - """Perform one PID control step based on current image and lens position. + self.buffer = [] + self.buffer_n = buffer_n - Args: - image (np.ndarray): gray-scaled image - pos (float): stage z-axis position - Returns: - relPosZ (float): estimated **relative** z-axis position to move to - """ + # def executePIDStep(self, image: np.ndarray, pos: float) -> float: + # """Perform one PID control step based on current image and lens position. - # Estimate focus the image at current position - PV = estimateFocus(self.focusEstimationMethod, image) + # Args: + # image (np.ndarray): gray-scaled image + # pos (float): stage z-axis position - # Apply a linear, weighted average to PV with emphasis on recent data - focuses = [PV] - if self.smoothingWindow > 1: - focuses = self.focusLog[-(self.smoothingWindow - 1):] + focuses - focuses = np.array(focuses) + # Returns: + # relPosZ (float): estimated **relative** z-axis position to move to + # """ - # Compute linear weight - t = np.array([1]) + # # Estimate focus the image at current position + # PV = estimateFocus(self.focusEstimationMethod, image) - if (len(focuses) > 1): - t = np.arange(len(focuses)) / float( min(1, len(focuses) - 1) ) - - weights = self.WEIHT_MIN + (self.WEIGHT_MAX - self.WEIHT_MIN) * t + # # Apply a linear, weighted average to PV with emphasis on recent data + # focuses = [PV] + # if self.smoothingWindow > 1: + # focuses = self.focusLog[-(self.smoothingWindow - 1):] + focuses + # focuses = np.array(focuses) - PV = sum(focuses * weights) / sum(weights) + # # Compute linear weight + # t = np.array([1]) - # Compute error - err = self.SP - PV - U: float = 0.0 + # if (len(focuses) > 1): + # t = np.arange(len(focuses)) / float( min(1, len(focuses) - 1) ) - if len(self.focusLog) == 0: - # If this is the first time executing, simply move by a minimum distance - U = self.minStepDist * self.direction + # weights = self.WEIHT_MIN + (self.WEIGHT_MAX - self.WEIHT_MIN) * t - else: - prevErr = self.errorLog[-1] - # Here we assume t to be a discrete time of this function is call. Thus simplify the formula. - derivative = (err - prevErr) - - # If the PV is not close enough to the SP (percentage-wise), then execute - errorRatio = abs( PV / self.SP - 1.0) - if errorRatio > self.acceptableErrorPercentage: - - # PID calculations - if self.integralLifeTime > 0: - self.integral = np.sum(self.errorLog[-self.integralLifeTime:]) - else: - self.integral = np.sum(self.errorLog) + # PV = sum(focuses * weights) / sum(weights) - U = (self.KP * err) + (self.KI * self.integral) + (self.KD * derivative) + # # Compute error + # err = self.SP - PV + # U: float = 0.0 - # Decide direction. If the error is increasing then we should flip direction. - if self.directionResetCounter > self.minStepBeforeChangeDir: - - # Compute derivative of past error up to histLength - pastErrs = list(zip( self.errorLog[1:], self.errorLog ))[-(self.minStepBeforeChangeDir + 1):] - diffs = list( map( lambda x: x[0] - x[1], pastErrs ) ) + # if len(self.focusLog) == 0: + # # If this is the first time executing, simply move by a minimum distance + # U = self.minStepDist * self.direction - # The averaing error is increasing - if sum(diffs) > 0: - - self.direction = self.direction * -1 - self.directionResetCounter = 0 - - self.directionResetCounter += 1 + # else: + # prevErr = self.errorLog[-1] + # # Here we assume t to be a discrete time of this function is call. Thus simplify the formula. + # derivative = (err - prevErr) - U = U * self.direction - + # # If the PV is not close enough to the SP (percentage-wise), then execute + # errorRatio = abs( PV / self.SP - 1.0) + # if errorRatio > self.acceptableErrorPercentage: - # Record - self.focusLog.append(PV) - self.errorLog.append(err) - self.posLog.append(pos) + # # PID calculations + # if self.integralLifeTime > 0: + # self.integral = np.sum(self.errorLog[-self.integralLifeTime:]) + # else: + # self.integral = np.sum(self.errorLog) - return U + # U = (self.KP * err) + (self.KI * self.integral) + (self.KD * derivative) + + # # Decide direction. If the error is increasing then we should flip direction. + # if self.directionResetCounter > self.minStepBeforeChangeDir: + # # Compute derivative of past error up to histLength + # pastErrs = list(zip( self.errorLog[1:], self.errorLog ))[-(self.minStepBeforeChangeDir + 1):] + # diffs = list( map( lambda x: x[0] - x[1], pastErrs ) ) + + # # The averaing error is increasing + # if sum(diffs) > 0: + + # self.direction = self.direction * -1 + # self.directionResetCounter = 0 + + # self.directionResetCounter += 1 + + # U = U * self.direction + + + # # Record + # self.focusLog.append(PV) + # self.errorLog.append(err) + # self.posLog.append(pos) + + # return U + + def executePIDStep(self, image, pos) -> float: + PV = estimateFocus(self.focusEstimationMethod, image) + self.buffer.append(PV) + batch_ready = len(self.buffer) == self.buffer_n + if batch_ready: + PV = np.median(self.buffer) + self.buffer = [] + + # Apply a linear, weighted average to PV with emphasis on recent data + focuses = [PV] + if self.smoothingWindow > 1: + focuses = self.focusLog[-(self.smoothingWindow - 1):] + focuses + focuses = np.array(focuses) + + # Compute linear weight + t = np.array([1]) + + if (len(focuses) > 1): + t = np.arange(len(focuses)) / float( max(1, len(focuses) - 1) ) + + weights = self.WEIHT_MIN + (self.WEIGHT_MAX - self.WEIHT_MIN) * t + + PV = sum(focuses * weights) / sum(weights) + + if len(self.focusLog) == 0: + self.bestFocus = PV + self.directionResetCounter = 0 + else: + prevPV = self.focusLog[-1] + if PV < self.bestFocus * self.reacquireFraction: + self.step = self.coarseStep + self.directionResetCounter = 0 + self.bestFocus = PV + else: + if PV < prevPV * (1.0 - self.peakEpsilonFrac): + self.directionResetCounter += 1 + else: + self.directionResetCounter = 0 + if self.directionResetCounter > self.minStepBeforeChangeDir: + self.direction *= -1 + self.step = max(self.step * 0.75, self.minStepDist) + self.directionResetCounter = 0 + self.bestFocus = max(self.bestFocus, PV) + self.focusLog.append(PV) + self.posLog.append(pos) + + if batch_ready and self.step > self.minStepDist: + U = self.step * self.direction + else: + U = 0.0 + + return U diff --git a/glowtracker/Basler_control.py b/glowtracker/Basler_control.py index b5c2001..2fa8f6e 100644 --- a/glowtracker/Basler_control.py +++ b/glowtracker/Basler_control.py @@ -13,7 +13,7 @@ class CameraGrabParameters: grabStrategy: pylon.GrabStrategy_OneByOne | pylon.GrabStrategy_LatestImageOnly isContinuous: bool = True numberOfImagesToGrab: int = 1 - + class Camera(pylon.InstantCamera): @@ -42,12 +42,12 @@ def OnImagesSkipped(self, camera, countOfSkippedImages): def OnImageGrabbed(self, camera, grabResult): return True - + camera.RegisterImageEventHandler(ImageEventPrinter(), pylon.RegistrationMode_Append, pylon.Cleanup_Delete) - + # Open the connection camera.Open() - + # Print the model name of the camera. print("Using device", camera.GetDeviceInfo().GetModelName()) @@ -57,7 +57,7 @@ def OnImageGrabbed(self, camera, grabResult): # Cannot connect to the camera print(exception) return None - + def __init__(self, *args): # WARNING: Outsider should not use this as a way to create and connect to camera. @@ -74,15 +74,15 @@ def __init__(self, *args): # on image acquisition mode, but is on a pause. self.__isOnHold__ = True - + # Getters, Setters def isOnHold(self) -> bool: return self.__isOnHold__ - + def setIsOnHold(self, value): self.__isOnHold__ = value - + # Class functions def updateProperties(self, propfile): @@ -102,41 +102,42 @@ def retrieveGrabbingResult(self) -> Tuple[ bool, np.ndarray, int, int]: isSuccess (bool): boolean indicate if the retrieving is successful img (np.array): the retrieved image timestamp (int): time stamp when the result is captured by camera internal clock - retrieveTimestamp (int): time stamp when the result is received via time.perf_counter() + retrieveTimestamp (int): time stamp when the result is received via time.perf_counter() """ isSuccess = False img = None timestamp = None retrieveTimestamp = None - + if self.IsGrabbing(): try: # Retrieve an image # The function pylon.InstantCamera is not well-ported to Python API. # If the grab is succeeded it will return pylon.GrabResult object. # Otherwise, it will return False. - grabResult: pylon.GrabResult | bool = self.RetrieveResult(1000, pylon.TimeoutHandling_Return) - - if isinstance(grabResult, bool) and grabResult == False: - pass - - else: - # Need to double check - if grabResult.GrabSucceeded(): - - isSuccess = True - img = grabResult.Array - retrieveTimestamp = time.perf_counter() - conversion_factor = 1e6 # for conversion in ms - timestamp = round(grabResult.TimeStamp/conversion_factor, 1) + grabResult: pylon.GrabResult | bool = self.RetrieveResult( + 1000, pylon.TimeoutHandling_Return + ) + + if not isinstance(grabResult, bool): + try: + if grabResult.GrabSucceeded(): + img = np.array(grabResult.Array, copy=True) + retrieveTimestamp = time.perf_counter() + conversion_factor = 1e6 # for conversion in ms + timestamp = round( + grabResult.TimeStamp / conversion_factor, 1 + ) + isSuccess = True + finally: grabResult.Release() except genicam.RuntimeException as e: - # An exception is thrown here when trying to access a grab result while the camera + # An exception is thrown here when trying to access a grab result while the camera # aquisition is being shut down. This can happen when the acquisition is happening # in a thread and failed to synchronize with the main thread in time. pass - + except Exception as e: # Report other error behaviors for better handling print(f'Camera::retrieveGrabbingResult -- {e}') @@ -167,7 +168,7 @@ def setROI(self, ROI_w: int, ROI_h: int, isCenter: bool= True) -> Tuple[int, int height (int): the actual camera ROI width that has been set width (int): the actual camera ROI width that has been set """ - + if ROI_w <= self.Width.Max and ROI_h <= self.Height.Max: # Set camera on hold flag @@ -184,8 +185,8 @@ def setROI(self, ROI_w: int, ROI_h: int, isCenter: bool= True) -> Tuple[int, int self.Height = max(ROI_h, self.Height.Min) if isCenter: - - # Compute additional offset from the previous offset + + # Compute additional offset from the previous offset additionalOffsetX = (prevCameraWidth - self.Width())//2 additionalOffsetY = (prevCameraHeight - self.Height())//2 @@ -203,7 +204,7 @@ def setROI(self, ROI_w: int, ROI_h: int, isCenter: bool= True) -> Tuple[int, int # Set the camera offset self.OffsetX.Value = offsetX self.OffsetY.Value = offsetY - + # grab lock self.TLParamsLocked.Value = True # cam start @@ -248,11 +249,11 @@ def setFramerate(self, fps: float) -> float: Returns: fps (float): the resulting framerate """ - + self.AcquisitionFrameRateEnable.Value = True self.AcquisitionFrameRate.Value = float(fps) return self.ResultingFrameRate() - + def getAllFeatures(self) -> dict[str, any]: """Get all current camera's features. @@ -268,13 +269,13 @@ def getAllFeatures(self) -> dict[str, any]: for IValue in IValues: try: - + # Check if it's one of the type we're interested in if type(IValue) in [genicam.IBoolean, genicam.IInteger, genicam.IBoolean, genicam.IString]: # Check if the node that holds the value is a feature node node: genicam.INode = IValue.GetNode() - + if node.IsFeature(): try: @@ -302,7 +303,7 @@ def saveImage(im: np.ndarray, path: str, fname: str, isFlipY: bool= False) -> No path (str): image path fname (str): image file name isFlipY (bool, optional): _description_. Defaults to False. - """ + """ img = im if isFlipY: @@ -310,7 +311,7 @@ def saveImage(im: np.ndarray, path: str, fname: str, isFlipY: bool= False) -> No try: imsave(os.path.join(path, fname), img, check_contrast=False) - + except FileNotFoundError as e: print(e) @@ -322,30 +323,31 @@ def readPFSFile(filepath: str) -> Dict[str, str] | None: filepath (str): the .pfs file path Returns: + Dict[str, str] | None: A string dictionary contains the configuration key and value. The value is always parsed as a string, so if it is number or other type, it would need to be converted manully before use. Return None if the reading or parsing is unsuccessfull. """ - + parsedDict = {} - + try: with open(filepath, 'r') as file: - + for line in file: # Strip unnescessary spaces line = line.strip() - + # Skip comment if line.startswith('#'): continue # Split words (separate by blanks) parts = line.split() - + key = parts[0] value = parts[-1] parsedDict[key] = value @@ -354,9 +356,8 @@ def readPFSFile(filepath: str) -> Dict[str, str] | None: except FileNotFoundError: print(f"Error: File '{filepath}' is not found.") - + except Exception as e: print(e) - - return None + return None diff --git a/glowtracker/DAQ_control.py b/glowtracker/DAQ_control.py index 3d03aef..6bd81f1 100644 --- a/glowtracker/DAQ_control.py +++ b/glowtracker/DAQ_control.py @@ -1,18 +1,17 @@ from __future__ import annotations +import ast import LabJackPython import u3 -import re from enum import Enum from collections import OrderedDict from copy import deepcopy from typing import List -from Microscope_macros import Vertex2D, Exterior +from Microscope_macros import Vertex2D, Exterior, computeAngleBetweenTwo2DVecs import numpy as np import math from matplotlib import pyplot as plt from matplotlib.backends.backend_agg import FigureCanvasAgg from dataclasses import dataclass -from Microscope_macros import computeAngleBetweenTwo2DVecs class DAQMode(Enum): Off = 'Off' @@ -49,9 +48,9 @@ def createAndConnectDaq(cls) -> DAQControl | None: # Instantiate DAQConatrol object daqControl.daq = daq - + print(f"Using {daqControl.daq.deviceName}, serial: {daqControl.daq.serialNumber}") - + # Set to factory default daqControl.daq.setDefaults() # Calibrate @@ -59,7 +58,7 @@ def createAndConnectDaq(cls) -> DAQControl | None: except Exception as e: print(e) - + finally: return daqControl @@ -74,102 +73,185 @@ def __init__(self): self.daqStageProgram: DAQStageProgram = DAQStageProgram() self.reversalDetector: ReversalDetector = ReversalDetector() self.currentVoltage: float = 0 - + def isConnected(self) -> bool: return self.daq is not None - def close(self): - if self.isConnected(): - # Check if Windows then call LabJackPython.Close(), else call self.daq.close() - self.daq.close() - self.daq = None + def safe_off(self) -> bool: + if not self.isConnected(): + self.currentVoltage = 0 + return True + + try: + dac0Val = self.daq.voltageToDACBits( + volts=0.0, dacNumber=0, is16Bits=False + ) + dac1Val = self.daq.voltageToDACBits( + volts=0.0, dacNumber=1, is16Bits=False + ) + self.daq.getFeedback( + u3.DAC0_8(dac0Val), + u3.DAC1_8(dac1Val), + ) + return True + except Exception as e: + print(f'Setting DAQ outputs to zero failed: {e}') + safe = True + for dacNumber, commandType in ((0, u3.DAC0_8), (1, u3.DAC1_8)): + try: + value = self.daq.voltageToDACBits( + volts=0.0, dacNumber=dacNumber, is16Bits=False + ) + self.daq.getFeedback(commandType(value)) + except Exception as channel_error: + safe = False + print( + f'Setting DAQ{dacNumber} to zero failed: ' + f'{channel_error}' + ) + return safe + finally: + self.sequnceDictRunning.clear() + self.currentVoltage = 0 + + + def close(self) -> bool: + if not self.isConnected(): + return True + + daq = self.daq + safe = False + try: + safe = self.safe_off() + finally: + try: + daq.close() + except Exception as e: + print(f'Closing DAQ connection failed: {e}') + finally: + self.daq = None + + return safe + - def start(self, startRecordPosition: np.ndarray): """Reset internal command dict to original to prepare for running. """ self.sequnceDictRunning = deepcopy(self.sequncerDict) self.daqStageProgram.startRecordPosition = startRecordPosition - - + + def reset(self): """Set DAQ values to factory default. Should be call after finished executing a command list. """ if not self.isConnected(): return - # Set to factory default - self.daq.setDefaults(SetToFactoryDefaults= True) - - # Manually set DAC0 to 0 (off) - dac0Val = self.daq.voltageToDACBits(volts= 0, dacNumber= 0, is16Bits= False) - dac0Command = u3.DAC0_8(dac0Val) - self.daq.getFeedback(dac0Command) - - # Clean running command queue - self.sequnceDictRunning.clear() - + try: + self.daq.setDefaults(SetToFactoryDefaults=True) + finally: + self.safe_off() + self.daqStageProgram.startRecordPosition = np.zeros([2], np.float32) - self.currentVoltage = 0 - - def parseTextScript(self, text: str) -> None: + def setDAC1(self, volts: float) -> None: + if not self.isConnected(): + return + volts = max(min(volts, 4.95), 0) + dac1Val = self.daq.voltageToDACBits(volts= volts, dacNumber= 1, is16Bits= False) + self.daq.getFeedback(u3.DAC1_8(dac1Val)) + + + def parseTextScript(self, text: str) -> None: try: - # Remove empty lines and surrounding whitespace lines = [line.strip() for line in text.splitlines() if line.strip()] - - # Remove trailing commas from each line - lines = [re.sub(r',$', '', line) for line in lines] - - # Wrap into a dict literal - preprocessdText = "{\n" + ",\n".join(lines) + "\n}" - - # Parse the text to be a dict object. Highlight keywords "on", "off" - processedDict = eval(preprocessdText, { - "on": "on", - "off": "off", - "mode": "mode", - "frame": "frame", - "time": "time" - }) - - # Check if empty - if len(processedDict) == 0: + lines = [line[:-1].rstrip() if line.endswith(',') else line for line in lines] + if not lines: self.sequncerDict.clear() return - - # Get running mode - mode = processedDict.pop('mode')[0] - - # Sort and convert to OrderedDict - self.sequncerDict = OrderedDict( {key:val for key, val in sorted(processedDict.items(), key= lambda x: x[0])} ) - - if mode == 'frame': - self.sequencerMode = SequencerMode.Frame - - elif mode == 'time': - self.sequencerMode = SequencerMode.Time - - else: - raise ValueError(f"Failed to parse DAQ script text: Invalid 'mode' argument. Options are ['frame', 'time']") - + expression = ast.parse("{\n" + ",\n".join(lines) + "\n}", mode='eval') + processedDict = self._parseScriptNode(expression.body) + if not isinstance(processedDict, dict): + raise ValueError('script must contain key-value entries') + if 'mode' not in processedDict: + raise ValueError("missing 'mode' entry") + modeValue = processedDict.pop('mode') + if not isinstance(modeValue, (list, tuple)) or len(modeValue) != 1: + raise ValueError("'mode' must be [frame] or [time]") + mode = modeValue[0] + if mode not in ('frame', 'time'): + raise ValueError("'mode' must be [frame] or [time]") + + commands = {} + for trigger, command in processedDict.items(): + if isinstance(trigger, bool) or not isinstance(trigger, (int, float)): + raise ValueError('command keys must be numeric') + if not math.isfinite(trigger) or trigger < 0: + raise ValueError('command keys must be finite and non-negative') + if mode == 'frame' and (not isinstance(trigger, int) or isinstance(trigger, bool)): + raise ValueError('frame command keys must be integers') + commands[trigger] = self._validateScriptCommand(command) + + self.sequncerDict = OrderedDict(sorted(commands.items())) + self.sequencerMode = SequencerMode.Frame if mode == 'frame' else SequencerMode.Time except Exception as e: raise ValueError(f"Failed to parse DAQ script text: {e}") - - def update(self, frameNum: int = 0, frameTime: float = 0, stagePosition: List[float] = [], posHist: np.ndarray = None) -> None: + + @staticmethod + def _parseScriptNode(node): + if isinstance(node, ast.Dict): + result = {} + for keyNode, valueNode in zip(node.keys, node.values): + key = DAQControl._parseScriptNode(keyNode) + if key in result: + raise ValueError(f'duplicate key {key!r}') + result[key] = DAQControl._parseScriptNode(valueNode) + return result + if isinstance(node, (ast.List, ast.Tuple)): + return [DAQControl._parseScriptNode(value) for value in node.elts] + if isinstance(node, ast.Constant) and isinstance(node.value, (str, int, float)): + return node.value + if isinstance(node, ast.Name) and node.id in {'mode', 'frame', 'time', 'on', 'off'}: + return node.id + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)): + value = DAQControl._parseScriptNode(node.operand) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError('signs may only be applied to numbers') + return value if isinstance(node.op, ast.UAdd) else -value + raise ValueError(f'unsupported syntax: {type(node).__name__}') + + + @staticmethod + def _validateScriptCommand(command): + if not isinstance(command, (list, tuple)) or not command: + raise ValueError("commands must be [off] or [on, voltage]") + if command[0] == 'off' and len(command) == 1: + return ['off'] + if command[0] == 'on' and len(command) == 2: + voltage = command[1] + if isinstance(voltage, bool) or not isinstance(voltage, (int, float)): + raise ValueError('voltage must be numeric') + if not math.isfinite(voltage) or not 0 <= voltage <= 4.95: + raise ValueError('voltage must be between 0 and 4.95') + return ['on', float(voltage)] + raise ValueError("commands must be [off] or [on, voltage]") + + + def update(self, frameNum: int = 0, frameTime: float = 0, stagePosition: List[float] | None = None, posHist: np.ndarray | None = None) -> None: if self.daqMode == DAQMode.Off: return - + elif self.daqMode == DAQMode.Sequencer: self.updateSequencer(frameNum= frameNum, frameTime= frameTime) - + elif self.daqMode == DAQMode.StageProgram: - self.updateStageProgram(stagePosition) - + if stagePosition is not None: + self.updateStageProgram(stagePosition) + elif self.daqMode == DAQMode.Reversal: self.updateReversalDetection(posHist) @@ -182,16 +264,16 @@ def updateSequencer(self, frameNum: int = 0, frameTime: float = 0) -> None: return if self.sequencerMode == SequencerMode.Frame: - + # Get the exact frame command frameCommand = self.sequnceDictRunning.pop(frameNum, default= None) if frameCommand is not None: print(f"Frame {frameNum}:") self._executeCommand(frameCommand) - + elif self.sequencerMode == SequencerMode.Time: - + # Get the first (lowest frame time) command in queue commandFrameTime = next(iter(self.sequnceDictRunning)) @@ -209,14 +291,14 @@ def updateSequencer(self, frameNum: int = 0, frameTime: float = 0) -> None: # If the command queue is now empty then stop if len(self.sequnceDictRunning) == 0: break - + # Get the next one commandFrameTime = next(iter(self.sequnceDictRunning)) # If the next commandFrameTime is already higher then break if commandFrameTime > frameTime: break - + if len(commands) > 0: # Execute the last command (closest to the frame time) commandFrameTime, frameCommand = commands[-1] @@ -227,38 +309,43 @@ def updateSequencer(self, frameNum: int = 0, frameTime: float = 0) -> None: def updateStageProgram(self, stagePosition: List[float]) -> None: # We want to evalute this vol = self.daqStageProgram.getValue(stagePosition[0], stagePosition[1]) - + if math.isclose(vol, 0): self._executeCommand(frameCommand= ['off']) else: self._executeCommand(frameCommand= ['on', vol]) - - def updateReversalDetection(self, posHist: np.ndarray) -> None: - # Convert posHist to numpy and discard the z-axis position - # and update unit from mm to meter. - trail = np.array(posHist)[:, (0, 1)] * 1e3 + + def updateReversalDetection(self, posHist: np.ndarray | None) -> None: + trail = np.asarray(posHist if posHist is not None else [], dtype=float) + if trail.ndim != 2 or trail.shape[1] < 2: + trail = np.empty((0, 2), dtype=float) + else: + trail = trail[:, :2] isReversing = self.reversalDetector.detectReversal(trail= trail) vol = self.reversalDetector.reversalVoltage if isReversing else self.reversalDetector.forwardVoltage + if math.isclose(vol, self.currentVoltage): + return + if math.isclose(vol, 0): self._executeCommand(frameCommand= ['off']) else: self._executeCommand(frameCommand= ['on', vol]) - + def _executeCommand(self, frameCommand: list) -> None: - + command: list = frameCommand[0] if command == 'on': if len(frameCommand) != 2: print("\"on\" command requires a voltage argument.") return - + vol = frameCommand[1] # TODO: This should be in the setting to support High-voltage DAQ @@ -267,20 +354,18 @@ def _executeCommand(self, frameCommand: list) -> None: print(f"Light on {vol} vol") - # Send command to DAQ at DAC0 dac0Val = self.daq.voltageToDACBits(volts= vol, dacNumber= 0, is16Bits= False) - dac0Command = u3.DAC0_8(dac0Val) - self.daq.getFeedback(dac0Command) + dac1Val = self.daq.voltageToDACBits(volts= vol, dacNumber= 1, is16Bits= False) + self.daq.getFeedback(u3.DAC0_8(dac0Val), u3.DAC1_8(dac1Val)) self.currentVoltage = vol - + elif command == 'off': - + print(f"Light off") - # Send command to DAQ at DAC0 dac0Val = self.daq.voltageToDACBits(volts= 0, dacNumber= 0, is16Bits= False) - dac0Command = u3.DAC0_8(dac0Val) - self.daq.getFeedback(dac0Command) + dac1Val = self.daq.voltageToDACBits(volts= 0, dacNumber= 1, is16Bits= False) + self.daq.getFeedback(u3.DAC0_8(dac0Val), u3.DAC1_8(dac1Val)) self.currentVoltage = 0 @@ -304,16 +389,16 @@ def __init__(self): self.gaussianParams = GaussianParams() self.isGaussianRelative = False self.startRecordPosition = np.zeros([2], np.float32) - - + + def update( - self, - mode: StageProgramMode | None = None, - quadVertex: List[Vertex2D] | None = None, - exterior: Exterior | None = None, - exteriorConstant: float | None = None, + self, + mode: StageProgramMode | None = None, + quadVertex: List[Vertex2D] | None = None, + exterior: Exterior | None = None, + exteriorConstant: float | None = None, isFourPointRelative: bool | None = None, - gaussianParams: GaussianParams | None = None, + gaussianParams: GaussianParams | None = None, isGaussianRelative: bool | None = None ) -> None: """Parse variables and process them. @@ -327,7 +412,7 @@ def update( if mode: self.mode = mode - + if quadVertex: self.quadVertex = quadVertex @@ -340,23 +425,23 @@ def update( # Sort self.quadVertex.sort(key= lambda vertex: math.atan2(vertex.point[1] - center[1], vertex.point[0] - center[0])) - + if exterior: self.exterior = exterior - - if exteriorConstant: + + if exteriorConstant is not None: self.exteriorConstant = exteriorConstant - + if isFourPointRelative is not None: self.isFourPointRelative = isFourPointRelative - + if gaussianParams: self.gaussianParams = gaussianParams - - if isGaussianRelative is not None: + + if isGaussianRelative is not None: self.isGaussianRelative = isGaussianRelative - - + + def getValue(self, x: float, y: float) -> float: """Get an interpolated signal value at a given stage position. @@ -376,18 +461,18 @@ def getValue(self, x: float, y: float) -> float: if self.isFourPointRelative: currentPosition = currentPosition - self.startRecordPosition self.quadVertex[0].point - + val = Vertex2D.bilerp( - self.quadVertex[0], - self.quadVertex[1], - self.quadVertex[2], - self.quadVertex[3], - currentPosition, - self.exterior, + self.quadVertex[0], + self.quadVertex[1], + self.quadVertex[2], + self.quadVertex[3], + currentPosition, + self.exterior, self.exteriorConstant ) - + elif self.mode == StageProgramMode.Gaussian: if not (math.isclose(self.gaussianParams.x_sigma, 0.0) or math.isclose(self.gaussianParams.y_sigma, 0.0)): @@ -409,7 +494,7 @@ def getValue(self, x: float, y: float) -> float: -((distance[1])**2 / (2 * (self.gaussianParams.y_sigma**2))) ) ) - + # TODO: This should be in the setting to support High-voltage DAQ # Clamp between 0, 5 vol val = min(max(0, val), 5) @@ -427,12 +512,12 @@ def generateValueMapPlot(self)-> np.ndarray: isRelativeToStart = (self.mode == StageProgramMode.FourPoint and self.isFourPointRelative) \ or (self.mode == StageProgramMode.Gaussian and self.isGaussianRelative) - + # Allocate value map valMapShape = [stageRange[0] + 1, stageRange[1] + 1, 1] if isRelativeToStart: valMapShape = [stageRange[0]*2 + 1, stageRange[1]*2 + 1, 1] - + valMap = np.zeros(valMapShape) # Compute value map @@ -441,7 +526,7 @@ def generateValueMapPlot(self)-> np.ndarray: y = j if isRelativeToStart: y = y - stageRange[0] - + for i in range(valMap.shape[1]): x = i @@ -449,11 +534,11 @@ def generateValueMapPlot(self)-> np.ndarray: x = x - stageRange[1] valMap[j, i] = self.getValue(x, y) - + # Create the plot plt.ioff() fig = plt.figure(figsize=(6, 6)) - + # Plot map extent = None if isRelativeToStart: @@ -461,21 +546,21 @@ def generateValueMapPlot(self)-> np.ndarray: im = plt.imshow(valMap, cmap= 'magma', extent= extent) plt.colorbar(im) - + # Plot landmarks def drawPointWithAnnotation(point: List[float], color: str, name: str) -> None: plt.scatter(point[0], point[1], c= color) plt.annotate(name, (point[0], point[1]), textcoords= 'offset points', xytext= (10,10), ha= 'center', fontsize= 12, color= 'green') - + if self.mode == StageProgramMode.FourPoint: drawPointWithAnnotation(self.quadVertex[0].point, 'r', self.quadVertex[0].name) drawPointWithAnnotation(self.quadVertex[1].point, 'r', self.quadVertex[1].name) drawPointWithAnnotation(self.quadVertex[2].point, 'r', self.quadVertex[2].name) drawPointWithAnnotation(self.quadVertex[3].point, 'r', self.quadVertex[3].name) - + elif self.mode == StageProgramMode.Gaussian: drawPointWithAnnotation([self.gaussianParams.x_mean, self.gaussianParams.y_mean], 'r', 'Mean') - + if isRelativeToStart: drawPointWithAnnotation([0, 0], 'r', 'Start Pos') @@ -487,7 +572,7 @@ def drawPointWithAnnotation(point: List[float], color: str, name: str) -> None: topRight = np.zeros([2], np.float32) if self.mode == StageProgramMode.FourPoint: - + for vertex in self.quadVertex: btmLeft = np.where(btmLeft > vertex.point, vertex.point, btmLeft) topRight = np.where(topRight < vertex.point, vertex.point, topRight) @@ -505,15 +590,15 @@ def drawPointWithAnnotation(point: List[float], color: str, name: str) -> None: # Also check bound with origin btmLeft = np.where(btmLeft > np.zeros([2]), np.zeros([2]), btmLeft) topRight = np.where(topRight < np.zeros([2]), np.zeros([2]), topRight) - + # Add padding btmLeft = btmLeft - 10 topRight = topRight + 10 - + plt.xlim(btmLeft[0], topRight[0]) plt.ylim(btmLeft[1], topRight[1]) - + else: plt.xlim(0, stageRange[0]) plt.ylim(0, stageRange[1]) @@ -546,7 +631,7 @@ def drawPointWithAnnotation(point: List[float], color: str, name: str) -> None: class ReversalDetector(): - + def __init__(self): self.isReversing: bool = False self.animalLength_mm: float = 0 @@ -556,20 +641,26 @@ def __init__(self): self.reversalVoltage: float = 0 self.forwardVoltage: float = 0 - + def detectReversal(self, trail: np.ndarray) -> bool: - - # Get last M (trial limit) vertices and + trail = np.asarray(trail, dtype=float) + limit = int(self.trailLimit) + if trail.ndim != 2 or trail.shape[1] < 2 \ + or len(trail) < 2 or limit < 2: + self.isReversing = False + return False + + # Get last M (trial limit) vertices and # apply transformation to each row vertex - croppedTrail = trail[-self.trailLimit::, :] + croppedTrail = trail[-limit:, :2] # Greedy sums up until equal or exceed animal's length # Get a reversed view: from bottom (most recent/head) to top (first point in the history) revTrail = croppedTrail[::-1] sumLength = 0 - + tailIndex = 0 - + for i in range(1, len(revTrail)): length = np.linalg.norm(revTrail[i-1] - revTrail[i]) sumLength = sumLength + length @@ -577,7 +668,7 @@ def detectReversal(self, trail: np.ndarray) -> bool: if sumLength >= self.animalLength_mm: break - + # Copy points from head to tail # We now have bodyVert: Bx2 (B:= body length), rows of point from head to tail bodyVert = revTrail[0:tailIndex+1:1] @@ -585,10 +676,11 @@ def detectReversal(self, trail: np.ndarray) -> bool: # Atleast two vertices if len(bodyVert) > 1: - # + # # Estimate velocity - # + # numHistVert = round(len(bodyVert) * self.velocityHistoryPercentage / 100) + numHistVert = max(2, min(len(bodyVert), numHistVert)) # Slice from head to numHistVert histVert = bodyVert[0:numHistVert] @@ -596,20 +688,21 @@ def detectReversal(self, trail: np.ndarray) -> bool: velocities = histVert[0:-1] - histVert[1:] # Uniform weighted average - velocity = np.sum(velocities, axis= 0) / len(velocities) + velocity = np.mean(velocities, axis=0) # Check if the velocity is angling more than the reversal threshold with the the tailToHead body. # If yes, reversal -> red color. # If not, non-reversal -> green color. vecTailToHead = bodyVert[0] - bodyVert[-1] + if np.linalg.norm(vecTailToHead) == 0 or np.linalg.norm(velocity) == 0: + self.isReversing = False + return False angle_radian = computeAngleBetweenTwo2DVecs(vecTailToHead, velocity) angle_degree = angle_radian * 180 / math.pi - if angle_degree > self.reversalThresholdRadian or angle_degree < -self.reversalThresholdRadian: - self.isReversing = True + self.isReversing = abs(angle_degree) > abs(self.reversalThresholdRadian) + else: + self.isReversing = False - else: - self.isReversing = False - return self.isReversing diff --git a/glowtracker/GlowTracker.py b/glowtracker/GlowTracker.py index 826b432..4d0bf25 100644 --- a/glowtracker/GlowTracker.py +++ b/glowtracker/GlowTracker.py @@ -1,4 +1,25 @@ from __future__ import annotations +import sys +import multiprocessing as mp +from multiprocessing.managers import SharedMemoryManager +from SharedMemory import SharedMemoryQueue +from scan import CenterRadiusFromThreePoints +import image_saver +from image_utils import prepare_texture_data +from runtime_control import ( + ManagedStageMove, + append_new_focus_values, + controller_velocity, +) +from threading import Thread, Lock, Event, current_thread + +USE_SHARED_MEMORY_SAVER = sys.platform != 'win32' +SAVE_HANDOFF_JOIN_TIMEOUT = 2.0 +SAVE_ACQUISITION_JOIN_TIMEOUT = 5.0 +SAVE_WORKER_JOIN_TIMEOUT = 15.0 +SAVE_WORKER_TERMINATE_TIMEOUT = 2.0 +SAVE_STATUS_JOIN_TIMEOUT = 2.0 +COORDINATE_CLOSE_TIMEOUT = 2.0 import os # Suppress kivy normal initialization logs in the beginning @@ -7,9 +28,13 @@ # Emulate camera # os.environ["PYLON_CAMEMU"] = "1" -# +if __name__ == '__mp_main__': + os.environ.setdefault('SDL_VIDEODRIVER', 'dummy') + os.environ.setdefault('SDL_AUDIODRIVER', 'dummy') + +# # Kivy Imports -# +# import kivy # Require modern version kivy.require('2.0.0') @@ -28,43 +53,39 @@ from kivy.graphics.texture import Texture from kivy.graphics.transformation import Matrix from kivy.factory import Factory -from kivy.properties import ObjectProperty, StringProperty, BoundedNumericProperty, NumericProperty, ConfigParserProperty, ListProperty +from kivy.properties import ObjectProperty, StringProperty, NumericProperty, ConfigParserProperty, ListProperty from kivy.clock import Clock, ClockEvent, mainthread -from kivy.metrics import Metrics -from kivy.uix.screenmanager import ScreenManager, Screen from kivy.uix.button import Button from kivy.uix.togglebutton import ToggleButton from kivy.uix.label import Label from kivy.uix.widget import Widget from kivy.uix.image import Image from kivy.uix.scatterlayout import ScatterLayout -from kivy.uix.scatter import Scatter from kivy.uix.tabbedpanel import TabbedPanel from kivy.uix.boxlayout import BoxLayout from kivy.uix.gridlayout import GridLayout -from kivy.uix.anchorlayout import AnchorLayout from kivy.uix.floatlayout import FloatLayout from kivy.uix.stencilview import StencilView from kivy.uix.popup import Popup from kivy.uix.settings import SettingsWithSidebar, SettingItem, SettingNumeric from kivy.uix.textinput import TextInput from kivy.uix.codeinput import CodeInput -from kivy.uix.slider import Slider -from kivy.uix.behaviors import DragBehavior, FocusBehavior +from kivy.uix.behaviors import DragBehavior from kivy.uix.switch import Switch from kivy.uix.spinner import Spinner from kivy.uix.stacklayout import StackLayout -# +# # IO, Utils -# +# +import asyncio import datetime +import json import time -from pathlib import Path from threading import Thread, Lock -from multiprocessing.pool import ThreadPool -from functools import partial -from queue import Queue +# from multiprocessing.pool import ThreadPool +# from functools import partial +from queue import Queue, Full, Empty from overrides import override from typing import List, Tuple from io import TextIOWrapper @@ -72,16 +93,16 @@ from zaber_motion.units import Units, units_from_literals from zaber_motion.unit_table import UnitTable from pypylon import pylon -import platformdirs +import platformdirs import shutil from pyparsing import ParseException import matplotlib.pyplot as plt from dataclasses import dataclass from copy import deepcopy -# +# # Own classes -# +# from Zaber_control import Stage, AxisEnum, Vec3 import Microscope_macros as macro from Microscope_macros import Vertex2D @@ -90,17 +111,24 @@ from AutoFocus import AutoFocusPID, FocusEstimationMethod from DAQ_control import DAQControl, DAQMode, StageProgramMode, GaussianParams -# +# # Math -# +# import math import numpy as np -from skimage.io import imsave import cv2 from scipy.stats import skew import gc +@dataclass +class Plate: + name: str + center: tuple + radius: float + scan_z: float + scan_exposure: float + track_exposure: float # helper functions def timeStamped(fname, fmt='%Y-%m-%d-%H-%M-%S-%f-{fname}'): @@ -116,19 +144,19 @@ def imageToTexture(image: np.ndarray) -> Texture: Returns: texture (Texture): image as a Kivy Texture - """ + """ + image, bufferfmt = prepare_texture_data(image) height, width = image.shape[0], image.shape[1] - - colorfmt = 'luminance' + if image.ndim == 2: colorfmt = 'luminance' - elif image.ndim == 3: + elif image.ndim == 3 and image.shape[2] == 3: colorfmt = 'rgb' - elif image.ndim == 4: + elif image.ndim == 3 and image.shape[2] == 4: colorfmt = 'rgba' - - bufferfmt = 'ubyte' - + else: + raise ValueError(f'unsupported image shape: {image.shape}') + # Create a new Kivy Texture image_texture = Texture.create( size=(width, height), colorfmt= colorfmt, bufferfmt= bufferfmt @@ -136,7 +164,7 @@ def imageToTexture(image: np.ndarray) -> Texture: # Kivy texture is in OpenGL corrindate which is btm-left origin so we need to flip texture coord once to match numpy's top-left image_texture.flip_vertical() - + # Upload data to texture buf = image.tobytes() image_texture.blit_buffer(buf, colorfmt= colorfmt, bufferfmt= bufferfmt) @@ -178,7 +206,7 @@ def __init__(self, **kwargs): # Camera config value self.cameraConfig: dict[str:any] = dict() - + Clock.schedule_once(self._do_setup) def _do_setup(self, *l): @@ -203,10 +231,10 @@ def createRecordingPath(self) -> None: self.savefile = absPath print(f'Set recording path to {self.savefile}') - + # reset the stage keys self.app.bind_keys() - + def dismiss_popup(self): self.app.bind_keys() @@ -293,7 +321,7 @@ def autoFocusButtonCallback(self): self._popup = WarningPopup(title="Autofocus", text='Autofocus requires a stage and a running camera!', size_hint=(0.5, 0.25)) self._popup.open() - + else: # Check if acquiring image @@ -304,32 +332,7 @@ def autoFocusButtonCallback(self): prevLiveViewButtonState: str = liveViewButton.state liveViewButton.state = 'normal' - # Load settings - depthoffield = self.app.config.getfloat('Camera', 'depthoffield') - depthoffieldsearchdistance = self.app.config.getfloat('Calibration', 'depthoffieldsearchdistance') - dualColorMode = self.app.config.getboolean('DualColor', 'dualcolormode') - dualColorModeMainSide = self.app.config.get('DualColor', 'mainside') - capturedRadius = self.app.config.getint('Tracking', 'capture_radius') - focusEstimationMethod = FocusEstimationMethod(self.app.config.get('Autofocus', 'focusestimationmethod')) - - # Reuse DepthOfFieldEstimator to scan and search for the best focus position - depthOfFieldEstimator = macro.DepthOfFieldEstimator() - numSamples = math.floor(depthoffieldsearchdistance / depthoffield) + 1 - depthOfFieldEstimator.takeCalibrationImages(camera, stage, depthoffieldsearchdistance, numSamples, focusEstimationMethod, dualColorMode, dualColorModeMainSide, capturedRadius) - - # Get best-focused position - bestFocusIndex = depthOfFieldEstimator.dofDataFrame['estimatedFocus'].idxmax() - bestFocusPosition = depthOfFieldEstimator.dofDataFrame.iloc[bestFocusIndex]['pos_z'] - - # Move to the best-focus position - stagePosition = stage.get_position() - stagePosition[2] = bestFocusPosition - stage.move_abs(stagePosition, unit= 'mm') - - # Remember best focus value for later auto focus - bestFocusValue = depthOfFieldEstimator.dofDataFrame.iloc[bestFocusIndex]['estimatedFocus'] - self.app.config.set('Autofocus', 'bestfocusvalue', bestFocusValue) - self.app.config.write() + self.app.autofocus() # Return LiveView state liveViewButton.state = prevLiveViewButtonState @@ -346,6 +349,8 @@ def __init__(self, **kwargs): super(RightColumn, self).__init__(**kwargs) # Class instance attributes self.app: GlowTrackerApp = App.get_running_app() + self._macroWidget = None + self._macroWidgets = [] def dismiss_popup(self): @@ -353,12 +358,12 @@ def dismiss_popup(self): self.app.bind_keys() self.app.root.ids.middlecolumn.ids.scalableimage.disabled = False self._popup.dismiss() - + def open_macro(self): """Open the macro script widget popup. """ - + # Disabled interaction with preview image widget self.app.root.ids.middlecolumn.ids.scalableimage.disabled = True # Unbind keyboard events @@ -366,6 +371,8 @@ def open_macro(self): # Create MacroScriptWidget Draggable Popup widget = MacroScriptWidget(app = self.app) + self._macroWidget = widget + self._macroWidgets.append(widget) widget.closeCallback = self.dismiss_popup self._popup = MacroScriptWidgetPopup(title= "Macro Script", content= widget, size_hint= (0.5, 0.7), auto_dismiss = False) self._popup.closeCallback = self.dismiss_popup @@ -393,7 +400,7 @@ def show_recording_settings(self): def show_calibration(self): """Show calibration window popup. - """ + """ camera = self.app.camera stage: Stage = self.app.stage @@ -404,12 +411,21 @@ def show_calibration(self): # Launch the widget inside a popup window self._popup = Popup(title= '', separator_height= 0, content= calibrationTabPanel, size_hint= (0.9, 0.75)) self._popup.open() - + else: self._popup = WarningPopup(title="Calibration", text='Autocalibration requires a stage and a camera. Connect a stage or use a calibration slide.', size_hint=(0.5, 0.25), closeTime= 5) self._popup.open() - + + def show_scan(self): + if getattr(self, '_scanPanel', None) is None: + self._scanPanel = CenterRadiusFromThreePoints() + if self._scanPanel.parent is not None: + self._scanPanel.parent.remove_widget(self._scanPanel) + self._popup = Popup(title= 'Plate Scan', content= self._scanPanel, size_hint= (0.95, 0.95)) + self._popup.open() + + def open_daq_widget(self): """Open the DAQ Control Sequence widget popup. """ @@ -423,7 +439,7 @@ def open_daq_widget(self): # Create DAQControlTabPanel Widget daqControlTabPanelHolder = DAQControlTabPanelHolder() daqControlTabPanelHolder.setCloseCallback(closeCallback= self.dismiss_popup) - + # Launch the widget inside a popup window self._popup = Popup(title= '', separator_height= 0, content= daqControlTabPanelHolder, size_hint= (0.7, 0.7)) self._popup.open() @@ -448,14 +464,14 @@ def _handle_keyboard(self, _window, key, *_args): """Override handle_keyboard function to always close the widget when ESC is pressed, regardless whether the self.auto_dismiss is True or False """ - # ESC + # ESC if key == 27: # Call closing the popup procedure self.closeCallback() # Tell the caller to stop propagating keyboard event return True - + @override def on_touch_down(self, touch) -> bool: """Override on_touch_down function to check if the touch is inside the CodeInput region. @@ -464,10 +480,10 @@ def on_touch_down(self, touch) -> bool: has_been_handled(bool): Flag to indicate if the touch event has been handled or not to stop propagation. """ discardRegion: CodeInput = self.content.ids.scripttext - + if discardRegion.collide_point(*touch.pos): return discardRegion.on_touch_down(touch) - + else: return super().on_touch_down(touch) @@ -481,10 +497,10 @@ def __init__(self, **kwargs): # Intercept GlowTrackerApp reference object. # There is a bug that if we call to get reference directly by App().get_running_app(), - # we would get a new GlowTrackerApp object that has different object id, and no config, root, etc. + # we would get a new GlowTrackerApp object that has different object id, and no config, root, etc. # like a completely new object. self.app: GlowTrackerApp = kwargs.pop('app', None) - + super(MacroScriptWidget, self).__init__(**kwargs) # Attributes @@ -522,14 +538,22 @@ def _record_for_handle(self, recordingTime: float): recordingTime (float): recording duratino in seconds """ + if self.macroScriptExecutor.is_stopping() or getattr(self.app, '_hardware_teardown', False): + return + # Check if still in recording mode, if so, overwrite it if self.recordButton.state == 'down': self.recordButton.state = 'normal' # Wait until the camera really stop grabbing - while self.app.camera.IsGrabbing(): + while self.app.camera is not None and self.app.camera.IsGrabbing(): + if self.macroScriptExecutor.is_stopping() or getattr(self.app, '_hardware_teardown', False): + return time.sleep(0.01) - + + if self.macroScriptExecutor.is_stopping() or getattr(self.app, '_hardware_teardown', False): + return + # Set recording config self.app.config.set('Experiment', 'iscontinuous', False) @@ -542,27 +566,35 @@ def _record_for_handle(self, recordingTime: float): @mainthread def setNFrames(): self.app.config.set('Experiment', 'nframes', nframes) - + setNFrames() self.app.config.write() - + # Start the recording mode self.recordButton.state = 'down' - + def _start_recording_handle(self): """Start the recording mode. """ + if self.macroScriptExecutor.is_stopping() or getattr(self.app, '_hardware_teardown', False): + return + # Check if still in recording mode, if so, overwrite it if self.recordButton.state == 'down': self.recordButton.state = 'normal' # Wait until the camera really stop grabbing - while self.app.camera.IsGrabbing(): + while self.app.camera is not None and self.app.camera.IsGrabbing(): + if self.macroScriptExecutor.is_stopping() or getattr(self.app, '_hardware_teardown', False): + return time.sleep(0.01) - + + if self.macroScriptExecutor.is_stopping() or getattr(self.app, '_hardware_teardown', False): + return + # Set recording config self.app.config.set('Experiment', 'iscontinuous', True) self.app.config.write() @@ -580,7 +612,7 @@ def _stop_recording_handle(self): def openLoadMacroScriptWidget(self): """Open a popup to load the macro script. """ - + loadWidget = LoadScriptWidget(load= self._loadScriptWidgetCallback) self._popup = Popup(title= "Load macro script file", content= loadWidget, size_hint= (0.9, 0.9), auto_dismiss= False) @@ -588,7 +620,7 @@ def openLoadMacroScriptWidget(self): loadWidget.cancel = self._popup.dismiss self._popup.open() - + def _loadScriptWidgetCallback(self, selection: list[str]): """Load the macro script from a list of given file path. Will choose only the first file. Used for handler of LoadScriptWidget. @@ -602,10 +634,10 @@ def _loadScriptWidgetCallback(self, selection: list[str]): if len(selection) == 0: return - + self.loadMacroScript(selection[0]) - - + + def loadMacroScript(self, filePath: str): """Load the macro script from a given file path. @@ -614,7 +646,7 @@ def loadMacroScript(self, filePath: str): """ # Get the absolute file path self.macroScriptFile = os.path.abspath(filePath) - + # Load the script text print(f'Loading the macro script {self.macroScriptFile}') @@ -627,7 +659,7 @@ def loadMacroScript(self, filePath: str): except IOError: print(f'An error occurred while reading the file {self.macroScriptFile}.') - + # Set display text self.ids.macroscriptfile.text = self.macroScriptFile self.ids.scripttext.text = self.macroScript @@ -635,7 +667,7 @@ def loadMacroScript(self, filePath: str): # Set as recent script self.app.config.set('MacroScript', 'recentscript', self.macroScriptFile) self.app.config.write() - + def saveMacroScript(self): """Save the current macro script into the same file (overwrite if exists). @@ -646,12 +678,12 @@ def saveMacroScript(self): try: # Convert to absolute path if it's a relative path abs_file_path = os.path.abspath(file_path) - + # Ensure the directory exists directory = os.path.dirname(abs_file_path) if directory: os.makedirs(directory, exist_ok=True) - + # Open the file in overwrite mode, creating it if it doesn't exist with open(abs_file_path, 'w') as file: file.write(script) @@ -666,7 +698,7 @@ def saveMacroScript(self): except Exception as e: print(f"Error saving macro script: {e}") - + def runMacroScript(self): """Run the current macro script. @@ -681,16 +713,16 @@ def runMacroScript(self): except ParseException as e: print(f"Parsing error: {e}") - + def finishedMacroScript(self): """Callback when the macro script is finished. Simply enable the run button back. """ print('Finished running the macro script.') - + # Enable the run button self.ids.runbutton.disabled = False - + def stopMacroScript(self): """Stop running the macro script. @@ -708,52 +740,53 @@ class LoadScriptWidget(BoxLayout): class CalibrationTabPanel(TabbedPanel): """Calibration widget that holds CameraAndStageCalibration, DualColorCalibration, and DepthOfFieldCalibration - """ + """ def setCloseCallback(self, closeCallback: callable) -> None: """API setting close callback event for children' tab. Args: closeCallback (callable): the closing callback event. - """ + """ self.closeCallback = closeCallback self.ids.stagecalibration.setCloseCallback( closeCallback ) self.ids.dualcolorcalibration.setCloseCallback( closeCallback ) self.ids.depthoffieldcalibration.setCloseCallback( closeCallback ) + self.ids.intensitysweepcalibration.setCloseCallback( closeCallback ) class CameraAndStageCalibration(BoxLayout): """Camera And Stage calibration widget that handles linking button callbacks and the calibration algorithm class. - """ + """ closeCallback = ObjectProperty(None) - + def setCloseCallback( self, closeCallback: callable ) -> None: """Set widget closing callback. Args: closeCallback (callable): the closing callback. - """ + """ self.closeCallback = closeCallback - + def calibrate(self): """Execute the camera and stage calibration process. 1. Take calibration images. 2. Estimate camera to stage transformation matrix. 3. Display results. - """ + """ app: GlowTrackerApp = App.get_running_app() camera = app.camera stage = app.stage if camera is None or stage is None: return - + # stop camera if already running liveViewButton: Button = app.root.ids.middlecolumn.ids.runtimecontrols.ids.imageacquisitionmanager.ids.liveviewbutton prevLiveViewButtonState = liveViewButton.state liveViewButton.state = 'normal' - + # get config values stepsize = app.config.getfloat('Calibration', 'step_size') stepunits = app.config.get('Calibration', 'step_units') @@ -768,14 +801,14 @@ def calibrate(self): stepsize, stepunits, dualcolormode, - mainside + mainside ) - + # Update display calibration images self.ids.fixedimage.texture = imageToTexture(basisImageOrig) self.ids.movingimagex.texture = imageToTexture(basisImageX) self.ids.movingimagey.texture = imageToTexture(basisImageY) - + # Estimate camera to stage transformation parameters calibratedParameters = cameraAndStageCalibrator.calibrateCameraAndStageTransform() @@ -786,12 +819,12 @@ def calibrate(self): size_hint=(0.35, 0.2), closeTime = 6) warningPopup.open() return - + rotation, imageNormDir, pixelSize = calibratedParameters app.config.set('Camera', 'rotation', rotation) app.config.set('Camera', 'imagenormaldir', '+Z' if imageNormDir == 1 else '-Z') app.config.set('Camera', 'pixelsize', pixelSize) - + # update calibration matrix app.imageToStageMat, app.imageToStageRotMat = macro.CameraAndStageCalibrator.genImageToStageMatrix(rotation, imageNormDir, pixelSize) @@ -813,17 +846,17 @@ def calibrate(self): class DualColorCalibration(BoxLayout): """Dual color calibration widget that handles linking button callbacks and the calibration algorithm class. - """ + """ closeCallback = ObjectProperty(None) - + def setCloseCallback( self, closeCallback: callable ) -> None: """Set widget closing callback. Args: closeCallback (callable): the closing callback. - """ + """ self.closeCallback = closeCallback - + def calibrate(self) -> None: """Execute the dual color calibration process. @@ -831,19 +864,19 @@ def calibrate(self) -> None: 2. Process the dual color image. 3. Calibrate main side to minor side transformation matrix. 4. Display results. - """ + """ app: GlowTrackerApp = App.get_running_app() camera: basler.Camera = app.camera stage: Stage = app.stage if camera is None or stage is None: return - + # stop camera if already running liveViewButton: Button = app.root.ids.middlecolumn.ids.runtimecontrols.ids.imageacquisitionmanager.ids.liveviewbutton prevLiveViewButtonState = liveViewButton.state liveViewButton.state = 'normal' - + # Take a dual color image for calibration isSuccess, dualColorImage = camera.singleTake() @@ -851,7 +884,7 @@ def calibrate(self) -> None: return mainSide = app.config.get('DualColor', 'mainside') - + # Instantiate a dual color calibrator dualColorImageCalibrator = macro.DualColorImageCalibrator() @@ -860,7 +893,7 @@ def calibrate(self) -> None: dualColorImage= dualColorImage, mainSide= mainSide ) - + # Update display image self.ids.mainsideimage.texture = imageToTexture(mainSideImage) self.ids.minorsideimage.texture = imageToTexture(minorSideImage) @@ -877,7 +910,7 @@ def calibrate(self) -> None: # Update labels shown self.ids.translation.text = f"Translation (x,y): {translation_x:.2f}, {translation_y:.2f}" self.ids.rotation.text = f"Rotation (rad): {rotation:.3f}" - + # Compute minor to main calibration matrix minorToMainMat = dualColorImageCalibrator.genMinorToMainMatrix(translation_x, translation_y, rotation, mainSideImage.shape[1]/2, mainSideImage.shape[0]/2) @@ -887,7 +920,7 @@ def calibrate(self) -> None: translatedMinorSideImage = cv2.warpAffine(minorSideImage, minorToMainMat[:2,:], (minorSideImage.shape[1], minorSideImage.shape[0])) # Combine main and minor side - combinedImage = np.zeros(shape= (mainSideImage.shape[0], mainSideImage.shape[1], 3), dtype= np.uint8) + combinedImage = np.zeros(shape= (mainSideImage.shape[0], mainSideImage.shape[1], 3), dtype= mainSideImage.dtype) combinedImage[:,:,0] = mainSideImage combinedImage[:,:,1] = translatedMinorSideImage @@ -900,17 +933,17 @@ def calibrate(self) -> None: class DepthOfFieldCalibration(BoxLayout): """Camera And Stage calibration widget that handles linking button callbacks and the calibration algorithm class. - """ + """ closeCallback = ObjectProperty(None) - + def setCloseCallback( self, closeCallback: callable ) -> None: """Set widget closing callback. Args: closeCallback (callable): the closing callback. - """ + """ self.closeCallback = closeCallback - + def calibrate(self): """Estimate Depth of Field of the current optic system and display the results. @@ -922,12 +955,12 @@ def calibrate(self): # Safe guard if camera is None or stage is None: return - + # stop camera if already running liveViewButton: Button = app.root.ids.middlecolumn.ids.runtimecontrols.ids.imageacquisitionmanager.ids.liveviewbutton prevLiveViewButtonState = liveViewButton.state liveViewButton.state = 'normal' - + # get config values depthoffieldsearchdistance = app.config.getfloat('Calibration', 'depthoffieldsearchdistance') depthoffieldnumsampleimages = app.config.getint('Calibration', 'depthoffieldnumsampleimages') @@ -939,7 +972,7 @@ def calibrate(self): # Take calibration images depthOfFieldEstimator = macro.DepthOfFieldEstimator() - + # Estimate DOF try: estimatedDof = depthOfFieldEstimator.estimate(camera, stage, depthoffieldsearchdistance, depthoffieldnumsampleimages, focusEstimationMethod, dualColorMode, mainSide, capturedRadius) @@ -967,6 +1000,43 @@ def calibrate(self): liveViewButton.state = prevLiveViewButtonState +class IntensitySweepCalibration(BoxLayout): + + closeCallback = ObjectProperty(None) + + def setCloseCallback(self, closeCallback: callable) -> None: + self.closeCallback = closeCallback + + + def sweep(self): + app: GlowTrackerApp = App.get_running_app() + camera: basler.Camera = app.camera + stage: Stage = app.stage + + if camera is None or stage is None: + return + + liveViewButton: Button = app.root.ids.middlecolumn.ids.runtimecontrols.ids.imageacquisitionmanager.ids.liveviewbutton + prevLiveViewButtonState = liveViewButton.state + liveViewButton.state = 'normal' + + zStart = float(self.ids.zstart.text) + zEnd = float(self.ids.zend.text) + numImages = int(self.ids.numphotos.text) + dualColorMode = app.config.getboolean('DualColor', 'dualcolormode') + mainSide = app.config.get('DualColor', 'mainside') + + intensitySweeper = macro.IntensitySweeper() + + try: + intensitySweeper.sweep(camera, stage, zStart, zEnd, numImages, dualColorMode, mainSide) + self.ids.intensitysweepplot.texture = imageToTexture(intensitySweeper.genPlot()) + except Exception as e: + print(f'Failed to run intensity sweep: {e}') + + liveViewButton.state = prevLiveViewButtonState + + class DAQControlTabPanelHolder(FloatLayout): mode: Spinner @@ -975,16 +1045,16 @@ def __init__(self, **kwargs): super().__init__(**kwargs) self.ids.daqcontroltabpanel.init() - + def setCloseCallback(self, closeCallback: callable) -> None: """API setting close callback event for children' tab. Args: closeCallback (callable): the closing callback event. - """ + """ self.closeCallback = closeCallback self.ids.daqcontroltabpanel.setCloseCallback( closeCallback ) - + def updateMode(self): print(self.mode.text) @@ -1000,24 +1070,24 @@ def updateMode(self): class DAQControlTabPanel(TabbedPanel): """Calibration widget that holds CameraAndStageCalibration, DualColorCalibration, and DepthOfFieldCalibration - """ + """ def __init__(self, **kwargs): super().__init__(**kwargs) - - + + def init(self): self.ids.sequencerwidget.init() self.ids.stageprogramwidget.init() self.ids.reversalwidget.init() - + def setCloseCallback(self, closeCallback: callable) -> None: """API setting close callback event for children' tab. Args: closeCallback (callable): the closing callback event. - """ + """ self.closeCallback = closeCallback self.ids.sequencerwidget.setCloseCallback( closeCallback ) self.ids.stageprogramwidget.setCloseCallback( closeCallback ) @@ -1034,7 +1104,7 @@ def __init__(self, **kwargs): def init(self): - + # Initialize MacroScriptExecutor self.app: GlowTrackerApp = App.get_running_app() self.stage = self.app.stage @@ -1048,20 +1118,20 @@ def init(self): if self.daqScriptFile != '': self.loadScript(self.daqScriptFile) - + def setCloseCallback( self, closeCallback: callable ) -> None: """Set widget closing callback. Args: closeCallback (callable): the closing callback. - """ + """ self.closeCallback = closeCallback def openLoadSequencerWidget(self): """Open a popup to load the script. """ - + loadWidget = LoadScriptWidget(load= self._loadScriptWidgetCallback) # Check if current file path is not empty then go to that path at the beginning, @@ -1075,7 +1145,7 @@ def openLoadSequencerWidget(self): loadWidget.cancel = self._popup.dismiss self._popup.open() - + def _loadScriptWidgetCallback(self, selection: list[str]): """Load the macro script from a list of given file path. Will choose only the first file. Used for handler of LoadScriptWidget. @@ -1089,10 +1159,10 @@ def _loadScriptWidgetCallback(self, selection: list[str]): if len(selection) == 0: return - + self.loadScript(selection[0]) - - + + def loadScript(self, filePath: str): """Load the script from a given file path. @@ -1101,7 +1171,7 @@ def loadScript(self, filePath: str): """ # Get the absolute file path self.daqScriptFile = os.path.abspath(filePath) - + # Load the script text try: @@ -1115,7 +1185,7 @@ def loadScript(self, filePath: str): except IOError: print(f'An error occurred while reading the file {self.daqScriptFile}.') - + # Set display text self.ids.daqscriptfile.text = self.daqScriptFile self.ids.scripttext.text = self.daqScript @@ -1131,7 +1201,7 @@ def loadScript(self, filePath: str): except Exception as e: print(e) return None - + def saveScript(self): """Save the current macro script into the same file (overwrite if exists). @@ -1143,12 +1213,12 @@ def saveScript(self): try: # Convert to absolute path if it's a relative path abs_file_path = os.path.abspath(file_path) - + # Ensure the directory exists directory = os.path.dirname(abs_file_path) if directory: os.makedirs(directory, exist_ok=True) - + # Open the file in overwrite mode, creating it if it doesn't exist with open(abs_file_path, 'w') as file: file.write(self.daqScript) @@ -1171,7 +1241,7 @@ def saveScript(self): except Exception as e: print(e) return None - + class StageProgramWidget(BoxLayout): """Widget that holds the parser and the function handler @@ -1214,8 +1284,8 @@ def __init__(self, **kwargs): def init(self): - - # Initialize + + # Initialize self.app: GlowTrackerApp = App.get_running_app() self.stage = self.app.stage self.camera = self.app.camera @@ -1224,27 +1294,27 @@ def init(self): self.mode = StageProgramMode[self.app.config.get('DaqControl', 'stageprogrammode')] self.modeSpinner.text = self.mode.value self._popup: Popup = None - + self.fourPointParamWidgets = [self.exterior_layout, self.fourpoint_header_layout, self.p1_layout, self.p2_layout, self.p3_layout, self.p4_layout, self.relative_layout] - + self.gaussianPointParamsWidgets = [self.g_amplitude_layout, self.g_x_mean_layout, self.g_x_sigma_layout, self.g_y_mean_layout, self.g_y_sigma_layout, self.g_relative_layout] # Temporary containing to keep the removed widget alive self._tempContainer = BoxLayout() - + self.initModeWidget() self.updateParam() - + def setCloseCallback( self, closeCallback: callable ) -> None: """Set widget closing callback. Args: closeCallback (callable): the closing callback. - """ + """ self.closeCallback = closeCallback - + def initModeWidget(self) -> None: """On startup gui, remove other modes' unrelated widgets """ @@ -1255,27 +1325,27 @@ def initModeWidget(self) -> None: for widget in self.gaussianPointParamsWidgets: stacklayout.remove_widget(widget= widget) self._tempContainer.add_widget(widget= widget) - + elif self.mode == StageProgramMode.Gaussian: # Remove FourPoint params widgets for widget in self.fourPointParamWidgets: stacklayout.remove_widget(widget= widget) self._tempContainer.add_widget(widget= widget) - + def updateExteriorChoice(self) -> None: # Parse choice text to enum self.exterior = macro.Exterior[self.exteriorSpinner.text] - + # Enable constanttextinput if choice is Constant if self.exterior == macro.Exterior.Constant: self.constanttextinput.disabled = False else: self.constanttextinput.disabled = True - - + + def updateMode(self) -> None: # Parse choice text to enum @@ -1299,17 +1369,17 @@ def updateMode(self) -> None: stacklayout.add_widget(widget= widget) elif self.mode == StageProgramMode.Gaussian: - + # Remove FourPoint params widgets for widget in self.fourPointParamWidgets: stacklayout.remove_widget(widget= widget) self._tempContainer.add_widget(widget= widget) - + # Add Gaussian params widgets for widget in self.gaussianPointParamsWidgets: self._tempContainer.remove_widget(widget= widget) stacklayout.add_widget(widget= widget) - + # Save to config self.app.config.set('DaqControl', 'stageprogrammode', self.modeSpinner.text) self.app.config.write() @@ -1329,7 +1399,7 @@ def updateParam(self) -> None: # Update DAQStageProgram variables self.app.daqControl.daqStageProgram.update(mode= self.mode, quadVertex= quadVertex, exterior= self.exterior, exteriorConstant= self.constanttextinput.value, isFourPointRelative= self.relative.active) - elif self.mode == StageProgramMode.Gaussian: + elif self.mode == StageProgramMode.Gaussian: # Parse values gaussianParams = GaussianParams( amplitude= self.g_amplitude.value, @@ -1344,7 +1414,7 @@ def updateParam(self) -> None: # Generate value map plot valMapPlot = self.app.daqControl.daqStageProgram.generateValueMapPlot() - + # Show the plot self.ids.visualizationplot.texture = imageToTexture(valMapPlot) @@ -1357,9 +1427,9 @@ def on_kv_post(self, *args): self.app = App.get_running_app() self.active = self.app.config.getboolean('DaqControl', self.configKey) - + @override - def on_touch_up(self, touch): + def on_touch_up(self, touch): """On switch touch up callback. Update the config value 'self.configKey', and call root.updateParam() @@ -1394,29 +1464,29 @@ def __init__(self, **kwargs): def init(self): - - # Initialize + + # Initialize self.app: GlowTrackerApp = App.get_running_app() self.stage = self.app.stage self.camera = self.app.camera self.imageAcquisitionManager: ImageAcquisitionManager = self.app.root.ids.middlecolumn.ids.runtimecontrols.imageacquisitionmanager - + def setCloseCallback( self, closeCallback: callable ) -> None: """Set widget closing callback. Args: closeCallback (callable): the closing callback. - """ + """ self.closeCallback = closeCallback - + def updateConfigChanged(self, configKey: str) -> None: if configKey in ['showtrail', 'showguideline', 'showreversalindicator']: # Redraw tracking overlay self.app.root.ids.middlecolumn.ids.imageoverlay.clearOverlay() - + def updateParam(self) -> None: @@ -1426,7 +1496,7 @@ def updateParam(self) -> None: self.app.daqControl.reversalDetector.reversalThresholdRadian = self.reversalthresholdradian.value self.app.daqControl.reversalDetector.reversalVoltage = self.reversalvoltage.value self.app.daqControl.reversalDetector.forwardVoltage = self.forwardvoltage.value - + self.app.root.ids.middlecolumn.ids.imageoverlay.clearOverlay() @@ -1438,9 +1508,9 @@ def on_kv_post(self, *args): self.app = App.get_running_app() self.active = self.app.config.getboolean('DaqControl', self.configKey) - + @override - def on_touch_up(self, touch): + def on_touch_up(self, touch): """On switch touch up callback. Update the config value 'self.configKey', and call root.updateConfigChanged() @@ -1470,7 +1540,7 @@ def on_kv_post(self, *args): def _validate(self) -> bool: """Validate if self.text can be interpreted as a numerical value. If successful, self.value is updated. - + Returns: bool: True if a number. Otherwise, False. """ @@ -1483,7 +1553,7 @@ def _validate(self) -> bool: except ValueError: # The value is not a number return False - + # Check if should display text in integer style or floating point style try: value_int = int(self.text) @@ -1504,10 +1574,10 @@ def on_text_validate(self, *args): self.app.config.set('DaqControl', self.configKey, self.value) self.app.config.write() self.root.updateParam() - + else: self.text = str(self.value) - + @override def keyboard_on_key_down(self, window, keycode, text, modifiers): @@ -1530,7 +1600,7 @@ def keyboard_on_key_down(self, window, keycode, text, modifiers): class StageAxisController(BoxLayout): """Template class for stage axis controller widget. - """ + """ def __init__(self, **kwargs): super(StageAxisController, self).__init__(**kwargs) @@ -1538,7 +1608,7 @@ def __init__(self, **kwargs): def disable_all(self): for id in self.ids: self.ids[id].disabled = True - + def enable_all(self): for id in self.ids: self.ids[id].disabled = False @@ -1561,16 +1631,70 @@ def __init__(self, **kwargs): super(ZControls, self).__init__(**kwargs) +class GoToControls(BoxLayout): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._moveWorker = ManagedStageMove(blocked=True) + + @property + def _moveThread(self): + return self._moveWorker.thread + + def go_to(self): + app = App.get_running_app() + try: + target = [ + float(self.ids.gotox.text), + float(self.ids.gotoy.text), + float(self.ids.gotoz.text), + ] + except ValueError: + print('invalid coordinate input') + return + + stage = app.stage + started = self._moveWorker.start( + stage, + target, + on_success=lambda: app.update_coordinates(isAsync=False), + teardown_requested=lambda: ( + getattr(app, '_hardware_teardown', False) + or app.stage is not stage + ), + ) + if not started and self._moveWorker.is_active(): + print('Go To movement is already active') + + def request_stop(self, block_new=False): + self._moveWorker.request_stop(block_new) + + def wait(self, timeout=None): + return self._moveWorker.wait(timeout) + + def is_active(self): + return self._moveWorker.is_active() + + def allow_moves(self): + self._moveWorker.allow() + + + def prefill(self): + coords = App.get_running_app().coords + self.ids.gotox.text = '{:.3f}'.format(coords[0]) + self.ids.gotoy.text = '{:.3f}'.format(coords[1]) + self.ids.gotoz.text = '{:.3f}'.format(coords[2]) + + class LoadCameraProperties(BoxLayout): """Camera settings loading widget - """ + """ load = ObjectProperty(None) cancel = ObjectProperty(None) class SaveExperiment(GridLayout): """File saving location widget. - """ + """ save = ObjectProperty(None) cancel = ObjectProperty(None) @@ -1608,7 +1732,7 @@ def delete_images(self): class LabelImage(): - + def __init__(self, **kwargs): super(LabelImage, self).__init__(**kwargs) self.text = '' @@ -1621,7 +1745,7 @@ class MultipleImages(GridLayout): class RecordingSettings(BoxLayout): """Record settings widget - """ + """ ok = ObjectProperty(None) # store recording settings from popups nframes = ConfigParserProperty(5, 'Experiment', 'nframes', 'app', val_type=int) @@ -1646,9 +1770,9 @@ def __init__(self, **kwargs): super(ContinuousSwitch, self).__init__(**kwargs) self.app: GlowTrackerApp = App.get_running_app() self.active = self.app.config.getboolean('Experiment', 'iscontinuous') - - def on_touch_up(self, touch): + + def on_touch_up(self, touch): """On switch touch up callback. Update the config value 'iscontinuous', and disabled or enabled the recording 'duration' and 'frames' input field. @@ -1662,7 +1786,7 @@ def on_touch_up(self, touch): self.app.config.set('Experiment', 'iscontinuous', int(self.active)) recordingSettings.ids.duration.disabled = self.active recordingSettings.ids.frames.disabled = self.active - + self.app.config.write() return True @@ -1670,7 +1794,7 @@ def on_touch_up(self, touch): class CameraProperties(GridLayout): """Camera properties editor widget - """ + """ gain = NumericProperty(0) exposure = NumericProperty(0) framerate = NumericProperty(0) @@ -1742,8 +1866,8 @@ class ImageAcquisitionButton(ToggleButton): - acquisitionCondition() in order to be functional. - """ - + """ + def __init__(self, **kwargs): super().__init__(**kwargs) @@ -1754,30 +1878,32 @@ def __init__(self, **kwargs): self.runtimeControls: RuntimeControls | None = None self.updateDisplayImageEvent: ClockEvent | None = None self.image: np.ndarray = np.zeros((1,1)) + self.originalImage: np.ndarray = np.zeros((1,1)) self.imageTimeStamp: float = 0 self.imageRetrieveTimeStamp: float = 0 self.dualColorMainSideImage: np.ndarray = np.zeros((1,1)) self.dualColorMinorSideImage: np.ndarray = np.zeros((1,1)) self.dualColorMinorToMainMat: np.ndarray | None = None + self.acquisitionError: Exception | None = None + - def on_state(self, widget: Widget, state: str): """On state change callback Args: widget (Widget): the kivy widget, in this case is the same as the class instance itself. state (str): the new state - """ + """ if state == 'down': self.startImageAcquisition() - + else: self.stopImageAcquisition() - + def startImageAcquisition(self) -> None: """The starting image acquisition process. Needs to be overridden. The important steps is to spawn the imageAcquisitionLoopingThread. - """ + """ pass @@ -1787,44 +1913,43 @@ def stopImageAcquisition(self) -> None: - Stop the update display event. - Stop camera grabbing. - Stop the acquisition looping thread if not already. - - Reset GUI back - """ - if self.camera is None: - return - - # Unschedule the display event thread - Clock.unschedule(self.updateDisplayImageEvent) + - Reset GUI back + """ + try: + if self.updateDisplayImageEvent is not None: + Clock.unschedule(self.updateDisplayImageEvent) + self.updateDisplayImageEvent = None + except Exception as e: + print(f'Unscheduling image display failed: {e}') - # Stop grabbing - if self.camera.IsGrabbing(): - self.camera.StopGrabbing() + try: + if self.camera is not None and self.camera.IsGrabbing(): + self.camera.StopGrabbing() + except Exception as e: + print(f'Stopping camera grabbing failed: {e}') - # Flag recompute dual color transformation matrix self.dualColorMinorToMainMat = None - - # Reset displayed framecounter - self.runtimeControls.framecounter.value = 0 - - # reset scale of image - self.app.root.ids.middlecolumn.ids.scalableimage.reset() - # Set self button state to normal. - self.state = 'normal' + try: + self.runtimeControls.framecounter.value = 0 + self.app.root.ids.middlecolumn.ids.scalableimage.reset() + liveAnalysisData: LiveAnalysisData = self.runtimeControls.imageacquisitionmanager.liveAnalysisData + with liveAnalysisData.lock: + liveAnalysisData.minBrightness = 0 + liveAnalysisData.maxBrightness = 0 + liveAnalysisData.meanBrightness = 0 + liveAnalysisData.medianBrightness = 0 + liveAnalysisData.skewness = 0 + liveAnalysisData.percentile_5 = 0 + liveAnalysisData.percentile_95 = 0 + except Exception as e: + print(f'Resetting acquisition UI failed: {e}') + finally: + self.state = 'normal' - # Reset liveAnalysisData - liveAnalysisData: LiveAnalysisData = self.runtimeControls.imageacquisitionmanager.liveAnalysisData - with liveAnalysisData.lock: - liveAnalysisData.minBrightness = 0 - liveAnalysisData.maxBrightness = 0 - liveAnalysisData.meanBrightness = 0 - liveAnalysisData.medianBrightness = 0 - liveAnalysisData.skewness = 0 - liveAnalysisData.percentile_5 = 0 - liveAnalysisData.percentile_95 = 0 - def imageAcquisitionLoopingThread(self, grabArgs) -> None: - """Image acquisition looping thread. This function should not be call directly + """Image acquisition looping thread. This function should not be call directly in the main thread but as a new thread instead for better performance. The procedure here is as follows: 1. Start camera grabbing. @@ -1832,48 +1957,50 @@ def imageAcquisitionLoopingThread(self, grabArgs) -> None: 3. Loop acquire image while the condition is True. 4. Callback for each acquired image. 5. Finished looping callback. - """ - - if grabArgs.isContinuous: - self.camera.StartGrabbing(grabArgs.grabStrategy) - - else: - # Grab for a specific number of frames - self.camera.StartGrabbingMax(grabArgs.numberOfImagesToGrab, grabArgs.grabStrategy) - - fps = self.camera.ResultingFrameRate() - print(f'Grabbing Framerate: {fps:.3f} fps') - - # Schedule a display update - fps = self.app.config.getfloat('Camera', 'display_fps') - self.updateDisplayImageEvent = Clock.schedule_interval(self.updateDisplayImage, 1.0 /fps) - print(f'Displaying at {fps:.3f} fps') - - returnCameraOnHoldFlag = True if self.camera.isOnHold() else False - - # Register start acquisition time - imageAcquisitionManager: ImageAcquisitionManager = self.parent - imageAcquisitionManager.startTime = time.perf_counter() + """ - # Start image acquisition loop - while self.acquisitionCondition(): + self.acquisitionError = None + try: + if grabArgs.isContinuous: + self.camera.StartGrabbing(grabArgs.grabStrategy) + else: + self.camera.StartGrabbingMax(grabArgs.numberOfImagesToGrab, grabArgs.grabStrategy) - # retrieve an image - isSuccess, image, imageTimeStamp, imageRetrieveTimeStamp = self.camera.retrieveGrabbingResult() + fps = self.camera.ResultingFrameRate() + print(f'Grabbing Framerate: {fps:.3f} fps') - if isSuccess: + fps = self.app.config.getfloat('Camera', 'display_fps') + self.updateDisplayImageEvent = Clock.schedule_interval(self.updateDisplayImage, 1.0 /fps) + print(f'Displaying at {fps:.3f} fps') - if returnCameraOnHoldFlag: - self.camera.setIsOnHold(False) - returnCameraOnHoldFlag = False + returnCameraOnHoldFlag = True if self.camera.isOnHold() else False + imageAcquisitionManager: ImageAcquisitionManager = self.parent + imageAcquisitionManager.startTime = time.perf_counter() - # Process the received image - self.processImageCallback( image, imageTimeStamp, imageRetrieveTimeStamp ) + while self.acquisitionCondition(): + isSuccess, image, imageTimeStamp, imageRetrieveTimeStamp = self.camera.retrieveGrabbingResult() - # Trigger image callback - self.receiveImageCallback() + if isSuccess: + if returnCameraOnHoldFlag: + self.camera.setIsOnHold(False) + returnCameraOnHoldFlag = False - self.finishAcquisitionCallback() + self.processImageCallback(image, imageTimeStamp, imageRetrieveTimeStamp) + self.receiveImageCallback() + except Exception as e: + self.acquisitionError = e + print(f'Image acquisition failed: {e}') + finally: + try: + self.finishAcquisitionCallback() + except Exception as e: + print(f'Finishing image acquisition failed: {e}') + finally: + if self.state == 'down': + try: + self.stopImageAcquisition() + except Exception as e: + print(f'Stopping image acquisition failed: {e}') def acquisitionCondition(self) -> bool: @@ -1881,25 +2008,26 @@ def acquisitionCondition(self) -> bool: Returns: isStillAcquiring (bool): is the acquisition is still True. - """ + """ pass - + def processImageCallback(self, image: np.ndarray, imageTimeStamp: float, imageRetrieveTimeStamp: float) -> None: - """Process the acquired image by cropping per settings, and also dual color image + """Process the acquired image by cropping per settings, and also dual color image processing if the dual color mode is on. Args: image (np.ndarray): the acquired image imageTimeStamp (float): the acquired image's internal clock timestamp imageRetrieveTimeStamp (float): the timestamp when receiving image in the software. - """ + """ - # Crop image h, w = image.shape cropX, cropY = self.runtimeControls.cropX, self.runtimeControls.cropY image = image[ cropY : h - cropY, cropX : w - cropX ] - + self.originalImage = image + h, w = image.shape + # Process image. For now this is only the case for dual color mode dualcolorMode = self.app.config.getboolean('DualColor', 'dualcolormode') mainSide = self.app.config.get('DualColor', 'mainside') @@ -1908,21 +2036,23 @@ def processImageCallback(self, image: np.ndarray, imageTimeStamp: float, imageRe if dualcolorMode: # If in dual color mode then post process the image - # Split image into main and minor side + splitWidth = w // 2 + leftImage = image[:, :splitWidth] + rightImage = image[:, w - splitWidth:] if mainSide == 'Left': - self.dualColorMainSideImage = image[:,:w//2] - self.dualColorMinorSideImage = image[:,w//2:] + self.dualColorMainSideImage = leftImage + self.dualColorMinorSideImage = rightImage elif mainSide == 'Right': - self.dualColorMainSideImage = image[:,w//2:] - self.dualColorMinorSideImage = image[:,:w//2] - + self.dualColorMainSideImage = rightImage + self.dualColorMinorSideImage = leftImage + # Compute minor to main calibration matrix if first time if self.dualColorMinorToMainMat is None: translation_x = self.app.config.getfloat('DualColor', 'translation_x') translation_y = self.app.config.getfloat('DualColor', 'translation_y') rotation = self.app.config.getfloat('DualColor', 'rotation') - + self.dualColorMinorToMainMat = macro.DualColorImageCalibrator.genMinorToMainMatrix(translation_x, translation_y, rotation, self.dualColorMainSideImage.shape[1]/2, self.dualColorMainSideImage.shape[0]/2) # Apply transformation @@ -1933,29 +2063,44 @@ def processImageCallback(self, image: np.ndarray, imageTimeStamp: float, imageRe if dualcolorViewMode == 'Merged': # Combine main and minor side - combinedImage = np.zeros(shape= (self.dualColorMainSideImage.shape[0], self.dualColorMainSideImage.shape[1], 3), dtype= np.uint8) + combinedImage = np.zeros( + shape=(self.dualColorMainSideImage.shape[0], + self.dualColorMainSideImage.shape[1], 3), + dtype=self.dualColorMainSideImage.dtype, + ) combinedImage[:,:,0] = self.dualColorMainSideImage combinedImage[:,:,1] = self.dualColorMinorSideImage self.image = combinedImage - + else: self.image = image - + else: # If not in dual color mode then simply pass on self.image = image - + self.imageTimeStamp = imageTimeStamp self.imageRetrieveTimeStamp = imageRetrieveTimeStamp # Compute live analysis data showliveanalysis = self.app.config.getboolean('LiveAnalysis', 'showliveanalysis') saveanalysistorecording = self.app.config.getboolean('LiveAnalysis', 'saveanalysistorecording') - if showliveanalysis or saveanalysistorecording: + + trackingCheckbox = self.runtimeControls.trackingcheckbox + recordButton = self.runtimeControls.imageacquisitionmanager.recordbutton + isTracking = trackingCheckbox is not None and trackingCheckbox.state == 'down' + isRecording = recordButton is not None and recordButton.state == 'down' + + if isTracking or isRecording: + computeLiveAnalysis = saveanalysistorecording + else: + computeLiveAnalysis = showliveanalysis or saveanalysistorecording + + if computeLiveAnalysis: self.computeLiveAnalysisValues() - - + + def computeLiveAnalysisValues(self): """Compute the following values from the current image - min @@ -1975,28 +2120,28 @@ def computeLiveAnalysisValues(self): # Get tracking configs capture_radius = self.app.config.getint('Tracking', 'capture_radius') - + # Crop to tracking region image = macro.cropCenterImage(image, capture_radius * 2, capture_radius * 2) - # Do we need to crop on tracking region? imageAcquisitionManager: ImageAcquisitionManager = self.parent liveAnalysisData = imageAcquisitionManager.liveAnalysisData + sample = image[::4, ::4] with liveAnalysisData.lock: imageAcquisitionManager.liveAnalysisData.minBrightness = np.min(image, axis= None) imageAcquisitionManager.liveAnalysisData.maxBrightness = np.max(image, axis= None) imageAcquisitionManager.liveAnalysisData.meanBrightness = np.mean(image, axis= None) - imageAcquisitionManager.liveAnalysisData.medianBrightness = np.median(image, axis= None) - imageAcquisitionManager.liveAnalysisData.skewness = skew(image, axis= None, nan_policy= 'omit') - imageAcquisitionManager.liveAnalysisData.percentile_5 = np.percentile(image, q= 5, axis= None) - imageAcquisitionManager.liveAnalysisData.percentile_95 = np.percentile(image, q= 95, axis= None) - + imageAcquisitionManager.liveAnalysisData.medianBrightness = np.median(sample, axis= None) + imageAcquisitionManager.liveAnalysisData.skewness = skew(sample, axis= None, nan_policy= 'omit') + imageAcquisitionManager.liveAnalysisData.percentile_5 = np.percentile(sample, q= 5, axis= None) + imageAcquisitionManager.liveAnalysisData.percentile_95 = np.percentile(sample, q= 95, axis= None) + def receiveImageCallback(self) -> None: """Callback after processed the image. Use for further updating. Update the parent (ImageAcquisitionManager) current image data. Can be extended. - """ + """ # Update parent (ImageAcquisitionManager) images imageAcquisitionManager: ImageAcquisitionManager = self.parent @@ -2005,7 +2150,7 @@ def receiveImageCallback(self) -> None: imageAcquisitionManager.imageRetrieveTimeStamp = self.imageRetrieveTimeStamp imageAcquisitionManager.dualColorMainSideImage = self.dualColorMainSideImage imageAcquisitionManager.currentTime = time.perf_counter() - + # Update display frame value self.runtimeControls.framecounter.value += 1 @@ -2015,7 +2160,7 @@ def receiveImageCallback(self) -> None: def finishAcquisitionCallback(self) -> None: """Finished the acquisition looping callback. Needs to be overridden. - """ + """ pass @@ -2024,33 +2169,33 @@ def updateDisplayImage(self, dt) -> None: Args: dt (float): addition delta time between each callback. - """ + """ self.app.image = self.image class LiveViewButton(ImageAcquisitionButton): """A LiveView button that have image acquisition capability. - """ - + """ + def __init__(self, **kwargs): super().__init__(**kwargs) @override def startImageAcquisition(self) -> None: - """Start the image acquisition process by getting the grabbing parameters, spawn + """Start the image acquisition process by getting the grabbing parameters, spawn image acquisition thread, and update the image GUI overlay. - """ - + """ + # Update the self-hold reference to the GlowTrackerApp object and the pylon camera object for each of access. self.app: GlowTrackerApp = App.get_running_app() self.camera = self.app.camera self.runtimeControls = App.get_running_app().root.ids.middlecolumn.runtimecontrols - if self.camera is None: + if self.camera is None or getattr(self.app, '_hardware_teardown', False): self.state = 'normal' return - + # Setup image acquisition thread parameters grabArgs = basler.CameraGrabParameters( bufferSize= 16, @@ -2068,7 +2213,7 @@ def startImageAcquisition(self) -> None: } ) self.imageAcquisitionThread.start() - + @override def stopImageAcquisition(self) -> None: @@ -2088,12 +2233,12 @@ def acquisitionCondition(self) -> bool: class RecordButton(ImageAcquisitionButton): """A Record button that have image acquisition capability. - """ + """ def __init__(self, **kwargs): super().__init__(**kwargs) - + # Declare class instance attributes self.numberRecordframes: int = 0 self.isContinuous: bool = False @@ -2108,7 +2253,11 @@ def __init__(self, **kwargs): self.imageFilenameExtension: str = '' self.prevLiveViewButtonState: str = 'normal' self.prevLiveAnalysisButtonState: str = 'normal' - + self._recordingCleanupLock = Lock() + self._saveFailureLock = Lock() + self._recordingCleanupStarted = False + self._abandonedSavers = [] + @override def on_state(self, widget: Widget, state: str): @@ -2117,20 +2266,30 @@ def on_state(self, widget: Widget, state: str): Args: widget (Widget): the kivy widget, in this case is the same as the class instance itself. state (str): the new state - """ + """ self.app: GlowTrackerApp = App.get_running_app() self.camera = self.app.camera if self.camera is None: + if state == 'down': + self.state = 'normal' return if state == 'down': + if getattr(self.app, '_hardware_teardown', False): + self.state = 'normal' + return + if self.imageAcquisitionThread is not None \ + and self.imageAcquisitionThread.is_alive(): + print('Previous recording acquisition is still stopping') + self.state = 'normal' + return self.startImageAcquisition() - + else: if self.camera.IsGrabbing(): self.stopImageAcquisition() - + @override def startImageAcquisition(self) -> None: @@ -2139,7 +2298,7 @@ def startImageAcquisition(self) -> None: - spawn image acquisition thread. - spawn image saving thread. - update the image GUI overlay. - """ + """ # Update the self-hold reference to the GlowTrackerApp object and the pylon camera object for each of access. self.app: GlowTrackerApp = App.get_running_app() @@ -2153,10 +2312,10 @@ def startImageAcquisition(self) -> None: self.prevLiveViewButtonState = imageAcquisitionManager.liveviewbutton.state # If there is no camera or recording file path doesn't exists - if self.camera is None: + if self.camera is None or getattr(self.app, '_hardware_teardown', False): self.state = 'normal' return - + if not os.path.exists(self.saveFilePath): print("The recording path doesn't exist. Can't start recording.") self.state = 'normal' @@ -2178,41 +2337,388 @@ def startImageAcquisition(self) -> None: self.isDualColorMode = self.app.config.getboolean('DualColor', 'dualcolormode') self.dualColorRecordingMode = self.app.config.get('DualColor', 'recordingmode') - # Image data queue to share between recording and saving - self.imageQueue = Queue() - - # Start a thread for saving images - self.savingthread = Thread(target= macro.ImageSaver.startSavingImageInQueueThread, args= [self.imageQueue, 3]) - self.savingthread.start() + # # Image data queue to share between recording and saving + # self.imageQueue = Queue() - # Prep DAQ control - if self.app.daqControl.isConnected() and self.app.daqControl.daqMode != DAQMode.Off: - self.app.daqControl.start( np.array(self.app.coords[:2]) ) + # # Start a thread for saving images + # self.savingthread = Thread(target= macro.ImageSaver.startSavingImageInQueueThread, args= [self.imageQueue, 3]) + # self.savingthread.start() - # Setup image acquisition thread parameters - self.initRecordingParams() self.frameCounter = 0 + self.saveHandoffError = None + self._saverFailureReported = False + self._imageSaverStarted = False + self._recordingCleanupStarted = False + self.shm_manager = None + self.saveproc = None + self.saveThreads = [] + self.stop_event = None + self.saveFailureEvent = None + self.saveStatusQueue = None + self.saveStatusThread = None + self.saveStatusStopEvent = Event() + self._saveStatusAbandoned = False + self.saveHandoffThread = None + self.saveHandoffQueue = None + self.saveHandoffStopEvent = Event() + self.coordinateFile = None + self.saveAcknowledgements = None - grabArgs = basler.CameraGrabParameters( - bufferSize= self.app.config.getint('Experiment', 'buffersize'), - isContinuous= self.isContinuous, - numberOfImagesToGrab= self.numberRecordframes, - grabStrategy= pylon.GrabStrategy_OneByOne - ) + try: + if self.app.daqControl.isConnected() and self.app.daqControl.daqMode != DAQMode.Off: + self.app.daqControl.start(np.array(self.app.coords[:2])) + + self.initRecordingParams() + grabArgs = basler.CameraGrabParameters( + bufferSize=self.app.config.getint('Experiment', 'buffersize'), + isContinuous=self.isContinuous, + numberOfImagesToGrab=self.numberRecordframes, + grabStrategy=pylon.GrabStrategy_OneByOne + ) + + self.coordinateFile = self.initCoordinateFile() + self.saveAcknowledgements = image_saver.SaveAcknowledgements( + self.coordinateFile + ) + self.imageAcquisitionThread = Thread( + target=self.imageAcquisitionLoopingThread, + daemon=True, + kwargs={'grabArgs': grabArgs} + ) + self.imageAcquisitionThread.start() + except Exception as e: + self.acquisitionError = e + print(f'Starting image acquisition failed: {e}') + self.stopImageAcquisition() - # open coordinate file - self.coordinateFile = self.initCoordinateFile() - # Spawn image acquisition thread - self.imageAcquisitionThread = Thread( - target= self.imageAcquisitionLoopingThread, - daemon= True, - kwargs= { - 'grabArgs' : grabArgs, - } - ) + def _startImageSaver(self, example_image: np.ndarray) -> None: + if self._imageSaverStarted: + return - self.imageAcquisitionThread.start() + try: + if USE_SHARED_MEMORY_SAVER: + processMethod = 'spawn' if sys.platform == 'win32' \ + else 'forkserver' + ctx = mp.get_context(processMethod) + self.shm_manager = SharedMemoryManager(ctx=ctx) + self.shm_manager.start() + example = { + 'img': np.empty(example_image.shape, dtype=example_image.dtype), + 'idx': 0, + 'channel': 0, + } + self.imageQueue = SharedMemoryQueue.create_from_examples( + self.shm_manager, example, buffer_size=60, + context=ctx, + ) + self.stop_event = ctx.Event() + self.saveFailureEvent = ctx.Event() + self.saveStatusQueue = ctx.Queue() + self.saveproc = ctx.Process( + target=image_saver.save_worker, + args=(self.imageQueue, self.saveFilePath, + self.imageFilenameFormat, self.stop_event, + self.saveStatusQueue, self.saveFailureEvent), + daemon=True) + self.saveproc.start() + else: + self.imageQueue = Queue(maxsize=60) + self.stop_event = Event() + self.saveFailureEvent = Event() + self.saveStatusQueue = Queue() + self.saveThreads = [ + Thread( + target=image_saver.save_worker, + args=(self.imageQueue, self.saveFilePath, + self.imageFilenameFormat, self.stop_event, + self.saveStatusQueue, self.saveFailureEvent), + daemon=True) + for _ in range(3) + ] + for saveThread in self.saveThreads: + saveThread.start() + + self.saveStatusThread = Thread( + target=self._saveStatusLoop, + args=( + self.saveStatusQueue, + self.saveStatusStopEvent, + self.saveAcknowledgements, + self.saveproc, + self.saveThreads, + self.saveFailureEvent, + ), + daemon=True, + ) + self.saveStatusThread.start() + self.saveHandoffQueue = Queue(maxsize=16) + self.saveHandoffThread = Thread( + target=self._saveHandoffLoop, + args=( + self.saveHandoffQueue, + self.saveHandoffStopEvent, + self.imageQueue, + self.saveFailureEvent, + self.saveAcknowledgements, + ), + daemon=True, + ) + self.saveHandoffThread.start() + self._imageSaverStarted = True + except Exception as e: + self._setSaverFailure(f'Starting image saver failed: {e}') + self._cleanupImageSaver() + raise + + + def _cleanupImageSaver(self) -> None: + if self.saveHandoffStopEvent is not None: + self.saveHandoffStopEvent.set() + handoffThread = self.saveHandoffThread + handoffAlive = False + try: + if handoffThread is not None and handoffThread is not current_thread(): + if handoffThread.is_alive(): + handoffThread.join(SAVE_HANDOFF_JOIN_TIMEOUT) + handoffAlive = handoffThread.is_alive() + if handoffAlive: + self._setSaverFailure( + 'Image handoff did not stop before the shutdown deadline' + ) + except Exception as e: + print(f'Stopping image handoff failed: {e}') + + try: + if self.stop_event is not None: + self.stop_event.set() + except Exception as e: + print(f'Setting image saver stop event failed: {e}') + + processAlive = False + try: + if self.saveproc is not None and self.saveproc.pid is not None: + self.saveproc.join(SAVE_WORKER_JOIN_TIMEOUT) + processAlive = self.saveproc.is_alive() + if processAlive: + self._setSaverFailure( + 'Image saver did not stop before the shutdown deadline' + ) + self.saveproc.terminate() + self.saveproc.join(SAVE_WORKER_TERMINATE_TIMEOUT) + processAlive = self.saveproc.is_alive() + if processAlive and hasattr(self.saveproc, 'kill'): + self.saveproc.kill() + self.saveproc.join(SAVE_WORKER_TERMINATE_TIMEOUT) + processAlive = self.saveproc.is_alive() + if self.saveproc.exitcode not in (None, 0): + self._setSaverFailure( + f'Image saver exited with code {self.saveproc.exitcode}' + ) + except Exception as e: + print(f'Stopping image saver process failed: {e}') + self._setSaverFailure(f'Stopping image saver process failed: {e}') + + threadDeadline = time.monotonic() + SAVE_WORKER_JOIN_TIMEOUT + liveSaveThreads = [] + for saveThread in self.saveThreads: + try: + if saveThread is not current_thread() and saveThread.is_alive(): + saveThread.join(max(0.0, threadDeadline - time.monotonic())) + if saveThread.is_alive(): + liveSaveThreads.append(saveThread) + except Exception as e: + print(f'Stopping image saver thread failed: {e}') + if liveSaveThreads: + self._setSaverFailure( + 'Image saver threads did not stop before the shutdown deadline' + ) + + if self.saveFailureEvent is not None \ + and self.saveFailureEvent.is_set() \ + and self.saveHandoffError is None: + self._setSaverFailure('Image saver stopped without an acknowledgement') + + if self.saveStatusStopEvent is not None: + self.saveStatusStopEvent.set() + statusThread = self.saveStatusThread + statusAlive = False + try: + if statusThread is not None and statusThread is not current_thread(): + if statusThread.is_alive(): + statusThread.join(SAVE_STATUS_JOIN_TIMEOUT) + statusAlive = statusThread.is_alive() + if statusAlive: + self._setSaverFailure( + 'Image saver status monitor did not stop before the shutdown deadline' + ) + except Exception as e: + print(f'Stopping image saver status monitor failed: {e}') + + if self.saveAcknowledgements is not None \ + and not statusAlive and not handoffAlive: + try: + unresolved = self.saveAcknowledgements.discard_pending() + if unresolved: + self._setSaverFailure( + f'{unresolved} image frames were not acknowledged' + ) + except Exception as e: + self._setSaverFailure( + f'Finalizing image acknowledgements failed: {e}' + ) + + try: + if self.shm_manager is not None \ + and not processAlive and not handoffAlive: + self.shm_manager.shutdown() + except Exception as e: + print(f'Shutting down shared memory failed: {e}') + finally: + self._saveStatusAbandoned = statusAlive or handoffAlive + if processAlive or handoffAlive or liveSaveThreads or statusAlive: + self._abandonedSavers.append(( + self.shm_manager, + self.saveproc, + self.saveThreads, + self.saveHandoffThread, + self.saveStatusThread, + self.imageQueue, + self.saveStatusQueue, + )) + self._imageSaverStarted = False + self.shm_manager = None + self.saveproc = None + self.saveThreads = [] + self.stop_event = None + self.saveFailureEvent = None + self.saveStatusQueue = None + self.saveStatusThread = None + self.saveStatusStopEvent = None + self.saveHandoffThread = None + self.saveHandoffQueue = None + self.saveHandoffStopEvent = None + + + def _setSaverFailure(self, message: str) -> None: + with self._saveFailureLock: + if self._saverFailureReported: + return + self._saverFailureReported = True + self.saveHandoffError = message + self.acquisitionError = RuntimeError(message) + try: + if self.saveFailureEvent is not None: + self.saveFailureEvent.set() + except Exception: + pass + + print(f'Image saver failed: {message}') + try: + if self.camera is not None and self.camera.IsGrabbing(): + self.camera.StopGrabbing() + except Exception as e: + print(f'Stopping acquisition after image saver failure failed: {e}') + + def showError(*args): + if getattr(self.app, '_hardware_teardown', False): + return + WarningPopup( + title='Recording stopped', + text=f'Image saving failed:\n{message}', + size_hint=(0.6, 0.3), + closeTime=10, + ).open() + + try: + Clock.schedule_once(showError) + except Exception: + pass + + + def _setRunSaverFailure(self, message, failureEvent) -> None: + if failureEvent is self.saveFailureEvent: + self._setSaverFailure(message) + else: + print(f'Abandoned image saver failed: {message}') + + + def _failSaveFrame( + self, index: int, message: str, + acknowledgements=None, failureEvent=None) -> None: + if acknowledgements is None: + acknowledgements = self.saveAcknowledgements + if failureEvent is None: + failureEvent = self.saveFailureEvent + try: + if acknowledgements is not None: + acknowledgements.failed(index) + except Exception as e: + message = f'{message}; updating coordinate acknowledgements failed: {e}' + self._setRunSaverFailure(message, failureEvent) + + + def _saverWorkerExited(self, saveproc, saveThreads) -> bool: + try: + if saveproc is not None and saveproc.pid is not None: + return not saveproc.is_alive() + if saveThreads: + return any(not thread.is_alive() for thread in saveThreads) + except Exception: + return True + return False + + + def _saveStatusLoop( + self, statusQueue, stopEvent, acknowledgements, + saveproc, saveThreads, failureEvent) -> None: + while True: + try: + status, index, channel, error = statusQueue.get( + timeout=0.1 + ) + except Empty: + if stopEvent.is_set(): + break + if failureEvent is self.saveFailureEvent \ + and not self._recordingCleanupStarted \ + and self._saverWorkerExited(saveproc, saveThreads): + self._setRunSaverFailure( + 'Image saver worker exited unexpectedly', + failureEvent, + ) + continue + except Exception as e: + self._setRunSaverFailure( + f'Receiving image saver acknowledgement failed: {e}', + failureEvent, + ) + break + + if status == 'saved': + try: + if acknowledgements is None: + raise RuntimeError('save acknowledgements are unavailable') + acknowledgements.saved(int(index), int(channel)) + except Exception as e: + self._failSaveFrame( + int(index), + f'Writing acknowledged coordinates failed: {e}', + acknowledgements, + failureEvent, + ) + elif status == 'failed': + self._failSaveFrame( + int(index), + f'Writing frame {index}, channel {channel} failed: {error}', + acknowledgements, + failureEvent, + ) + else: + self._setRunSaverFailure( + f'Image saver returned an unknown status: {status}', + failureEvent, + ) def initCoordinateFile(self) -> TextIOWrapper: @@ -2232,7 +2738,7 @@ def initCoordinateFile(self) -> TextIOWrapper: nframes = self.app.config.getint('Experiment', 'nframes') coordinateFile.write(f'nframes {nframes}\n') - # Camera + # Camera coordinateFile.write(f'# Camera\n') # framerate framerate = self.camera.ResultingFrameRate() @@ -2303,48 +2809,92 @@ def initCoordinateFile(self) -> TextIOWrapper: # area area = self.app.config.getint('Tracking', 'area') coordinateFile.write(f'area {area}\n') - + # Write recording header - coordinateFile.write(f"# Frame Time X Y Z minBrightness maxBrightness meanBrightness medianBrightness skewness percentile_5 percentile_95, daqVol\n") + coordinateFile.write(f"# Frame Time X Y Z minBrightness maxBrightness meanBrightness medianBrightness skewness percentile_5 percentile_95 daqVol\n") return coordinateFile - + @override def stopImageAcquisition(self) -> None: - """Extend the stop image acquisition functionality: + """Extend the stop image acquisition functionality: - Stop the camera - Closing the coordinate file. - Closing the image saving thread. - Update display texts. - Un-disabled (enable if) the LiveView button - """ - - if self.camera is None or self.frameCounter == 0: - return - - # If the live view button was previously running, - # then set the transitioning "OnHold" flag. - if self.prevLiveViewButtonState == 'down': - self.camera.setIsOnHold(True) + """ - print(f'Recorded {self.frameCounter} frames') + with self._recordingCleanupLock: + if self._recordingCleanupStarted: + return + self._recordingCleanupStarted = True - # Reset frame counter + recordedFrames = self.frameCounter self.frameCounter = 0 + print(f'Recorded {recordedFrames} frames') - # Stop the camera and clear values - super().stopImageAcquisition() + try: + if self.camera is not None and self.prevLiveViewButtonState == 'down': + self.camera.setIsOnHold(True) + except Exception as e: + print(f'Setting camera hold state failed: {e}') + super().stopImageAcquisition() print('Stopped recording') - # Schedule closing coordinate file a bit later - Clock.schedule_once(lambda dt: self.coordinateFile.close(), 0.5) - - # Close saving threads - if self.savingthread: - self.imageQueue.put(None) - self.savingthread.join() + acquisitionThread = self.imageAcquisitionThread + if acquisitionThread is not None \ + and acquisitionThread is not current_thread() \ + and acquisitionThread.is_alive(): + acquisitionThread.join(SAVE_ACQUISITION_JOIN_TIMEOUT) + if acquisitionThread.is_alive(): + self._setSaverFailure( + 'Image acquisition did not stop before saver shutdown' + ) + + try: + self._cleanupImageSaver() + except Exception as e: + print(f'Stopping image saver failed: {e}') + + if not self._saveStatusAbandoned: + try: + if self.coordinateFile is not None \ + and not self.coordinateFile.closed: + coordinateFile = self.coordinateFile + closed, closeError, closeThread = \ + image_saver.close_file_with_timeout( + coordinateFile, COORDINATE_CLOSE_TIMEOUT + ) + self.coordinateFile = None + if not closed: + if closeThread.is_alive(): + self._abandonedSavers.append( + (coordinateFile, closeThread) + ) + message = 'Coordinate file did not close before the shutdown deadline' + else: + message = f'Closing coordinate file failed: {closeError}' + print(message) + self._setSaverFailure(message) + except Exception as e: + print(f'Closing coordinate file failed: {e}') + + savedFrames = 0 + failedFrames = 0 + if self.saveAcknowledgements is not None: + savedFrames = self.saveAcknowledgements.saved_frames + failedFrames = self.saveAcknowledgements.failed_frames + print( + f'Image saving completed: {savedFrames} saved, ' + f'{failedFrames} failed, {recordedFrames} captured' + ) + if self.saveHandoffError is not None: + print(f'WARNING: recording stopped after saver failure: {self.saveHandoffError}') + + # Set LiveView button state back to enable. @@ -2356,17 +2906,30 @@ def stopImageAcquisition(self) -> None: # within the same Kivy render timeframe as this thread. By calling it through Clock.schedule_once, # we essentially schedule the on_state to be call in the next Kivy render timeframe, ensuring that # it is not invoked from a thread but from the main thread always. + recordingCamera = self.camera + def resumeButtonsState(*args): # LiveView self.parent.liveviewbutton.disabled = False - self.parent.liveviewbutton.state = self.prevLiveViewButtonState - - Clock.schedule_once( resumeButtonsState ) + if not getattr(self.app, '_hardware_teardown', False) \ + and self.app.camera is recordingCamera: + self.parent.liveviewbutton.state = self.prevLiveViewButtonState + else: + self.parent.liveviewbutton.state = 'normal' + + try: + Clock.schedule_once(resumeButtonsState) + except Exception as e: + print(f'Restoring acquisition buttons failed: {e}') # Reset the DAQ state if self.app.daqControl.isConnected() and self.app.daqControl.daqMode != DAQMode.Off: - self.app.daqControl.reset() - + try: + self.app.daqControl.reset() + except Exception as e: + print(f'Resetting DAQ after acquisition failed: {e}') + self.app.daqControl.safe_off() + @override def acquisitionCondition(self) -> bool: @@ -2374,9 +2937,10 @@ def acquisitionCondition(self) -> bool: return (self.camera is not None) \ and (self.camera.IsGrabbing() or self.camera.isOnHold()) \ and (self.isContinuous or (self.frameCounter < self.numberRecordframes)) \ + and (self.saveFailureEvent is None or not self.saveFailureEvent.is_set()) \ and self.state == 'down' - + @override def processImageCallback(self, image, imageTimeStamp, imageRetrieveTimeStamp) -> None: @@ -2390,7 +2954,7 @@ def processImageCallback(self, image, imageTimeStamp, imageRetrieveTimeStamp) -> # if not showliveanalysis: # self.computeLiveAnalysisValues() - + @override def receiveImageCallback(self) -> None: """Extended to further: @@ -2398,58 +2962,48 @@ def receiveImageCallback(self) -> None: - Put the image into an image saving queue. """ - # Write coordinate into file. - if not self.coordinateFile.closed: - try: - with self.parent.liveAnalysisData.lock: - self.coordinateFile.write(f"{self.frameCounter} \ -{self.imageTimeStamp} \ -{self.app.coords[0]} \ -{self.app.coords[1]} \ -{self.app.coords[2]} \ -{self.parent.liveAnalysisData.minBrightness} \ -{self.parent.liveAnalysisData.maxBrightness} \ -{self.parent.liveAnalysisData.meanBrightness} \ -{self.parent.liveAnalysisData.medianBrightness} \ -{self.parent.liveAnalysisData.skewness} \ -{self.parent.liveAnalysisData.percentile_5} \ -{self.parent.liveAnalysisData.percentile_95} \ -{self.app.daqControl.currentVoltage}\n") - - # Handle error from writing the file, such as ValueError: I/O operation on closed file. - except ValueError as e: - print(f'Error writing coordinateFile: {e}') - - # Put image(s) into the saving queue - if not self.isDualColorMode or ( self.isDualColorMode and self.dualColorRecordingMode == 'Original' ): - # Put the full image - self.imageQueue.put([ - np.copy(self.image), - self.saveFilePath, - self.imageFilenameFormat.format(self.frameCounter) - ]) - - elif self.isDualColorMode and self.dualColorRecordingMode == 'Splitted': - # Put the dual color main and minor images - mainImageFileName = self.imageFilenameFormat.format(self.frameCounter) - minorImageFileName = str(mainImageFileName) - - extensionLen = len(self.imageFilenameExtension) + if self.isDualColorMode and self.dualColorRecordingMode == 'Splitted': + saveImages = ( + (self.dualColorMainSideImage, 1), + (self.dualColorMinorSideImage, 2), + ) + else: + saveImages = ((self.originalImage, 0),) + + frameIndex = self.frameCounter + coords = tuple(self.app.coords[:3]) + with self.parent.liveAnalysisData.lock: + analysis = self.parent.liveAnalysisData + coordinateRow = ( + f'{frameIndex} {self.imageTimeStamp} ' + f'{coords[0]} {coords[1]} {coords[2]} ' + f'{analysis.minBrightness} {analysis.maxBrightness} ' + f'{analysis.meanBrightness} {analysis.medianBrightness} ' + f'{analysis.skewness} {analysis.percentile_5} ' + f'{analysis.percentile_95} ' + f'{self.app.daqControl.currentVoltage}\n' + ) - mainImageFileName = mainImageFileName[:-(extensionLen+1)] + '-main.' + self.imageFilenameExtension - minorImageFileName = minorImageFileName[:-(extensionLen+1)] + '-minor.' + self.imageFilenameExtension + self._startImageSaver(saveImages[0][0]) + if self.saveAcknowledgements is None: + raise RuntimeError('save acknowledgements are unavailable') + self.saveAcknowledgements.add( + frameIndex, + coordinateRow, + (channel for _, channel in saveImages), + ) - mainImageFileName = mainImageFileName[:] - self.imageQueue.put([ - np.copy(self.dualColorMainSideImage), - self.saveFilePath, - mainImageFileName - ]) - self.imageQueue.put([ - np.copy(self.dualColorMinorSideImage), - self.saveFilePath, - minorImageFileName - ]) + for saveImage, channel in saveImages: + try: + self.saveHandoffQueue.put_nowait( + (saveImage, frameIndex, channel) + ) + except Full: + self._failSaveFrame( + frameIndex, + f'Image handoff queue filled at frame {frameIndex}' + ) + break self.frameCounter += 1 @@ -2460,7 +3014,7 @@ def receiveImageCallback(self) -> None: if self.frameCounter == self.numberRecordframes \ and not self.isContinuous \ and self.prevLiveViewButtonState == 'down': - + self.camera.setIsOnHold(True) @@ -2471,27 +3025,64 @@ def receiveImageCallback(self) -> None: imageAcquisitionManager: ImageAcquisitionManager = self.parent self.app.daqControl.update( - frameNum= self.runtimeControls.framecounter.value, - frameTime= imageAcquisitionManager.currentTime - imageAcquisitionManager.startTime, - stagePosition= self.app.coords, + frameNum= self.runtimeControls.framecounter.value, + frameTime= imageAcquisitionManager.currentTime - imageAcquisitionManager.startTime, + stagePosition= self.app.coords, posHist= self.runtimeControls.posHist ) - + super().receiveImageCallback() - + + + def _saveHandoffLoop( + self, handoffQueue, stopEvent, imageQueue, + failureEvent, acknowledgements) -> None: + while True: + try: + image, idx, channel = handoffQueue.get(timeout=0.1) + except Empty: + if stopEvent.is_set(): + break + continue + + if failureEvent is not None and failureEvent.is_set(): + try: + if acknowledgements is not None: + acknowledgements.failed(idx) + except Exception as e: + print(f'Discarding failed image acknowledgement failed: {e}') + continue + + try: + if USE_SHARED_MEMORY_SAVER: + imageQueue.put( + {'img': image, 'idx': idx, 'channel': channel} + ) + else: + imageQueue.put( + {'img': image, 'idx': idx, 'channel': channel}, timeout=0.1 + ) + except Full: + self._failSaveFrame( + idx, + f'Image saver queue filled at frame {idx}', + acknowledgements, + failureEvent, + ) + except Exception as e: + self._failSaveFrame( + idx, + f'Passing frame {idx} to the image saver failed: {e}', + acknowledgements, + failureEvent, + ) + @override def finishAcquisitionCallback(self) -> None: """Send stop signal to image saving threads and stop image acquisition. - """ - # Send signal to terminate recording workers - self.imageQueue.put(None) - - # Reset the DAQ state - if self.app.daqControl.isConnected() and self.app.daqControl.daqMode != DAQMode.Off: - self.app.daqControl.reset() - + """ # There are two ways to reach this point: # a. Manually stop recording by clicking the Record button # b. Automatically after recorded target number of frames. @@ -2499,11 +3090,11 @@ def finishAcquisitionCallback(self) -> None: # In case b.) the button state will still be 'down' and therefore we need to call stopImageAcquisition() procedure. if self.state == 'down': self.stopImageAcquisition() - + def initRecordingParams(self): """Initialize the recording arguments - """ + """ # Setup grabbing with recording settings self.numberRecordframes = self.app.config.getint('Experiment', 'nframes') @@ -2523,12 +3114,12 @@ def initRecordingParams(self): # pre-calculate the filename self.imageFilenameExtension = self.app.config.get('Experiment', 'extension') self.imageFilenameFormat = timeStamped("basler_{}."+f"{self.imageFilenameExtension}") - + class ImageAcquisitionManager(BoxLayout): """An ImageAcquisition buttons holder widget. This class acts as a centralized contact point for accessing the acquired images. - """ + """ recordbutton: RecordButton = ObjectProperty(None, rebind = True) liveviewbutton: LiveViewButton = ObjectProperty(None, rebind = True) snapbutton: Button = ObjectProperty(None, rebind = True) @@ -2555,10 +3146,10 @@ def snap(self) -> None: path = self.app.root.ids.leftcolumn.savefile snap_filename = timeStamped("snap."+f"{ext}") camera = self.app.camera - + if camera is None: return - + # Get an image appropriately acoording to current viewing mode if self.recordbutton.state == 'down' or self.liveviewbutton.state == 'down': # save the current image @@ -2567,7 +3158,6 @@ def snap(self) -> None: else: # Call capture an image isSuccess, img = camera.singleTake() - if isSuccess: basler.saveImage(img, path, snap_filename) @@ -2585,7 +3175,7 @@ def on_touch_down(self, touch): return super().on_touch_down(touch) else: return False - + def on_touch_up(self, touch): """Limits subsequent interactions to only be activated if it's within the StencilFloatLayout """ @@ -2594,11 +3184,11 @@ def on_touch_up(self, touch): else: return False - + class ScalableImage(ScatterLayout): def on_touch_up(self, touch): - + # If the widget is enabled and interaction point is inside its bounding box if self.disabled or not self.collide_point(*touch.pos): return False @@ -2634,15 +3224,15 @@ def __init__(self, **kwargs): def mouse_pos(self, window, pos): """Calculate relative mouse position to the preview image and update the inspect pixel value text at the bottom right corner of the GUI. - """ + """ if not hasattr(self, 'app'): self.app = App.get_running_app() - + image: np.ndarray = self.app.image - + if image is None: return - + mouse_pos = np.array(pos, np.float32) previewImage = self @@ -2679,7 +3269,7 @@ def mouse_pos(self, window, pos): # Update info text self.app.root.ids.middlecolumn.ids.pixelvalue.text = f'x: {self.mouse_pos_in_tex_coord[0]}, y: {self.mouse_pos_in_tex_coord[1]}, intensity: {pixelVal}' - return + return def captureCircle(self, pos): @@ -2718,14 +3308,14 @@ def on_touch_down(self, touch): # Start tracking procedure Clock.schedule_once(lambda dt: runtimeControls.startTracking(self.mouse_pos_in_tex_coord), 0) - - # remove the circle + + # remove the circle # Clock.schedule_once((lambda dt: self.circle = (0, 0, 0)), 0.5) Clock.schedule_once(lambda dt: self.clearcircle(), 0.5) class LiveAnalysisLabel(Label): - + def __init__(self, **kwargs): super(LiveAnalysisLabel, self).__init__(**kwargs) self.updateText(LiveAnalysisData()) @@ -2737,9 +3327,6 @@ def on_touch_down(self, touch): def updateText(self, liveAnalysisData: LiveAnalysisData): - # Get LiveAnalysisData from ImageAcquisition - app: GlowTrackerApp = App.get_running_app() - self.text = f"""Min: {liveAnalysisData.minBrightness:.2f} Max: {liveAnalysisData.maxBrightness:.2f} Mean: {liveAnalysisData.meanBrightness:.2f} @@ -2751,8 +3338,8 @@ def updateText(self, liveAnalysisData: LiveAnalysisData): class ImageOverlay(FloatLayout): """An image overlay class than handles drawing of GUI overlays ontop of the image. - """ - + """ + def __init__(self, **kwargs): super(ImageOverlay, self).__init__(**kwargs) # Declare class instance's attributes @@ -2789,13 +3376,13 @@ def resizeToImage(self) -> None: imageWidgetSize = previewImage.size self.pos[0] = (imageWidgetSize[0] - normImageSize[0]) / 2 self.pos[1] = (imageWidgetSize[1] - normImageSize[1]) / 2 - - + + def on_size(self, *args) -> None: """Called everytime the widget is resized. Resize the overlay to match the image and redraw. - """ + """ self.updateOverlay() - + @mainthread def updateOverlay(self) -> None: @@ -2804,11 +3391,11 @@ def updateOverlay(self) -> None: 2. Clear all the overlay 3. Redraw all the overlay """ - + # If the app has just started with a logo then don't draw any overlay if self.app.image is None: return - + # Resize the overlay to match the image self.resizeToImage() @@ -2817,18 +3404,18 @@ def updateOverlay(self) -> None: # Update dual color overlay dualcolormode = self.app.config.getboolean('DualColor', 'dualcolormode') - + if dualcolormode: mainside = self.app.config.get('DualColor', 'mainside') self.drawDualColorOverlay(mainside) - + # Update tracking overlay showtrackingoverlay = self.app.config.getboolean('Tracking', 'showtrackingoverlay') - + if showtrackingoverlay: self.updateTrackingOverlay(doClear= False) - + def updateTrackingOverlay(self, doClear: bool = True): """Gather tracking overlay data and draw. @@ -2838,7 +3425,7 @@ def updateTrackingOverlay(self, doClear: bool = True): cmsOffset_x, cmsOffset_y = 0, 0 trackingMask = np.zeros(0) - trail = np.empty([1, 2]) + trail = np.empty((0, 2)) # Trail is a n-by-2 matrix of stage position history, with first entry be the oldest and last be the latest. rtc: RuntimeControls = self.app.root.ids.middlecolumn.runtimecontrols @@ -2860,8 +3447,8 @@ def updateTrackingOverlay(self, doClear: bool = True): self.clearTrackingOverlay() self.drawTrackingOverlay(cmsOffset_x, cmsOffset_y, trackingMask, trail) - - + + def computeTrackingOverlayBorderBBox(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """Compute the tracking overlay bounding box in the local widget space. @@ -2881,18 +3468,18 @@ def computeTrackingOverlayBorderBBox(self) -> Tuple[np.ndarray, np.ndarray, np.n dualColorMode = self.app.config.getboolean('DualColor', 'dualcolormode') dualColorViewMode = self.app.config.get('DualColor', 'viewmode') - # If we are using the dual color and viewing the 'Splitted' mode, - # then we have to shift the center of tracking border to the left ro right + # If we are using the dual color and viewing the 'Splitted' mode, + # then we have to shift the center of tracking border to the left ro right # side accordingly. if dualColorMode and dualColorViewMode == 'Splitted': mainSide = self.app.config.get('DualColor', 'mainside') - + if mainSide == 'Left': center[0] -= normImageSize[0]/4 elif mainSide == 'Right': center[0] += normImageSize[0]/4 - + # Compute the overlay bbox imageSize = previewImage.texture_size displayedScale = normImageSize[0] / imageSize[0] @@ -2918,8 +3505,8 @@ def computeTrackingOverlayBorderBBox(self) -> Tuple[np.ndarray, np.ndarray, np.n top_right = np.fmin(top_right, image_top_right) return center, btm_left, top_right - - + + def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: float | None = None, trackingMask: np.ndarray | None = None, trail: np.ndarray | None = None) -> None: """Draw the tracking info overlay. 1. Draw the tracking mask if provided @@ -2931,7 +3518,7 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo cmsOffset_y (float | None, optional): center of mass position as an ofset from the center of the image. Defaults to None. trackingMask (np.ndarray | None, optional): 2D uint8 numpy array representing the mask that is used for calculating the center of mass. Defaults to None. """ - + # Frequently used variables center, btm_left, top_right = self.computeTrackingOverlayBorderBBox() @@ -2941,7 +3528,7 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo imageSize = previewImage.texture_size displayedScale = normImageSize[0] / imageSize[0] - + # px -> meter imageToStageRotOnlyMat_XY = macro.swapMatXYOrder(self.app.imageToStageRotMat) # meter -> px @@ -2958,11 +3545,11 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo stageToImageMat = stageToImageRotOnlyMat / pixelsize_meter - # + # # Check if needs to draw tracking mask - # + # if trackingMask is not None: - + if self.trackingMaskLayout is None: # Create a FloatLayout @@ -2977,7 +3564,7 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo # Add the Image widget self.trackingMaskLayout.add_widget(self.trackingMask) - + if self.trackingMask.texture is None \ or self.trackingMask.texture.width != trackingMask.shape[1] \ or self.trackingMask.texture.height != trackingMask.shape[0]: @@ -3010,9 +3597,9 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo imageByteBuffer: bytes = trackingMaskColor.tobytes() self.trackingMask.texture.blit_buffer(imageByteBuffer, colorfmt= 'rgba', bufferfmt= 'ubyte') - # + # # Check if needs to reconstruct the tracking border - # + # if self.trackingBorder is None: trackingBorderPoints = [ @@ -3029,38 +3616,38 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo self.canvas.add(Color(1., 0., 0., 0.5)) self.canvas.add(self.trackingBorder) - # + # # Draw tracking center of mass if provided - # + # if cmsOffset_x is not None and cmsOffset_y is not None: - + # Compute cms draw position - cms = center + np.array([cmsOffset_x, cmsOffset_y]) * displayedScale + cms = center + np.array([cmsOffset_x, cmsOffset_y]) * displayedScale pointRadius = 10 * displayedScale if self.cmsShape is None: # If the tracking shape is not yet created, create it and draw self.cmsShape = Ellipse( - pos= (cms[0] - pointRadius, cms[1] - pointRadius), + pos= (cms[0] - pointRadius, cms[1] - pointRadius), size= (pointRadius * 2, pointRadius * 2) ) # Draw the cms as a teal dot self.canvas.add(Color(0.435, 0.957, 1.0, 0.75)) self.canvas.add(self.cmsShape) - + else: # Else just update the position self.cmsShape.pos = (cms[0] - pointRadius, cms[1] - pointRadius) - - - # + + + # # Draw 1mm guildeline - # - showguideline = self.app.config.getboolean('DaqControl', 'showguideline') + # + showguideline = self.app.config.getboolean('DaqControl', 'showguideline') if showguideline: - + # Draw a guide-line. 1mm from center to right. This position in meter. guideline = np.array([[0, 0], [1e-3, 0]], np.float32) @@ -3068,8 +3655,8 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo guideline_imageSpace = guideline / pixelsize_meter self._updateLineMesh( - mesh= self.guidelineMesh, - vertices_in= guideline_imageSpace, + mesh= self.guidelineMesh, + vertices_in= guideline_imageSpace, color= Color(1.0, 1,0, 0.0, 0.75), stageToImageMat= np.identity(n= 2), displayedScale= displayedScale, @@ -3077,48 +3664,48 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo isCenterAtFirstVertex= True ) - # + # # Draw tracking trail - # - showtrail = self.app.config.getboolean('DaqControl', 'showtrail') - if showtrail: - + # + showtrail = self.app.config.getboolean('DaqControl', 'showtrail') + if showtrail and len(trail) > 0: + self._updateLineMesh( - mesh= self.trailMesh, - vertices_in= trail, + mesh= self.trailMesh, + vertices_in= trail, color= Color(0.9, 0.0, 1.0, 0.75), stageToImageMat= stageToImageMat, displayedScale= displayedScale, screenCenter= center, isCenterAtFirstVertex= False ) - - # + + # # Draw reversal detection indicators - # - showreversalindicator = self.app.config.getboolean('DaqControl', 'showreversalindicator') - if showreversalindicator: - # + # + showreversalindicator = self.app.config.getboolean('DaqControl', 'showreversalindicator') + if showreversalindicator and len(trail) > 1: + # # Draw Body line - # - - # We have trail positions in mm + # + + # We have trail positions in mm animallength_um = self.app.config.getfloat('DaqControl', 'animallength') animallength = animallength_um * 1e-6 - # Get last M (trial limit) vertices and + # Get last M (trial limit) vertices and # apply transformation to each row vertex traillimit = self.app.config.getint('DaqControl', 'traillimit') - + croppedTrail = trail[-traillimit::, :] # Greedy sums up until equal or exceed animal's length # Get a reversed view: from bottom (most recent/head) to top (first point in the history) revTrail = croppedTrail[::-1] sumLength = 0 - + tailIndex = 0 - + for i in range(1, len(revTrail)): length = np.linalg.norm(revTrail[i-1] - revTrail[i]) sumLength = sumLength + length @@ -3126,7 +3713,7 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo if sumLength >= animallength: break - + # Copy points from head to tail bodyVert = revTrail[0:tailIndex+1:1] # print(f'bodylength: {sumLength:.4f} meter, verts: {len(bodyVert)}') @@ -3135,26 +3722,26 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo if len(bodyVert) > 1: self._updateLineMesh( - mesh= self.bodyMesh, - vertices_in= bodyVert, + mesh= self.bodyMesh, + vertices_in= bodyVert, color= Color(1.0, 1.0, 0.0, 0.75), stageToImageMat= stageToImageMat, displayedScale= displayedScale, screenCenter= center, isCenterAtFirstVertex= True ) - - # + + # # Draw vector from tail to head - # + # # We have bodyVert: Bx2 (B:= body length), rows of point from head to tail head = bodyVert[0] tail = bodyVert[-1] tailToHeadVert = np.vstack([tail, head]) self._updateLineMesh( - mesh= self.tailToHeadMesh, - vertices_in= tailToHeadVert, + mesh= self.tailToHeadMesh, + vertices_in= tailToHeadVert, color= Color(0.0, 1.0, 1.0, 0.75), stageToImageMat= stageToImageMat, displayedScale= displayedScale, @@ -3162,12 +3749,13 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo isCenterAtFirstVertex= False ) - # + # # Estimate velocity - # + # velocityHistoryPercentage = self.app.config.getfloat('DaqControl', 'velocityhistorypercentage') numHistVert = round(len(bodyVert) * velocityHistoryPercentage / 100) + numHistVert = max(2, min(len(bodyVert), numHistVert)) # Slice from head to numHistVert histVert = bodyVert[0:numHistVert] @@ -3175,30 +3763,34 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo velocities = histVert[0:-1] - histVert[1:] # Uniform weighted average - velocity = np.sum(velocities, axis= 0) / len(velocities) + velocity = np.mean(velocities, axis=0) + + velocityNorm = np.linalg.norm(velocity) + bodyDirection = head - tail + if velocityNorm == 0 or np.linalg.norm(bodyDirection) == 0: + return # Draw the directional line as 100 pixel long - directionVert = np.array([[0,0], velocity / np.linalg.norm(velocity)]) * 100 * pixelsize_meter + directionVert = np.array([[0,0], velocity / velocityNorm]) * 100 * pixelsize_meter # Check if the velocity is angling more than the reversal threshold with the the tailToHead body. # If yes, reversal -> red color. # If not, non-reversal -> green color. - vecTailToHead = head - tail - angle_radian = macro.computeAngleBetweenTwo2DVecs(vecTailToHead, velocity) + angle_radian = macro.computeAngleBetweenTwo2DVecs(bodyDirection, velocity) angle_degree = angle_radian * 180 / math.pi - reversalthresholdradian = self.app.config.getfloat('DaqControl', 'reversalthresholdradian') + reversalthresholdradian = self.app.config.getfloat('DaqControl', 'reversalthresholdradian') self.velocityMeshColor.rgba = [0, 1, 0, 0.75] - + if angle_degree > reversalthresholdradian or angle_degree < -reversalthresholdradian: self.velocityMeshColor.rgba = [1, 0, 0, 0.75] print("Reversing!") self._updateLineMesh( - mesh= self.velocityMesh, - vertices_in= directionVert, + mesh= self.velocityMesh, + vertices_in= directionVert, color= self.velocityMeshColor, stageToImageMat= stageToImageMat, displayedScale= displayedScale, @@ -3208,13 +3800,13 @@ def drawTrackingOverlay(self, cmsOffset_x: float | None = None, cmsOffset_y: flo def _updateLineMesh( - self, - mesh: Mesh, - vertices_in: np.ndarray, - color: Color, - stageToImageMat: np.ndarray, - displayedScale: float, - screenCenter: np.ndarray, + self, + mesh: Mesh, + vertices_in: np.ndarray, + color: Color, + stageToImageMat: np.ndarray, + displayedScale: float, + screenCenter: np.ndarray, isCenterAtFirstVertex: bool ) -> None: # vertices_in is N x 2 mat in stage space (meter). Each row contains a vertex in XY @@ -3227,13 +3819,13 @@ def _updateLineMesh( # Transfrom back to column matrix verts_imageCoord = verts_imageCoord.transpose() - # Transform to screen space + # Transform to screen space verts_screenCoord = verts_imageCoord * displayedScale offsetToCenter = screenCenter - ( verts_screenCoord[0] if isCenterAtFirstVertex else verts_screenCoord[-1] ) verts_screenCoord = verts_screenCoord + offsetToCenter - + # Construct vertex array # [[x1, y1, u1, v1], [x2, y2, u2, v2], ...] verts_with_uv = np.column_stack([verts_screenCoord, np.zeros(verts_screenCoord.shape)]) @@ -3251,21 +3843,21 @@ def _updateLineMesh( if mesh not in self.canvas.children: self.canvas.add(color) self.canvas.add(mesh) - + def clearTrackingOverlay(self): """Clear the tracking info overlay """ - + if self.trackingMaskLayout is not None: self.remove_widget(self.trackingMaskLayout) self.trackingMaskLayout.clear_widgets() self.trackingMaskLayout = None - + if self.trackingMask is not None: self.trackingMask.texture = None self.remove_widget(self.trackingMask) - + if self.trackingBorder is not None: self.canvas.remove(self.trackingBorder) self.trackingBorder = None @@ -3273,29 +3865,29 @@ def clearTrackingOverlay(self): if self.cmsShape is not None: self.canvas.remove(self.cmsShape) self.cmsShape = None - + if self.trailMesh in self.canvas.children: self.canvas.remove(self.trailMesh) if self.guidelineMesh in self.canvas.children: self.canvas.remove(self.guidelineMesh) - + if self.bodyMesh in self.canvas.children: self.canvas.remove(self.bodyMesh) if self.tailToHeadMesh in self.canvas.children: self.canvas.remove(self.tailToHeadMesh) - + if self.velocityMesh in self.canvas.children: self.canvas.remove(self.velocityMesh) - + def redrawDualColorOverlay(self, mainSide: str= 'Right'): """Redraw the dual color overlay by clear and draw. Args: mainSide (str, optional): Main side of the dual color mode. Defaults to 'Right'. - """ + """ self.clearDualColorOverlay() self.drawDualColorOverlay(mainSide) @@ -3308,7 +3900,7 @@ def drawDualColorOverlay(self, mainSide: str= 'Right'): """ if self.hasDrawDualColorOverlay: return - + self.hasDrawDualColorOverlay = True previewImage: PreviewImage = self.app.root.ids.middlecolumn.previewimage @@ -3317,21 +3909,21 @@ def drawDualColorOverlay(self, mainSide: str= 'Right'): if viewMode == 'Splitted': - # + # # Red line at the middle - # + # pos_center_local = self.to_local(self.center_x, self.center_y) p1 = (pos_center_local[0], pos_center_local[1] + self.height/2) p2 = (pos_center_local[0], pos_center_local[1] - self.height/2) self.canvas.add(Color(1., 0., 0., 0.5)) self.canvas.add(Line(points= [p1[0], p1[1], p2[0], p2[1]], width= 1, cap= 'none')) - # + # # Label on the main side - # + # if self.label is None: # Create a Label and add it as a child - self.label = Label(text= '[color=8e0045]Main[/color]', markup= True) + self.label = Label(text= '[color=8e0045]Main[/color]', markup= True) self.label.size_hint = [None, None] self.label.valign = 'top' self.label.halign = 'left' @@ -3343,20 +3935,20 @@ def drawDualColorOverlay(self, mainSide: str= 'Right'): # so we will mimick this by re-adding it again. self.remove_widget(self.label) self.add_widget(self.label) - + self.label.size = self.label.texture_size # Compute label position normImageSize = previewImage.get_norm_image_size() - labelDisplayedSize = np.array(self.label.texture_size) - + labelDisplayedSize = np.array(self.label.texture_size) + labelOffset_x = pos_center_local[0] - labelDisplayedSize[0]/2 if mainSide == 'Left': labelOffset_x -= normImageSize[0]/4 elif mainSide == 'Right': labelOffset_x += normImageSize[0]/4 - + # Compute position at the top labelOffset_y = pos_center_local[1] + normImageSize[1]/2 - labelDisplayedSize[1] # Further adjust to look prettier @@ -3364,12 +3956,12 @@ def drawDualColorOverlay(self, mainSide: str= 'Right'): self.label.pos = [float(labelOffset_x), float(labelOffset_y)] - + elif viewMode == 'Merged': - # + # # Label on the header - # + # if self.label is None: # Create a Label and add it as a child self.label = Label(text= '[color=8e0045]Dual Color: Merged[/color]', markup= True) @@ -3378,31 +3970,31 @@ def drawDualColorOverlay(self, mainSide: str= 'Right'): self.label.halign = 'left' self.label.texture_update() self.add_widget(self.label) - + else: # In this case, the self.canvas.clear() has been called so we have to redraw the label. # Ideally, we would like to call self.canvas.add( some label draw instruction ) but I can't find it # so we will mimick this by re-adding it again. self.remove_widget(self.label) self.add_widget(self.label) - + self.label.size = self.label.texture_size # Compute label position normImageSize = previewImage.get_norm_image_size() - labelDisplayedSize = np.array(self.label.texture_size) - + labelDisplayedSize = np.array(self.label.texture_size) + # Compute center position pos_center_local = self.to_local(self.center_x, self.center_y) labelOffset_x = pos_center_local[0] - labelDisplayedSize[0]/2 - + # Compute position at the top labelOffset_y = pos_center_local[1] + normImageSize[1]/2 - labelDisplayedSize[1] # Further adjust to look prettier labelOffset_y -= labelDisplayedSize[1] * 0.75 self.label.pos = [float(labelOffset_x), float(labelOffset_y)] - + def clearDualColorOverlay(self): """Clear the canvas and set internal hasDraw flag to false @@ -3410,13 +4002,13 @@ def clearDualColorOverlay(self): self.canvas.clear() self.hasDrawDualColorOverlay = False - + def clearOverlay(self) -> None: """Clear both tracking and dual color overlay. """ self.clearTrackingOverlay() self.clearDualColorOverlay() - + class RuntimeControls(BoxLayout): framecounter = ObjectProperty(rebind=True) @@ -3425,13 +4017,15 @@ class RuntimeControls(BoxLayout): imageacquisitionmanager: ImageAcquisitionManager = ObjectProperty(rebind=True) cropX = NumericProperty(0, rebind=True) cropY = NumericProperty(0, rebind=True) - + def __init__(self, **kwargs): super(RuntimeControls, self).__init__(**kwargs) self.focus_history = [] self.liveFocusThread = None + self.trackthread = None self.focus_motion = 0 + self.track_done = Event() self.isTracking = False self.isShowTrackingDialogueFirstTime = True self.coord_updateevent: ClockEvent | None = None @@ -3451,7 +4045,7 @@ def startLiveFocus(self): """ camera: GlowTrackerApp = App.get_running_app().camera stage: Stage = App.get_running_app().stage - + # Sanity check if camera is not None and stage is not None and camera.IsGrabbing(): @@ -3473,18 +4067,21 @@ def startLiveFocus(self): depthoffield = app.config.getfloat('Camera', 'depthoffield') smoothingwindow = app.config.getint('Autofocus', 'smoothingwindow') minstepbeforechangedir = app.config.getint('Autofocus', 'minstepbeforechangedir') - + coarsestep = app.config.getfloat('Autofocus', 'coarsestep') + buffer_n = app.config.getint('Autofocus', 'buffer_n') + autoFocusPID = AutoFocusPID( KP= KP, KI= KI, KD= KD, SP= SP, focusEstimationMethod= FocusEstimationMethod(focusEstimationMethod), - minStepDist= depthoffield, acceptableErrorPercentage= 0.05, integralLifeTime= 0, smoothingWindow= smoothingwindow, - minStepBeforeChangeDir= minstepbeforechangedir + minStepBeforeChangeDir= minstepbeforechangedir, + coarseStep= coarsestep, + buffer_n= buffer_n ) # Data handle from LiveFocus thread to plotting in main thread @@ -3516,13 +4113,13 @@ def startLiveFocus(self): plt.legend() plt.show() - # Event to update the graph + # Event to update the graph def updateLiveFocusGraph( dt: float ): # Empty guard if len(graph_x_data) == 0 and len(graph_y_data) == 0: return - + with graph_data_lock: linePlotHandle.set_xdata(graph_x_data) linePlotHandle.set_ydata(graph_y_data) @@ -3558,7 +4155,7 @@ def updateLiveFocusGraph( dt: float ): self._popup.open() self.livefocuscheckbox.state = 'normal' - + def _liveFocus(self, autoFocusPID: AutoFocusPID, camera: basler.Camera, stage: Stage, dualColorMode: bool = False, capturedRadius: float = 0, isShowGraph: bool = False, fps: float = 10.0, graph_x_data: List[float] = list(), graph_y_data: List[float] = list(), graph_data_lock: Lock = None) -> None: """Autofocus loop to be executed inside a thread. @@ -3600,18 +4197,24 @@ def _liveFocus(self, autoFocusPID: AutoFocusPID, camera: basler.Camera, stage: S # Perform one autofocus step relPosZ = autoFocusPID.executePIDStep(croppedImage, pos= pos) - # Move relative z-position - stage.move_z(relPosZ, unit='mm', wait_until_idle= False) + if autoFocusPID.focusLog: + print(f'PV={autoFocusPID.focusLog[-1]:.2f} best={autoFocusPID.bestFocus:.2f} step={autoFocusPID.step:.5f} dir={autoFocusPID.direction} relZ={relPosZ:.5f}') + + if self.livefocuscheckbox.state != 'down' \ + or getattr(app, '_hardware_teardown', False): + break + + if stage.move_z(relPosZ, unit='mm', wait_until_idle= False): + app.coords[2] = app.coords[2] + relPosZ - # Update App's internal stage coordinate - app.coords[2] = app.coords[2] + relPosZ - if isShowGraph: - # Update live graph data - with graph_data_lock: - graph_x_data.append(len(autoFocusPID.focusLog) - 1) - graph_y_data.append(autoFocusPID.focusLog[-1]) - + append_new_focus_values( + autoFocusPID.focusLog, + graph_x_data, + graph_y_data, + graph_data_lock, + ) + endTime = time.perf_counter() elapsedTime = endTime - startTime @@ -3621,10 +4224,9 @@ def _liveFocus(self, autoFocusPID: AutoFocusPID, camera: basler.Camera, stage: S # Wait until matching spf if waitTime > 0: time.sleep(waitTime) - - # The live focus has stopped - self.livefocuscheckbox.state == 'normal' - + + self.livefocuscheckbox.state = 'normal' + def stopLiveFocus(self): """Callback to stop LiveFocus mode @@ -3650,35 +4252,47 @@ def trackingButtonCallback(self): self.isShowTrackingDialogueFirstTime = False self._popup = WarningPopup( - title="Click on animal", + title="Click on animal", text = 'Click on an animal to start tracking it.', closeTime= 4, size_hint=(0.5, 0.25) ) self._popup.open() - + else: self._popup = WarningPopup(title="Tracking", text='Tracking requires a stage, a camera and the camera needs to be grabbing.', size_hint=(0.5, 0.25)) self._popup.open() self.trackingcheckbox.state = 'normal' - - def startTracking(self, start_pos_tex_coord: np.array) -> None: + + def startTracking(self, start_pos_tex_coord: np.array, track_interval: float | None = None) -> None: """Start the tracking procedure by gathering variables, setting up the camera, and then spawn a tracking loop. Args: start_pos_tex_coord (np.array): Starting position in the image texture space (full image size). Used to move the stage to center at that position. - """ - app: GlowTrackerApp = App.get_running_app() - stage: Stage = app.stage - units: str = app.config.get('Calibration', 'step_units') - minstep: float = app.config.getfloat('Tracking', 'min_step') - dualColorMode: bool = app.config.getboolean('DualColor', 'dualcolormode') - - # + track_interval (float | None): The interval between tracking updates in seconds. If None, uses the value from the configuration. + """ + app = App.get_running_app() + stage = app.stage + if getattr(app, '_hardware_teardown', False): + self.trackingcheckbox.state = 'normal' + return + if self.trackthread is not None and self.trackthread.is_alive(): + return + units = app.config.get('Calibration', 'step_units') + minstep = app.config.getfloat('Tracking', 'min_step') + dualColorMode = app.config.getboolean('DualColor', 'dualcolormode') + + stage.set_motion( + float(app.config.get('Stage', 'track_speed')), + float(app.config.get('Stage', 'track_acceleration')), + app.config.get('Stage', 'speed_unit'), + app.config.get('Stage', 'acceleration_unit')) + + # # Move stage by the user pointed starting position - # + # # Compute the offset from the center imageHeight, imageWidth = app.image.shape[0], app.image.shape[1] @@ -3686,43 +4300,51 @@ def startTracking(self, start_pos_tex_coord: np.array) -> None: if dualColorMode: # Get the main side mainSide = app.config.get('DualColor', 'mainside') - + # Compute offset from the center of the main side if mainSide == 'Right': offset_from_center = start_pos_tex_coord - np.array([imageWidth*3.0/4, imageHeight/2]) - + elif mainSide == 'Left': offset_from_center = start_pos_tex_coord - np.array([imageWidth*1.0/4, imageHeight/2]) - + else: # In normal mode, compute from the image center offset_from_center = start_pos_tex_coord - np.array([imageWidth/2, imageHeight/2]) - + # Set tracking ROI roiX, roiY = app.config.getint('Tracking', 'roi_x'), app.config.getint('Tracking', 'roi_y') self.set_ROI(roiX, roiY) # Convert from texture coordinates to stage coordinates ystep, xstep = macro.getStageDistances(np.array([offset_from_center[1], offset_from_center[0]]), app.imageToStageMat) - + print('Stage centering image offset:',ystep, xstep, units) # Move the stage if abs(xstep) > minstep: - stage.move_x(xstep, unit= units, wait_until_idle= True) + if not stage.move_x(xstep, unit= units, wait_until_idle= True): + self.trackingcheckbox.state = 'normal' + return if abs(ystep) > minstep: - stage.move_y(ystep, unit= units, wait_until_idle= True) + if not stage.move_y(ystep, unit= units, wait_until_idle= True): + self.trackingcheckbox.state = 'normal' + return # Update stage coordinate in the app - app.coords = app.stage.get_position() + position = app.stage.get_position() + if position is None: + self.trackingcheckbox.state = 'normal' + return + app.coords = position # Record position history self.posHist.clear() self.posHist.append((app.coords[0], app.coords[1], app.coords[2])) - # + # # Start the tracking - # + # capture_radius = app.config.getint('Tracking', 'capture_radius') binning = app.config.getint('Tracking', 'binning') dark_bg = app.config.getboolean('Tracking', 'dark_bg') @@ -3732,19 +4354,55 @@ def startTracking(self, start_pos_tex_coord: np.array) -> None: min_brightness = app.config.getfloat('Tracking', 'min_brightness') max_brightness = app.config.getfloat('Tracking', 'max_brightness') - # make a tracking thread + + # make a tracking thread track_args = minstep, units, capture_radius, binning, dark_bg, area, threshold, trackingMode, min_brightness, max_brightness, self.posHist self.trackthread = Thread(target=self.tracking, args = track_args, daemon = True) self.trackthread.start() print('started tracking thread') + self._track_timeout = None + if track_interval is not None: + self._track_timeout = Clock.schedule_once(lambda dt: setattr(self.trackingcheckbox, 'state', 'normal'), track_interval) + # schedule occasional position check of the stage - self.coord_updateevent = Clock.schedule_interval(lambda dt: stage.get_position(), 10) + # self.coord_updateevent = Clock.schedule_interval(lambda dt: stage.get_position(), 10) + + def _track(self, duration, record = False): + app = App.get_running_app() + rc = app.root.ids.middlecolumn.ids.runtimecontrols + mgr = rc.ids.imageacquisitionmanager + + self.track_done.clear() + + def _start(dt): + if self.track_done.is_set() or getattr(app, '_hardware_teardown', False): + return + mgr.liveviewbutton.state = 'down' + Clock.schedule_interval(_go, 0.1) + + def _go(dt): + if self.track_done.is_set() or getattr(app, '_hardware_teardown', False): + return False + if app.camera is None or not app.camera.IsGrabbing(): + return + h, w = app.image.shape[0], app.image.shape[1] + rc.trackingcheckbox.state = 'down' + rc.startTracking(np.array([w / 2.0, h / 2.0]), track_interval=duration) + rc.livefocuscheckbox.state = 'down' + if record: + mgr.recordbutton.state = 'down' + return False + + Clock.schedule_once(_start) + self.track_done.wait() + if record and not getattr(app, '_hardware_teardown', False): + Clock.schedule_once(lambda dt: setattr(mgr.recordbutton, 'state', 'normal')) def set_ROI(self, roiX, roiY): app: GlowTrackerApp = App.get_running_app() - + hc, wc = app.camera.setROI(roiX, roiY, isCenter = True) print(hc, wc, roiX, roiY) @@ -3755,89 +4413,87 @@ def set_ROI(self, roiX, roiY): if hc > roiY: self.cropY = int((hc-roiY)//2) - + def tracking(self, minstep: int, units: str, capture_radius: int, binning: int, dark_bg: bool, area: int, threshold: int, mode: str, min_brightness: int, max_brightness: int, posHist: List[Vec3]) -> None: + try: + self._trackingLoop( + minstep, units, capture_radius, binning, dark_bg, area, + threshold, mode, min_brightness, max_brightness, posHist + ) + except Exception as e: + print(f'Tracking failed: {e}') + finally: + self.isTracking = False + self.track_done.set() + self.cmsOffset_x = None + self.cmsOffset_y = None + self.trackingMask = None + if self.trackingcheckbox.state == 'down': + Clock.schedule_once( + lambda dt: setattr(self.trackingcheckbox, 'state', 'normal') + ) + + + def _trackingLoop(self, minstep: int, units: str, capture_radius: int, binning: int, dark_bg: bool, area: int, threshold: int, mode: str, min_brightness: int, max_brightness: int, posHist: List[Vec3]) -> None: """Tracking function to be running inside a thread """ app: GlowTrackerApp = App.get_running_app() stage = app.stage camera = app.camera - # Compute second per frame to determine the lower bound waiting time - camera_spf = 1 / camera.ResultingFrameRate() - + unitToMm = {'mm': 1.0, 'um': 0.001}.get(units) + if unitToMm is None: + raise ValueError(f'Unsupported tracking unit {units!r}') + + def cameraActive(): + try: + return camera is not None and (camera.IsGrabbing() or camera.isOnHold()) + except Exception: + return False - # Dual Color mode settings dualColorMode = app.config.getboolean('DualColor', 'dualcolormode') - + self.isTracking = True - image: np.ndarray | None = None - retrieveTimestamp: float = 0 - prevImage: np.ndarray | None = None + prevImage = None scale = 1.0 + SETTLE_FLOOR = 3e-3 + ready_time = 0.0 - estimated_next_timestamp: float | None = None - - while camera is not None and (camera.IsGrabbing() or camera.isOnHold()) and self.trackingcheckbox.state == 'down': - - # Handling image cycle synchronization. - # Because the recording and tracking thread are asynchronous - # and doesn't have the same priority, it could be the case that - # one thread get executed more than the other and the estimated time - # became inaccurate. - wait_time = 0 - if estimated_next_timestamp is not None: - - retrieveTimestamp = self.imageacquisitionmanager.imageRetrieveTimeStamp - diff_estimated_time = estimated_next_timestamp - retrieveTimestamp - - # If the estimated time is approximately close to the image timestamp - # then it's ok to use the current image. The epsilon in this case is 10% of the camera_spf - if abs(diff_estimated_time)/camera_spf < 0.1: - pass - else: - # If the estimated time is less than the current time - # then it is also ok to use the current image - if estimated_next_timestamp < retrieveTimestamp: - pass - # If the estimated time is more than the current image timestamp - # then compute the estimated next cycle time and wait - else: - current_time = time.perf_counter() - - diff_time_factor = (current_time - retrieveTimestamp) / camera_spf - fractional_part, integer_part = math.modf(diff_time_factor) - - wait_time = camera_spf * ( 1.0 - fractional_part ) + bench_window = 30 + bench_n = 0 + bench_fetch = bench_detect = bench_store = bench_convert = bench_move = bench_settle = bench_frame = 0.0 + bench_start = time.perf_counter() - time.sleep(wait_time) - else: - # Wait for the stage to finished moving/centering at location in the - # first time - stage.wait_until_idle() + while cameraActive() and self.trackingcheckbox.state == 'down': - retrieveTimestamp = self.imageacquisitionmanager.imageRetrieveTimeStamp - estimated_next_timestamp = self.imageacquisitionmanager.imageRetrieveTimeStamp + wait_begin = time.perf_counter() + wait_ready = ready_time + while self.trackingcheckbox.state == 'down' \ + and self.imageacquisitionmanager.imageRetrieveTimeStamp <= ready_time: + if not cameraActive(): + return + time.sleep(0.001) + if self.trackingcheckbox.state != 'down' or not cameraActive(): + return + wait_end = time.perf_counter() - # Get the latest image - tracking_frame_start_time = time.perf_counter() + tracking_frame_start_time = wait_end if dualColorMode: image = self.imageacquisitionmanager.dualColorMainSideImage else: image = self.imageacquisitionmanager.image - retrieveTimestamp = self.imageacquisitionmanager.imageRetrieveTimeStamp - - # If prev frame is empty then use the same as current if prevImage is None: prevImage = image + _t_fetch = time.perf_counter() + # Extract worm position if mode=='Diff': ystep, xstep = macro.extractWormsDiff(prevImage, image, capture_radius, binning, area, threshold, dark_bg) - + elif mode=='Min/Max': ystep, xstep = macro.extractWorms(image, capture_radius = capture_radius, bin_factor=binning, dark_bg = dark_bg, display = False) @@ -3847,99 +4503,107 @@ def tracking(self, minstep: int, units: str, capture_radius: int, binning: int, except ValueError as e: ystep, xstep = 0, 0 - + _t_detect = time.perf_counter() + # Record cms for tracking overlay self.cmsOffset_x = xstep self.cmsOffset_y = -ystep - + _t_store = time.perf_counter() + # Compute relative distancec in each axis # Invert Y because the coordinate is in image space which is top left, while the transformation matrix is in btm left ystep, xstep = macro.getStageDistances(np.array([-ystep, xstep]), app.imageToStageMat) ystep *= scale xstep *= scale + _t_convert = time.perf_counter() - # Getting stage coord is slow so we will interpolate from movements + # getting stage coord is slow so we will interpolate from movements + if self.trackingcheckbox.state != 'down' \ + or getattr(app, '_hardware_teardown', False): + return + movedDistances = [] if abs(xstep) > minstep: - stage.move_x(xstep, unit=units, wait_until_idle =False) - app.coords[0] += xstep/1000. + if not stage.move_x(xstep, unit=units, wait_until_idle =False): + print('Tracking stopped because the X move was refused or failed') + stage.emergency_stop() + return + app.coords[0] += xstep * unitToMm + movedDistances.append(abs(xstep) * unitToMm) prevImage = image - + if abs(ystep) > minstep: - stage.move_y(ystep, unit=units, wait_until_idle = False) - app.coords[1] += ystep/1000. + if not stage.move_y(ystep, unit=units, wait_until_idle = False): + print('Tracking stopped because the Y move was refused or failed') + stage.emergency_stop() + return + app.coords[1] += ystep * unitToMm + movedDistances.append(abs(ystep) * unitToMm) prevImage = image - - # Record position history - posHist.append((app.coords[0], app.coords[1], app.coords[2])) - - tracking_frame_end_time = time.perf_counter() - - # Wait for stage movement to finish to not get motion blur. - # This could be done by checking with stage.is_busy(). - # However, that function call is very costly (~3 secs) - # and is not good for loop checking. - # So we are going to just estimate it here. - - # Delay from receing the image in recording and tracking it - delay_receive_image_and_tracking_time = tracking_frame_start_time - retrieveTimestamp - - # Time take to compute tracking - computation_time = tracking_frame_end_time - tracking_frame_start_time - - # Communication delay from host to stage is 20 ms - communication_delay = 20e-3 - - # Travel time - # Because x and y axis travel independently, the speed that we have to wait - # is the maximum between the two. - max_travel_dist = max(abs(xstep), abs(ystep)) # in micro meter : 1e-6 - stage_travel_time = stage.estimateTravelTime(max_travel_dist * 1e-3) - - # Sums up all the waiting time ingredient - tracking_process_time = delay_receive_image_and_tracking_time + computation_time + communication_delay + stage_travel_time - - # Compute the waiting time to reach the next receive image - fractional_part, integer_part = math.modf(tracking_process_time / camera_spf ) - time_to_next_receive_image = (1.0 - fractional_part) * camera_spf - - # Sums up the total time we need to wait, which are: - # communication delay - # + stage travelling time - # + time to receiving the last blurry image - total_waiting_time = communication_delay + stage_travel_time + time_to_next_receive_image - - estimated_next_timestamp = tracking_frame_end_time + total_waiting_time - - # Wait - time.sleep(total_waiting_time) - - # When the camera is not grabbing or is None and exit the loop, make sure to change the state button back to normal - self.trackingcheckbox.state = 'normal' - self.cmsOffset_x = None - self.cmsOffset_y = None - self.trackingMask = None + posHist.append((app.coords[0], app.coords[1], app.coords[2])) + trailLimit = max(2, app.config.getint('DaqControl', 'traillimit')) + if len(posHist) > trailLimit: + del posHist[:-trailLimit] + _t_move = time.perf_counter() + + max_travel_dist = max(movedDistances, default=0.0) + settle = SETTLE_FLOOR + stage.estimateTravelTime(max_travel_dist) + ready_time = time.perf_counter() + settle + + settle_wait = max(0.0, min(wait_ready, wait_end) - wait_begin) + frame_wait = (wait_end - wait_begin) - settle_wait + + bench_n += 1 + bench_fetch += _t_fetch - tracking_frame_start_time + bench_detect += _t_detect - _t_fetch + bench_store += _t_store - _t_detect + bench_convert += _t_convert - _t_store + bench_move += _t_move - _t_convert + bench_settle += settle_wait + bench_frame += frame_wait + if bench_n >= bench_window: + elapsed = time.perf_counter() - bench_start + per = lambda s: s / bench_n * 1000.0 + print( + f'track: fetch {per(bench_fetch):.2f} | detect {per(bench_detect):.1f} | ' + f'store {per(bench_store):.2f} | convert {per(bench_convert):.2f} | ' + f'move {per(bench_move):.1f} | settle {per(bench_settle):.1f} | ' + f'frame {per(bench_frame):.1f}ms | {bench_n / elapsed:.1f} fps' + ) + bench_n = 0 + bench_fetch = bench_detect = bench_store = bench_convert = bench_move = bench_settle = bench_frame = 0.0 + bench_start = time.perf_counter() def stopTracking(self): """Stop the tracking mode. Unschedule events. Reset camera parameters back. And then update the overlay. """ + self.track_done.set() app: GlowTrackerApp = App.get_running_app() camera = app.camera - if camera is None: - return - + if getattr(self, '_track_timeout', None) is not None: + self._track_timeout.cancel() + self._track_timeout = None + self.isTracking = False self.cropX = 0 self.cropY = 0 + if app.stage is not None: + app.stage.set_motion( + float(app.config.get('Stage', 'precise_speed')), + float(app.config.get('Stage', 'precise_acceleration')), + app.config.get('Stage', 'speed_unit'), + app.config.get('Stage', 'acceleration_unit')) + if self.coord_updateevent is not None: Clock.unschedule(self.coord_updateevent) self.coord_updateevent = None dualColorMode = app.config.getboolean('DualColor', 'dualcolormode') # If in single color mode - if not dualColorMode: + if not dualColorMode and camera is not None \ + and not getattr(app, '_hardware_teardown', False): # Reset the camera params back: Width, Height, OffsetX, OffsetY, center flag cameraConfig: dict = app.root.ids.leftcolumn.cameraConfig @@ -3974,7 +4638,7 @@ def stopTracking(self): # Update overlay app.root.ids.middlecolumn.ids.imageoverlay.updateOverlay() - + def computeTrackingCMS(self) -> Tuple[float, float, np.ndarray]: """Comput tracking mask and center off mass offsets that would be used for tracking, but just for analytic in this case @@ -4008,7 +4672,7 @@ def computeTrackingCMS(self) -> Tuple[float, float, np.ndarray]: except ValueError as e: offsetY, offsetX = 0, 0 trackingMask = np.zeros(image.shape, image.dtype) - + finally: # Flip Y from the top-right corner to btm-left corner return offsetX, -offsetY, trackingMask @@ -4036,10 +4700,10 @@ def __init__(self, **kwargs): else: self.state = 'normal' self.text = self.normalText - + def on_state(self, button: ToggleButton, state: 'str'): - + # Update config and setting app = App.get_running_app() configValue = '0' @@ -4051,7 +4715,7 @@ def on_state(self, button: ToggleButton, state: 'str'): else: self.text = self.downText configValue = '1' - + app.config.set('Tracking', 'showtrackingoverlay', configValue) app.config.write() @@ -4083,15 +4747,15 @@ def __init__(self, **kwargs): else: self.state = 'normal' self.text = self.normalText - + def on_state(self, button: ToggleButton, state: 'str'): - + # Update config and setting # Pass on start up if self.app.root is None: return - + liveanalysislabel: LiveAnalysisLabel = self.app.root.ids.middlecolumn.ids.liveanalysislabel if state == 'normal': @@ -4111,7 +4775,7 @@ def on_state(self, button: ToggleButton, state: 'str'): liveAnalysisData.skewness = 0 liveAnalysisData.percentile_5 = 0 liveAnalysisData.percentile_95 = 0 - + else: # Switch on @@ -4119,14 +4783,14 @@ def on_state(self, button: ToggleButton, state: 'str'): self.app.config.set('LiveAnalysis', 'showliveanalysis', 1) liveanalysislabel.disabled = False liveanalysislabel.opacity = 1 - + self.app.config.write() - + class DualColorViewModeQuickButtonLayout(BoxLayout): - + dualcolorviewmodequickbutton = ObjectProperty(None) - + def __init__(self, **kwargs): super().__init__(**kwargs) @@ -4141,7 +4805,7 @@ def __init__(self, **kwargs): else: self.hideButton() - + def hideButton(self): if self.dualcolorviewmodequickbutton in self.children: self.remove_widget(self.dualcolorviewmodequickbutton) @@ -4151,7 +4815,7 @@ def showButton(self): if not self.dualcolorviewmodequickbutton in self.children: self.add_widget(self.dualcolorviewmodequickbutton) - + class DualColorViewModeQuickButton(ToggleButton): normalText = 'Dual Color: [b]Splitted[/b]' @@ -4174,10 +4838,10 @@ def __init__(self, **kwargs): elif viewmode == 'Merged': self.state = 'down' self.text = self.downText - + def on_state(self, button: ToggleButton, state: 'str'): - + # Update config and setting app = App.get_running_app() configValue = str() @@ -4189,7 +4853,7 @@ def on_state(self, button: ToggleButton, state: 'str'): else: configValue = 'Merged' self.text = self.downText - + app.config.set('DualColor', 'viewmode', configValue) app.config.write() @@ -4207,6 +4871,10 @@ class Connections(BoxLayout): def __init__(self, **kwargs): super(Connections, self).__init__(**kwargs) + self._stageSetupCancel = Event() + self._stageSetupThread = None + self._suppressCameraState = False + self._suppressStageState = False Clock.schedule_once(self._do_setup) @@ -4219,6 +4887,8 @@ def _do_setup(self, *l): def connectCamera(self): + if self._suppressCameraState: + return print('Connecting Camera') # connect camera app = App.get_running_app() @@ -4233,67 +4903,153 @@ def connectCamera(self): def disconnectCamera(self): - camera = App.get_running_app().camera + if self._suppressCameraState: + return + app = App.get_running_app() + camera = app.camera if camera is not None: print('Disconnecting camera') - camera.Close() + app._hardware_teardown = True + activeWorkers = app.stop_active_workers(timeout=10.0) + if activeWorkers: + print(f'Camera disconnect cancelled; workers still active: {activeWorkers}') + app._hardware_teardown = False + self._suppressCameraState = True + try: + self.cam_connection.state = 'down' + finally: + self._suppressCameraState = False + return + try: + camera.Close() + finally: + if app.camera is camera: + app.camera = None + app._hardware_teardown = False def connectStage(self): + if self._suppressStageState: + return + if self._stageSetupThread is not None and self._stageSetupThread.is_alive(): + self._suppressStageState = True + try: + self.stage_connection.state = 'normal' + finally: + self._suppressStageState = False + return print('Connecting Stage') app = App.get_running_app() port = app.config.get('Stage', 'port') - maxspeed = float( app.config.get('Stage', 'maxspeed') ) - maxspeed_unit = app.config.get('Stage', 'maxspeed_unit') - accel = float( app.config.get('Stage', 'acceleration') ) + maxspeed = float( app.config.get('Stage', 'precise_speed') ) + maxspeed_unit = app.config.get('Stage', 'speed_unit') + accel = float( app.config.get('Stage', 'precise_acceleration') ) accel_unit = app.config.get('Stage', 'acceleration_unit') stage = Stage(port, maxspeed, maxspeed_unit, accel, accel_unit) - + if stage.connection is None: self.stage_connection.state = 'normal' App.get_running_app().stage = None else: app.stage: Stage = stage # type: ignore - + self._stageSetupCancel = Event() + app.root.ids.leftcolumn.ids.xcontrols.disable_all() + app.root.ids.leftcolumn.ids.ycontrols.disable_all() + app.root.ids.leftcolumn.ids.zcontrols.disable_all() + homing = app.config.getboolean('Stage', 'homing') move_start = app.config.getboolean('Stage', 'move_start') startloc = [float(x) for x in app.config.get('Stage', 'start_loc').split(',')] limits = [float(x) for x in app.config.get('Stage', 'stage_limits').split(',')] - + def connect_async(): + success = False + position = None + try: + success = stage.on_connect( + homing, move_start, startloc, limits, + cancel_event=self._stageSetupCancel + ) + if success and not self._stageSetupCancel.is_set(): + position = stage.get_position(isAsync=False) + success = position is not None and stage.start_position_poller() + except Exception as e: + print(f'Stage setup failed: {e}') + + def finishSetup(dt): + if self._stageSetupCancel.is_set() or app.stage is not stage: + return + if not success: + stage.disconnect() + if app.stage is stage: + app.stage = None + self._suppressStageState = True + try: + self.stage_connection.state = 'normal' + finally: + self._suppressStageState = False + return + app.coords = position + if getattr(app, 'coord_updateevent', None) is not None: + app.coord_updateevent.cancel() + app.coord_updateevent = Clock.schedule_interval(app.update_coordinates, 0.2) + app.root.ids.leftcolumn.ids.xcontrols.enable_all() + app.root.ids.leftcolumn.ids.ycontrols.enable_all() + app.root.ids.leftcolumn.ids.zcontrols.enable_all() + app.root.ids.leftcolumn.ids.gotocontrols.allow_moves() - # home stage - do this in a thread, it is slow, ~2 sec - app.stage.on_connect(homing, move_start, startloc, limits) - - # Call update_coordinates once. - # We have to specify not to run 'update_coordinates' in async mode because it's going to - # be run inside a thread. - app.update_coordinates(isAsync= False) - - - thread_connect_async = Thread(target= connect_async) - thread_connect_async.daemon = True - thread_connect_async.start() - - app.root.ids.leftcolumn.ids.xcontrols.enable_all() - app.root.ids.leftcolumn.ids.ycontrols.enable_all() - app.root.ids.leftcolumn.ids.zcontrols.enable_all() - - - def disconnectStage(self): + Clock.schedule_once(finishSetup) + + self._stageSetupThread = Thread(target=connect_async, daemon=True) + self._stageSetupThread.start() + + + def disconnectStage(self, wait=True, timeout=10.0): + if self._suppressStageState: + return True print('Disconnecting Stage') app = App.get_running_app() + self._stageSetupCancel.set() + app.root.ids.leftcolumn.ids.xcontrols.disable_all() + app.root.ids.leftcolumn.ids.ycontrols.disable_all() + app.root.ids.leftcolumn.ids.zcontrols.disable_all() if app.stage is None: - self.stage_connection.state = 'normal' + return True else: + goToControls = app.root.ids.leftcolumn.ids.gotocontrols + goToControls.request_stop(block_new=True) + if wait and not goToControls.wait(timeout): + print('Stage disconnect deferred because Go To movement is still active') + goToControls.allow_moves() + self._suppressStageState = True + try: + self.stage_connection.state = 'down' + finally: + self._suppressStageState = False + return False + + if getattr(app, 'coord_updateevent', None) is not None: + app.coord_updateevent.cancel() + app.coord_updateevent = None + + app.stage.emergency_stop() + setupThread = self._stageSetupThread + if wait and setupThread is not None and setupThread.is_alive(): + setupThread.join(timeout) + if setupThread is not None and setupThread.is_alive(): + print('Stage disconnect deferred because setup is still active') + self._suppressStageState = True + try: + self.stage_connection.state = 'down' + finally: + self._suppressStageState = False + return False + app.stage.disconnect() app.stage = None - # disable buttons - app.root.ids.leftcolumn.ids.xcontrols.disable_all() - app.root.ids.leftcolumn.ids.ycontrols.disable_all() - app.root.ids.leftcolumn.ids.zcontrols.disable_all() - + return True + class DAQConnectionButton(ToggleButton): @@ -4301,7 +5057,7 @@ def on_state(self, widget: Widget, state: str): if state == 'down': self.connectDaq() - + else: self.disconnectDaq() @@ -4315,12 +5071,12 @@ def connectDaq(self): # Connect to device app.daqControl = DAQControl.createAndConnectDaq() - + # If no device if not app.daqControl.isConnected(): self.state = 'normal' return - + app.daqControl.daqMode = DAQMode[app.config.get("DaqControl", "mode")] # Load recent script @@ -4370,10 +5126,12 @@ def connectDaq(self): app.daqControl.daqStageProgram.update(mode= stageprogrammode, quadVertex= quadVertex, exterior= exterior, exteriorConstant= exteriorConstant, gaussianParams= gaussianParams) # Update DAQReversalDetection variables - app.daqControl.reversalDetector.animalLength_mm = app.config.getfloat('DaqControl', 'animallength') + app.daqControl.reversalDetector.animalLength_mm = app.config.getfloat('DaqControl', 'animallength') * 1e-3 app.daqControl.reversalDetector.trailLimit = app.config.getint('DaqControl', 'traillimit') app.daqControl.reversalDetector.velocityHistoryPercentage = app.config.getfloat('DaqControl', 'velocityhistorypercentage') - app.daqControl.reversalDetector.reversalthresholdradian = app.config.getfloat('DaqControl', 'reversalthresholdradian') + app.daqControl.reversalDetector.reversalThresholdRadian = app.config.getfloat('DaqControl', 'reversalthresholdradian') + app.daqControl.reversalDetector.reversalVoltage = app.config.getfloat('DaqControl', 'reversalvoltage') + app.daqControl.reversalDetector.forwardVoltage = app.config.getfloat('DaqControl', 'forwardvoltage') return @@ -4402,7 +5160,7 @@ class SettingsCustomNumeric(SettingNumeric): def _validate(self, instance): # Close the popup self._dismiss() - + value_float = float(0) # Check if input is a number @@ -4412,7 +5170,7 @@ def _validate(self, instance): except ValueError: # The value is not a number return - + # Check if should display text in integer style or floating point style try: value_int = int(self.textinput.text) @@ -4423,28 +5181,30 @@ def _validate(self, instance): self.value = str(value_float) return - + # load the layout class GlowTrackerApp(App): # stage configuration properties - these will update when changed in config menu - vhigh = ConfigParserProperty(20, - 'Stage', 'vhigh', 'app', val_type=float) - vlow = ConfigParserProperty(20, - 'Stage', 'vlow', 'app', val_type=float) + vhigh = ConfigParserProperty(30, + 'Stage', 'input_fast_speed', 'app', val_type=float) + vlow = ConfigParserProperty(1, + 'Stage', 'input_slow_speed', 'app', val_type=float) unit = ConfigParserProperty('mm/s', 'Stage', 'speed_unit', 'app', val_type=str) # stage coordinates and current image texture = ObjectProperty(None, force_dispatch=True, rebind=True) image = ObjectProperty(None, force_dispatch=True, rebind=True) coords = ListProperty([0, 0, 0]) + plateCenter = ObjectProperty(None) + plateRadius = ObjectProperty(None) frameBuffer = list() def __init__(self, **kwargs): super(GlowTrackerApp, self).__init__(**kwargs) # Declare config file path - self.configFile = self.getDefaultUserConfigFilePath() + self.configFile = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'glowtracker_local.ini') # define settings menu style self.settings_cls = SettingsWithSidebar # bind key presses to stage motion - right now also happens in settings! @@ -4454,11 +5214,12 @@ def __init__(self, **kwargs): self.stage: Stage = Stage(None) self.daqControl: DAQControl = DAQControl() self.updateFpsEvent = None - + self._hardware_teardown = False + def getDefaultUserConfigFilePath(self) -> str: """Get the default glowtrackeer app config file path from the user local machine. - The default location depends on the username and the OS. Create a new one by + The default location depends on the username and the OS. Create a new one by copying from the default template if it doesn't exist. Returns: @@ -4469,7 +5230,7 @@ def getDefaultUserConfigFilePath(self) -> str: # Join the directory path and file name for a complete file path. configFullPath = os.path.join(configDir, configFileName) - + # If the config file doesn't exist, create a new one. if not os.path.exists(configFullPath): @@ -4479,7 +5240,7 @@ def getDefaultUserConfigFilePath(self) -> str: # Copy the template file to the target directory. shutil.copy(configFileName, configDir) - + except Exception as e: print(e) @@ -4497,20 +5258,25 @@ def build_config(self, config: ConfigParser): Thus, we will skip the loading here and pass the responsibility to self.build() to load instead. """ - # Set the config defaults + # Set the config defaults config.setdefaults('Stage', { - 'speed_unit': 'mm/s', - 'vhigh': '30.0', - 'vlow': '1.0', 'port': '/dev/ttyUSB0', 'move_start': 'false', 'homing': 'false', - 'stage_limits': '160,160,180', + 'stage_limits': '150,150,152', 'start_loc': '0,0,0', - 'maxspeed': '20', - 'maxspeed_unit': 'mm/s', - 'acceleration': '60', + 'speed_unit': 'mm/s', 'acceleration_unit': 'mm/s^2', + 'input_fast_speed': '20.0', + 'input_fast_acceleration': '100', + 'input_slow_speed': '0.5', + 'input_slow_acceleration': '100', + 'precise_speed': '15', + 'precise_acceleration': '200', + 'scan_speed': '26', + 'scan_acceleration': '500', + 'track_speed': '20', + 'track_acceleration': '200', 'move_image_space_mode': 'false' }) @@ -4533,6 +5299,8 @@ def build_config(self, config: ConfigParser): 'bestfocusvalue': 2000, 'focusfps': '15', 'isshowgraph': 'false', + 'coarsestep': '0.02', + 'buffer_n': '5', }) config.setdefaults('Calibration', { @@ -4564,7 +5332,7 @@ def build_config(self, config: ConfigParser): 'mode': 'CMS', 'area': '400', 'min_brightness': '0', - 'max_brightness': '255' + 'max_brightness': '65535' }) config.setdefaults('LiveAnalysis', { @@ -4622,7 +5390,7 @@ def build_config(self, config: ConfigParser): 'forwardvoltage' : '0', 'showguideline': 'true', }) - + config.setdefaults('Developer', { 'showfps': 'false' }) @@ -4630,6 +5398,18 @@ def build_config(self, config: ConfigParser): def build(self): + print('>>> configFile:', self.configFile) + print('>>> exists:', os.path.exists(self.configFile)) + try: + with open(self.configFile) as _f: + print('>>> sections in file:') + for _line in _f: + if _line.strip().startswith('['): + print(' ', _line.strip()) + except Exception as _e: + print('>>> could not read configFile:', _e) + print('>>> in-memory sections before update:', self.config.sections()) + # Load user's config self.config.update_config(self.configFile, overwrite= True) @@ -4648,7 +5428,7 @@ def build(self): # manage xbox input Window.bind(on_joy_axis= self.on_controller_input) - self.stopevent = Clock.create_trigger(lambda dt: self.stage.stop(), 0.1) + self.stopevent = Clock.create_trigger(lambda dt: self.request_stage_stop(), 0.1) # Load and gen camera&stage transformation matricies rotation = self.config.getfloat('Camera', 'rotation') @@ -4661,7 +5441,7 @@ def build(self): self.moveImageSpaceMode = self.config.getboolean('Stage', 'move_image_space_mode') return layout - + def on_start(self): '''Event handler for the `on_start` event which is fired after @@ -4672,8 +5452,8 @@ def on_start(self): showfps = self.config.getboolean('Developer', 'showfps') if showfps: self.startShowFpsEvent() - - + + # use custom settings for our GUI def build_settings(self, settings: SettingsWithSidebar): """build the settings window""" @@ -4705,17 +5485,17 @@ def create_settings(self): ''' self.config.read(self.configFile) - + settings = self.settings_cls() self.build_settings(settings) - + self.unbind_keys() settings.bind( on_close= self.close_settings, on_config_change= self.on_config_change ) - + return settings @@ -4744,35 +5524,61 @@ def close_settings(self, *args) -> bool: # Bind back the keys self.bind_keys() - + # Enabled back the interaction with preview image widget self.root.ids.middlecolumn.ids.scalableimage.disabled = False return True - + def stage_stop(self): """stop all axes and report coordinates.""" - self.stage.stop() - self.coords = self.stage.get_position() + self.request_stage_stop() + position = self.stage.get_cached_position() + if position is not None: + self.coords = position self.stopevent = None print('stopped') + def request_stage_stop(self, stopAxis: AxisEnum = AxisEnum.ALL) -> bool: + if self.stage is None: + return False + return self.stage.request_stop(stopAxis) + + + def request_jog(self, velocity: tuple, fast: bool) -> bool: + if self.stage is None: + return False + key = 'input_fast_acceleration' if fast else 'input_slow_acceleration' + return self.stage.request_start_move( + velocity, + self.unit, + self.config.getfloat('Stage', key), + self.config.get('Stage', 'acceleration_unit'), + ) + + + def jog(self, direction: tuple, fast: bool = True) -> None: + if self.stage is None: + return + speed = self.vhigh if fast else self.vlow + self.request_jog(tuple(d * speed for d in direction), fast) + + def on_controller_input(self, win, stickid, axisid, value) -> None: """Handle controller input from Kivi App""" print(win, stickid, axisid, value) - if self.stage is None or self.stage.is_busy(): + if self.stage is None: return if self.stopevent is not None: Clock.unschedule(self.stopevent) - - #scale velocity - v = self.vhigh*value/32767 - if v < self.vlow*0.01: + + v = controller_velocity(value, self.vhigh, self.vlow) + if v is None: self.stage_stop() else: direction = { @@ -4782,24 +5588,22 @@ def on_controller_input(self, win, stickid, axisid, value) -> None: } if axisid in [0,1,4]: self.stopevent = Clock.schedule_once(lambda dt: self.stage_stop(), 0.1) - self.stage.start_move(direction[axisid], self.unit) + self.request_jog(direction[axisid], True) + - def _keydown(self, instance, key, scancode, codepoint, modifier) -> None: """Manage keyboard input for stage and focus""" - + print(f"Keydown") if self.stage is None: return - + # print(key, scancode, codepoint, modifier) - if 'shift' in modifier: - v = self.vlow - else: - v = self.vhigh - + fast = 'shift' not in modifier + v = self.vhigh if fast else self.vlow + direction = { 273: (0,v,0), # up arrow 274: (0,-v,0), # down arrow @@ -4808,10 +5612,10 @@ def _keydown(self, instance, key, scancode, codepoint, modifier) -> None: 280: (0,0,-v), # page up 281: (0,0,v) # page down } - + if key not in direction.keys(): return - + velocity = direction[key] # Stage movement mode @@ -4824,53 +5628,44 @@ def _keydown(self, instance, key, scancode, codepoint, modifier) -> None: # Convert back to a 3D tuple velocity = ( float(translation_vec_stage_space[1]), float(translation_vec_stage_space[0]), move_img_space[2] ) - - # Move - self.stage.start_move(velocity, self.unit) - # Update stage position app.coords - # Extrapolated position by speed - # Convert speed to cm/s - velocity_cm_per_sec = np.zeros(shape= (3), dtype= np.float32) - velocity_cm_per_sec[0] = UnitTable.convert_units(value= velocity[0], from_unit= self.unit, to_unit= Units.VELOCITY_CENTIMETRES_PER_SECOND) - velocity_cm_per_sec[1] = UnitTable.convert_units(value= velocity[1], from_unit= self.unit, to_unit= Units.VELOCITY_CENTIMETRES_PER_SECOND) - velocity_cm_per_sec[2] = UnitTable.convert_units(value= velocity[2], from_unit= self.unit, to_unit= Units.VELOCITY_CENTIMETRES_PER_SECOND) - - # Very crude estimation. Need to consult Monika - spf = 1 / 30.0 - extrapolatedPos = np.array(self.coords) + spf * velocity_cm_per_sec * 10 - self.coords = extrapolatedPos.tolist() + # Move + if not self.request_jog(velocity, fast): + return def _keyup(self, instance, key, scancode) -> None: """Handle keyup callbacks. This is usually only for stopping axis movement""" if self.stage is None: return - + + movement_keys = [273, 274, 275, 276, 280, 281] + if key not in movement_keys: + return + # Stopping axis depending on the movement mode if self.moveImageSpaceMode: - + # TODO: Improve this feature so that we can move in image space simultaneously # in both X,Y axis. Will require additive velocity movement handling. - if key in [273, 274, 275, 276, 280, 281]: - self.stage.stop(stopAxis= AxisEnum.ALL) - self.coords = self.stage.get_position() - + self.request_stage_stop(AxisEnum.ALL) + else: - + # Movement key up # Call the coresponding axis to stop and update te stage position if key == 275 or key == 276: - self.stage.stop(stopAxis= AxisEnum.X) - self.coords = self.stage.get_position() + self.request_stage_stop(AxisEnum.X) elif key == 273 or key == 274: - self.stage.stop(stopAxis= AxisEnum.Y) - self.coords = self.stage.get_position() + self.request_stage_stop(AxisEnum.Y) elif key == 280 or key == 281: - self.stage.stop(stopAxis= AxisEnum.Z) - self.coords = self.stage.get_position() + self.request_stage_stop(AxisEnum.Z) + + position = self.stage.get_cached_position() + if position is not None: + self.coords = position stagePosString = f'Stage position: {self.coords[0]:.3f}, {self.coords[1]:.3f}' if len(self.coords) > 2: @@ -4900,7 +5695,7 @@ def on_config_change(self, settingsWidget: SettingsWithSidebar, config: ConfigPa if config is not self.config: return - + updateSettingsWidgetFlag = False updateOverlayFlag = False @@ -4930,29 +5725,27 @@ def on_config_change(self, settingsWidget: SettingsWithSidebar, config: ConfigPa limits = ','.join([str(round(x,2)) for x in limits]) self.config.set('Stage', 'stage_limits', limits) updateSettingsWidgetFlag = True - - elif key == 'maxspeed': - # Set the stage maxspeed + + elif key == 'precise_speed': maxspeed = float(value) - maxspeed_unit = self.config.get('Stage', 'maxspeed_unit') + maxspeed_unit = self.config.get('Stage', 'speed_unit') maxspeed = self.stage.set_maxspeed(maxspeed, maxspeed_unit) maxspeed = round(maxspeed, 2) # Get back the current value and set back to settings in case the input value is invalid - self.config.set('Stage', 'maxspeed', maxspeed) + self.config.set('Stage', 'precise_speed', maxspeed) self.config.write() updateSettingsWidgetFlag = True - - elif key == 'acceleration': - # Set the stage acceleration speed + + elif key == 'precise_acceleration': acceleration = float(value) acceleration_unit = self.config.get('Stage', 'acceleration_unit') acceleration = self.stage.set_accel(acceleration, acceleration_unit) acceleration = round(acceleration, 2) # Get back the current value and set back to settings in case the input value is invalid - self.config.set('Stage', 'acceleration', acceleration) + self.config.set('Stage', 'precise_acceleration', acceleration) self.config.write() updateSettingsWidgetFlag = True - + elif key == 'move_image_space_mode': # value is a str of int or float, i.e. '0', '1' so we have to parse it to boolean self.moveImageSpaceMode = bool(int(value)) @@ -4970,7 +5763,7 @@ def on_config_change(self, settingsWidget: SettingsWithSidebar, config: ConfigPa self.imageToStageMat, self.imageToStageRotMat = macro.CameraAndStageCalibrator.genImageToStageMatrix(rotation= rotation, imageNormalDir= imageNormalDir, pixelSize= pixelsize) elif section == 'DualColor': - + if key == 'dualcolormode': updateOverlayFlag = True @@ -4979,20 +5772,20 @@ def on_config_change(self, settingsWidget: SettingsWithSidebar, config: ConfigPa dualColorViewModeQuickButtonLayout: DualColorViewModeQuickButtonLayout = self.root.ids.middlecolumn.ids.runtimecontrols.ids.dualcolorviewmodequickbuttonlayout if dualcolormode: dualColorViewModeQuickButtonLayout.showButton() - + else: dualColorViewModeQuickButtonLayout.hideButton() - + elif key == 'mainside': updateOverlayFlag = True - + elif key == 'viewmode': updateOverlayFlag = True - + # Also update the DualColorViewMode Quick Button button = self.root.ids.middlecolumn.ids.runtimecontrols.ids.dualcolorviewmodequickbuttonlayout.dualcolorviewmodequickbutton button.state = 'down' if value == 'Merged' else 'normal' - + elif section == 'Tracking': if key == 'showtrackingoverlay': @@ -5002,12 +5795,12 @@ def on_config_change(self, settingsWidget: SettingsWithSidebar, config: ConfigPa showtrackingoverlay = bool(int(value)) self.root.ids.middlecolumn.ids.runtimecontrols.ids.trackingoverlayquickbutton.state = \ 'down' if showtrackingoverlay else 'normal' - + elif key == 'capture_radius': updateOverlayFlag = True elif key == 'min_brightness': - + min_brightness = int(value) max_brightness = self.config.getint('Tracking', 'max_brightness') @@ -5017,24 +5810,23 @@ def on_config_change(self, settingsWidget: SettingsWithSidebar, config: ConfigPa self.config.set('Tracking', 'min_brightness', min_brightness) self.config.write() updateSettingsWidgetFlag = True - + elif key == 'max_brightness': - + max_brightness = int(value) min_brightness = self.config.getint('Tracking', 'min_brightness') - # Bound the value between [min_brightness, 255] - max_brightness = max(min_brightness, min(max_brightness, 255)) + max_brightness = max(min_brightness, max_brightness) self.config.set('Tracking', 'max_brightness', max_brightness) self.config.write() updateSettingsWidgetFlag = True - + elif section == 'Experiment': if key == 'exppath': self.root.ids.leftcolumn.ids.saveloc.text = value - + elif section == 'LiveAnalysis': if key == 'showliveanalysis': @@ -5044,7 +5836,7 @@ def on_config_change(self, settingsWidget: SettingsWithSidebar, config: ConfigPa showliveanalysis = bool(int(value)) self.root.ids.middlecolumn.ids.runtimecontrols.ids.liveanalysisquickbutton.state = \ 'down' if showliveanalysis else 'normal' - + elif section == 'DaqControl': @@ -5066,22 +5858,22 @@ def on_config_change(self, settingsWidget: SettingsWithSidebar, config: ConfigPa updateSettingsWidgetFlag = True - + # Update setting widget value to reflect the setting file if updateSettingsWidgetFlag: panels = settingsWidget.interface.content.panels - + # For every setting items in the panel - for panel in panels.values(): + for panel in panels.values(): for child in panel.children: - - if isinstance(child, SettingItem): + + if isinstance(child, SettingItem): child.value = panel.get_value(child.section, child.key) - + # Update overlay if updateOverlayFlag: self.root.ids.middlecolumn.ids.imageoverlay.updateOverlay() - + def startShowFpsEvent(self): # Bring up the FPS label @@ -5113,21 +5905,28 @@ def stopShowFpsEvent(self): def on_image(self, *args) -> None: """On image change callback. Update image texture and GUI overlay """ - imageHeight, imageWidth = self.image.shape[0], self.image.shape[1] - imageColorFormat = 'rgb' if self.image.ndim == 3 else 'luminance' - # Force unsign byte format - imageDataFormat = 'ubyte' + textureImage, imageDataFormat = prepare_texture_data(self.image) + imageHeight, imageWidth = textureImage.shape[0], textureImage.shape[1] + if textureImage.ndim == 2: + imageColorFormat = 'luminance' + elif textureImage.ndim == 3 and textureImage.shape[2] == 3: + imageColorFormat = 'rgb' + elif textureImage.ndim == 3 and textureImage.shape[2] == 4: + imageColorFormat = 'rgba' + else: + raise ValueError(f'unsupported image shape: {textureImage.shape}') # Check if need to recreate texture if self.texture is None \ or self.texture.width != imageWidth or self.texture.height != imageHeight \ or self.texture.colorfmt != imageColorFormat \ or self.texture.bufferfmt != imageDataFormat: - + # Recreate texture self.texture = Texture.create( size= (imageWidth, imageHeight), - colorfmt= imageColorFormat + colorfmt= imageColorFormat, + bufferfmt= imageDataFormat, ) # Kivy texture is in OpenGL corrindate which is btm-left origin so we need to flip texture coord once to match numpy's top-left @@ -5137,13 +5936,13 @@ def on_image(self, *args) -> None: self.root.ids.middlecolumn.ids.imageoverlay.updateOverlay() # Upload image data to texture - imageByteBuffer: bytes = self.image.tobytes() + imageByteBuffer: bytes = textureImage.tobytes() self.texture.blit_buffer(imageByteBuffer, colorfmt= imageColorFormat, bufferfmt= imageDataFormat) # Update tracking overlay if the option is enabled if self.config.getboolean('Tracking', 'showtrackingoverlay'): self.root.ids.middlecolumn.ids.imageoverlay.updateTrackingOverlay(doClear= False) - + # ask for confirmation of closing def on_request_close(self, *args, **kwargs): @@ -5158,42 +5957,220 @@ def dismiss_popup(self): self._popup.dismiss() + def stop_active_workers(self, timeout=10.0): + if self.root is None: + return [] + + deadline = time.monotonic() + timeout + activeWorkers = [] + runtimeControls = self.root.ids.middlecolumn.ids.runtimecontrols + acquisitionManager = runtimeControls.ids.imageacquisitionmanager + rightColumn = self.root.ids.rightcolumn + goToControls = self.root.ids.leftcolumn.ids.gotocontrols + scanPanel = getattr(rightColumn, '_scanPanel', None) + macroWidgets = list(getattr(rightColumn, '_macroWidgets', [])) + + try: + goToControls.request_stop() + except Exception as e: + print(f'Stopping Go To movement failed: {e}') + + if scanPanel is not None: + scanPanel.request_shutdown() + for macroWidget in macroWidgets: + macroWidget.macroScriptExecutor.stop() + + try: + if runtimeControls.trackingcheckbox.state == 'down': + runtimeControls.trackingcheckbox.state = 'normal' + elif runtimeControls.trackthread is not None \ + and runtimeControls.trackthread.is_alive(): + runtimeControls.stopTracking() + except Exception as e: + print(f'Stopping tracking failed: {e}') + + try: + if runtimeControls.livefocuscheckbox.state == 'down': + runtimeControls.livefocuscheckbox.state = 'normal' + elif runtimeControls.liveFocusThread is not None \ + and runtimeControls.liveFocusThread.is_alive(): + runtimeControls.stopLiveFocus() + except Exception as e: + print(f'Stopping live focus failed: {e}') + + for button in (acquisitionManager.recordbutton, acquisitionManager.liveviewbutton): + try: + thread = button.imageAcquisitionThread + if button.state == 'down': + button.state = 'normal' + if thread is not None and thread.is_alive(): + button.stopImageAcquisition() + except Exception as e: + print(f'Stopping image acquisition failed: {e}') + + motionThreads = ( + goToControls._moveThread, + runtimeControls.trackthread, + runtimeControls.liveFocusThread, + *(getattr(widget.macroScriptExecutor, '_executorThread', None) + for widget in macroWidgets), + ) + if self.stage is not None and any( + thread is not None and thread.is_alive() for thread in motionThreads): + self.stage.emergency_stop() + + threads = ( + ('Go To movement', goToControls._moveThread), + ('tracking', runtimeControls.trackthread), + ('live focus', runtimeControls.liveFocusThread), + ('recording acquisition', acquisitionManager.recordbutton.imageAcquisitionThread), + ('live acquisition', acquisitionManager.liveviewbutton.imageAcquisitionThread), + ) + for name, thread in threads: + if thread is None or thread is current_thread() or not thread.is_alive(): + continue + thread.join(max(0.0, deadline - time.monotonic())) + if thread.is_alive(): + activeWorkers.append(name) + + if scanPanel is not None: + if not scanPanel.wait(max(0.0, deadline - time.monotonic())): + activeWorkers.append('scan') + + for macroWidget in macroWidgets: + if not macroWidget.macroScriptExecutor.wait( + max(0.0, deadline - time.monotonic())): + if 'macro' not in activeWorkers: + activeWorkers.append('macro') + + return activeWorkers + + def graceful_exit(self): - # disconnect hardware - # stop remaining stage motion - if self.stage is not None: - print('Disconnecting Stage') - self.stage.stop() - self.stage.disconnect() - - if self.camera is not None: - print('Disconnecting Camera') - self.camera.Close() - - if self.daqControl.isConnected(): - print('Disconnecting DAQ') - self.daqControl.close() + self._hardware_teardown = True + activeWorkers = self.stop_active_workers(timeout=10.0) + + if activeWorkers: + print(f'Hardware connections left open because workers did not stop: {activeWorkers}') + if self.stage is not None: + self.stage.emergency_stop() + if self.daqControl.isConnected(): + self.daqControl.safe_off() + else: + connections = self.root.ids.rightcolumn.ids.connections + if self.stage is not None: + print('Disconnecting Stage') + try: + connections.disconnectStage(wait=True, timeout=10.0) + except Exception as e: + print(f'Disconnecting Stage failed: {e}') + + if self.daqControl.isConnected(): + print('Disconnecting DAQ') + try: + self.daqControl.close() + except Exception as e: + print(f'Disconnecting DAQ failed: {e}') + + if self.camera is not None: + print('Disconnecting Camera') + camera = self.camera + try: + camera.Close() + except Exception as e: + print(f'Disconnecting Camera failed: {e}') + finally: + if self.camera is camera: + self.camera = None # stop the app self.stop() - + # close the window self.root_window.close() - - + + def update_coordinates(self, dt= None, isAsync= True) -> None: """get the current stage position.""" if self.stage is not None: - pos = self.stage.get_position(isAsync= isAsync) + pos = self.stage.get_cached_position() if pos is not None: self.coords = pos + def get_fov_mm(self): + if self.camera is None: + return None + to_mm = 0.001 if self.config.get('Calibration', 'step_units') == 'um' else 1.0 + W, H = self.camera.Width(), self.camera.Height() + + imageToStageMat = getattr(self, 'imageToStageMat', None) + if imageToStageMat is not None: + corners = np.array([[-W/2, -H/2], [W/2, -H/2], [W/2, H/2], [-W/2, H/2]]) + stage = np.array([ + macro.getStageDistances(np.array([cy, cx]), imageToStageMat) + for cx, cy in corners + ]) + fov_x = (stage[:, 1].max() - stage[:, 1].min()) * to_mm + fov_y = (stage[:, 0].max() - stage[:, 0].min()) * to_mm + return (fov_x, fov_y) + + pixelsize = self.config.getfloat('Camera', 'pixelsize') + if pixelsize <= 0: + return None + return (pixelsize * W * to_mm, pixelsize * H * to_mm) + + def autofocus(self, follow_worm: bool = False, threshold: float = 150, min_pixels: int = 50) -> float | None: + """moved from autofocus macro to have a shared autofocus function that can be called from both macro and settings menu. + When follow_worm is True the Z sweep tracks the worm in XY (moves the camera to keep it centered) + and measures focus on a window around the worm; returns None if no worm is seen during the sweep. + """ + camera = self.camera + stage = self.stage + if camera is None or stage is None: + print('autofocus requires a stage and a camera') + return None + + depthoffield = self.config.getfloat('Camera', 'depthoffield') + depthoffieldsearchdistance = self.config.getfloat('Calibration', 'depthoffieldsearchdistance') + dualColorMode = self.config.getboolean('DualColor', 'dualcolormode') + dualColorModeMainSide = self.config.get('DualColor', 'mainside') + capturedRadius = self.config.getint('Tracking', 'capture_radius') + focusEstimationMethod = FocusEstimationMethod(self.config.get('Autofocus', 'focusestimationmethod')) + + depthOfFieldEstimator = macro.DepthOfFieldEstimator() + numSamples = math.floor(depthoffieldsearchdistance / depthoffield) + 1 + print(f'autofocus: Taking {numSamples} images for depth of field estimation with search distance {depthoffieldsearchdistance} and step size {depthoffield}') + stageUnits = self.config.get('Calibration', 'step_units') + depthOfFieldEstimator.takeCalibrationImages( + camera, stage, depthoffieldsearchdistance, numSamples, focusEstimationMethod, + dualColorMode, dualColorModeMainSide, capturedRadius, + followWorm= follow_worm, imageToStageMat= self.imageToStageMat, + stageUnits= stageUnits, threshold= threshold, minPixels= min_pixels) + + valid = depthOfFieldEstimator.dofDataFrame.dropna(subset= ['estimatedFocus']) + if valid.empty: + print('autofocus: no worm detected during the sweep') + return None + + bestFocusIndex = valid['estimatedFocus'].idxmax() + bestFocusPosition = valid.loc[bestFocusIndex, 'pos_z'] + stagePosition = stage.get_position() + stagePosition[2] = bestFocusPosition + stage.move_abs(stagePosition, unit= 'mm') + + bestFocusValue = valid.loc[bestFocusIndex, 'estimatedFocus'] + self.config.set('Autofocus', 'bestfocusvalue', bestFocusValue) + self.config.write() + + return bestFocusPosition + def reset(): + global Window # Cleaner for the events in memory if not EventLoop.event_listeners: - + Window = Window.core_select_lib('window', Window.window_impl, True) Cache.print_usage() for cat in Cache._categories: @@ -5201,11 +6178,14 @@ def reset(): def main(): + if USE_SHARED_MEMORY_SAVER: + mp.set_start_method('forkserver', force=True) + mp.set_forkserver_preload(['image_saver']) reset() Window.size = (1280, 800) Config.set('graphics', 'position', 'custom') - Config.set('graphics', 'top', '0') - Config.set('graphics', 'left', '0') + Config.set('graphics', 'top', '0') + Config.set('graphics', 'left', '0') # Last barrier for catching unhandled exception. try: @@ -5213,9 +6193,11 @@ def main(): App.run() # This runs the App in an endless loop until it closes. At this point it will execute the code below except Exception as e: + import traceback + traceback.print_exc() print(f'Kivy App error: {e}') return None if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/glowtracker/MacroScript.py b/glowtracker/MacroScript.py index 0894d30..1541f8f 100644 --- a/glowtracker/MacroScript.py +++ b/glowtracker/MacroScript.py @@ -8,7 +8,7 @@ import time -from threading import Thread +from threading import Thread, current_thread class MacroScriptExecutor: """Parser and executor for a custom glowtracker macro scripts. @@ -210,7 +210,7 @@ def executorThreadWrapper(commandList: List | ParseResults, terminationFlag: lis except ValueError as e: print(f'Macro Script error: {e}') - + finally: if finishedCallback is not None: finishedCallback() @@ -227,10 +227,25 @@ def executorThreadWrapper(commandList: List | ParseResults, terminationFlag: lis self._executorThread.start() - def stop(self) -> None: + def stop(self, timeout: float | None = None) -> bool: """Stop running the macro """ self._terminationFlag[0] = True + if timeout is not None: + return self.wait(timeout) + return True + + + def wait(self, timeout: float | None = None) -> bool: + thread = self._executorThread + if thread is None or thread is current_thread() or not thread.is_alive(): + return True + thread.join(timeout) + return not thread.is_alive() + + + def is_stopping(self) -> bool: + return self._terminationFlag[0] def _executeCommandList(self, commandList: List | ParseResults, terminationFlag: list[bool], scopeVariableDict: dict[int, float] | None = None) -> None: @@ -277,7 +292,12 @@ def _executeCommandList(self, commandList: List | ParseResults, terminationFlag: elif commandName == 'wait': waitTime = self._resolveExpression(scopeVariableDict, command[1]) - time.sleep(waitTime) + deadline = time.monotonic() + max(0.0, waitTime) + while not terminationFlag[0]: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(0.05, remaining)) elif commandName == 'loop': @@ -422,4 +442,3 @@ def _resolveExpression(self, scopeVariableDict: dict[int, float], expression: st else: raise ValueError(f"Expression {expression} is invalid.") - diff --git a/glowtracker/Microscope_macros.py b/glowtracker/Microscope_macros.py index 65e0510..75bb1de 100644 --- a/glowtracker/Microscope_macros.py +++ b/glowtracker/Microscope_macros.py @@ -2,9 +2,9 @@ from __future__ import annotations from enum import Enum -# +# # IO, Utils -# +# import os from multiprocessing.pool import ThreadPool from queue import Queue @@ -14,27 +14,28 @@ import pandas as pd import tifffile -# +# # Own classes -# +# import Basler_control as basler import Zaber_control as zaber from AutoFocus import FocusEstimationMethod, estimateFocus +from image_utils import effective_max_brightness, normalize_image -# +# # Math -# +# import math import numpy as np -import scipy.ndimage as ndi from scipy.optimize import curve_fit from scipy.stats import gennorm from scipy.special import gamma as gammafunc +from scipy.signal import savgol_filter import matplotlib as mpl import matplotlib.pylab as plt plt.set_loglevel('warning') from matplotlib.backends.backend_agg import FigureCanvasAgg -from skimage.filters import threshold_otsu, threshold_li, threshold_yen +from skimage.filters import threshold_yen from skimage.transform import downscale_local_mean from skimage.registration import phase_cross_correlation import itk @@ -46,7 +47,7 @@ # def extractWorms(img, area=0, bin_factor=4, li_init=10, display = True): # ''' # use otsu threshold to obtain mask of pharynx & label them -# input: image of shape (N,M) +# input: image of shape (N,M) # output: array of worm coordinates. # ''' # img = img[::bin_factor, ::bin_factor] @@ -61,7 +62,7 @@ # plt.imshow(img) # plt.subplot(212) # plt.imshow(labeled) - + # coords = [] # for region in regionprops(labeled): # if region.area >=area: @@ -85,7 +86,7 @@ # for (y,x) in coords: # distanceToCenter = (h//2 - y)**2 + (w//2-x)**2 # if distanceToCenter < current_distance: -# current_distance = distanceToCenter +# current_distance = distanceToCenter # yc, xc = y,x # # return offset from center for closest object # return yc-h//2, xc-w//2 @@ -100,12 +101,46 @@ def getStageDistances(deltaCoords, imageToStageMat): stageDistances = np.matmul(imageToStageMat, deltaCoords) return stageDistances + +def generate_scan_tiles(center, radius, fov_w, fov_h, overlap_w=0.0, overlap_h=0.0, edge_margin=0.0): + """scan a circle""" + cx, cy = center + step_x = max(fov_w * (1.0 - overlap_w), 1e-3) + step_y = max(fov_h * (1.0 - overlap_h), 1e-3) + keep_radius = max(radius - edge_margin, 0.0) + n_x = int(np.ceil(radius / step_x)) + n_y = int(np.ceil(radius / step_y)) + + tiles = [] + for row, iy in enumerate(range(-n_y, n_y + 1)): + y = cy + iy * step_y + xs = list(range(-n_x, n_x + 1)) + if row % 2 == 1: + xs.reverse() + for ix in xs: + x = cx + ix * step_x + if (x - cx) ** 2 + (y - cy) ** 2 <= keep_radius ** 2: + tiles.append((x, y)) + return tiles + + +def detect_worm(image, threshold, min_pixels=20): + bright = image > threshold + count = int(bright.sum()) + if count < min_pixels: return False, None + ys, xs = np.nonzero(bright) + h, w = image.shape[:2] + offset_x = float(xs.mean()) - w / 2.0 + offset_y = float(ys.mean()) - h / 2.0 + return True, (offset_x, offset_y) + + # functions for tracking #%% Functions used for centering stage def extractWormsDiff(img1, img2, capture_radius = -1, bin_factor=4, area = 0, threshold = 10, dark_bg = True, display = False): ''' use image difference to detect motion of object. - input: image of shape (N,M) + input: image of shape (N,M) minimal_difference: fraction of pixel that need to have changed to consider a difference output: vector of maximal/minimal change indicating where stage should compensate. ''' @@ -117,11 +152,11 @@ def extractWormsDiff(img1, img2, capture_radius = -1, bin_factor=4, area = 0, t ymin, ymax, xmin, xmax = np.max([0,h//2-capture_radius]), np.min([h,h//2+capture_radius]), np.max([0,w//2-capture_radius]), np.min([w,w//2+capture_radius]) img1_sm = img1[ymin:ymax, xmin:xmax] img2_sm = img2[ymin:ymax, xmin:xmax] - + # reduce image size img1_sm = downscale_local_mean(img1_sm, (bin_factor, bin_factor), cval=0, clip=True) img2_sm = downscale_local_mean(img2_sm, (bin_factor, bin_factor), cval=0, clip=True) - + # threshold # threshold = threshold_yen(img1_sm) # if dark_bg: @@ -130,10 +165,10 @@ def extractWormsDiff(img1, img2, capture_radius = -1, bin_factor=4, area = 0, t # else: # img1_sm = img1_sm < threshold # img2_sm = img2_sm < threshold - + # generate image difference - use floats! diff = img1_sm.astype(float) - img2_sm.astype(float) - + h,w = diff.shape # reduced image size # print('image shape after binning', diff.shape) @@ -176,7 +211,7 @@ def extractWormsDiff(img1, img2, capture_radius = -1, bin_factor=4, area = 0, t def extractWorms(img1, capture_radius = -1, bin_factor=4, dark_bg = True, display = False): ''' use image to detect motion of object. - input: image of shape (N,M) + input: image of shape (N,M) minimal_difference: fraction of pixel that need to have changed to consider a difference output: vector of maximal/minimal change indicating where stage should compensate. ''' @@ -185,7 +220,7 @@ def extractWorms(img1, capture_radius = -1, bin_factor=4, dark_bg = True, displ h,w = img1.shape #to region of interest ymin, ymax, xmin, xmax = 0,h,0,w - + if capture_radius > 0 : ymin, ymax, xmin, xmax = np.max([0,h//2-capture_radius]), np.min([h,h//2+capture_radius]), np.max([0,w//2-capture_radius]), np.min([w,w//2+capture_radius]) print(xmin, xmax, ymin, ymax) @@ -197,7 +232,7 @@ def extractWorms(img1, capture_radius = -1, bin_factor=4, dark_bg = True, displ # print('image shape after binning',h,w) # get cms h,w = img1_sm.shape - + # simply use max or min location if dark_bg: yc, xc = np.unravel_index(img1_sm.argmax(), img1_sm.shape) @@ -213,7 +248,7 @@ def extractWorms(img1, capture_radius = -1, bin_factor=4, dark_bg = True, displ rect = mpl.patches.Rectangle((xmin, ymin), xmax-xmin, ymax-ymin, linewidth=1, edgecolor='r', facecolor='none') # Add the patch to the Axes plt.gca().add_patch(rect) - + plt.colorbar() plt.subplot(212) plt.imshow(img1_sm) @@ -225,10 +260,10 @@ def extractWorms(img1, capture_radius = -1, bin_factor=4, dark_bg = True, displ return (yc-h//2)*bin_factor, (xc - w//2)*bin_factor -def extractWormsCMS(img1, capture_radius = -1, bin_factor=4, dark_bg = True, display = False, min_brightness: int = 0, max_brightness: int = 255): +def extractWormsCMS(img1, capture_radius = -1, bin_factor=4, dark_bg = True, display = False, min_brightness: int = 0, max_brightness: int | None = None): ''' use image to detect motion of object. - input: image of shape (N,M) + input: image of shape (N,M) minimal_difference: fraction of pixel that need to have changed to consider a difference output: vector of maximal/minimal change indicating where stage should compensate. ''' @@ -237,8 +272,11 @@ def extractWormsCMS(img1, capture_radius = -1, bin_factor=4, dark_bg = True, di img1_sm = cropCenterImage(img1, capture_radius * 2, capture_radius * 2) # Set pixels that are outside of the brightness range to 0 + max_brightness = effective_max_brightness(img1_sm, max_brightness) + if min_brightness > max_brightness: + raise ValueError('minimum brightness exceeds maximum brightness') img1_sm[ (img1_sm < min_brightness) | (img1_sm > max_brightness) ] = 0 - + # Compute tracking mask mask, resize_factor, intermediate_images = create_mask(img1_sm, dark_bg, display= display, bin_factor= bin_factor) @@ -247,18 +285,22 @@ def extractWormsCMS(img1, capture_radius = -1, bin_factor=4, dark_bg = True, di result = None try: result = find_CMS(mask, display=display) - + except ValueError as e: raise e - + # Unpack return values if display: (xc, yc), annotated_mask = result else: xc, yc = result - + # show intermediate steps for debugging if display: + xmin = (img1.shape[1] - img1_sm.shape[1]) // 2 + ymin = (img1.shape[0] - img1_sm.shape[0]) // 2 + xmax = xmin + img1_sm.shape[1] + ymax = ymin + img1_sm.shape[0] plt.subplot(231) plt.imshow(img1, cmap='gray') plt.title('img original') @@ -267,17 +309,17 @@ def extractWormsCMS(img1, capture_radius = -1, bin_factor=4, dark_bg = True, di rect = mpl.patches.Rectangle((xmin, ymin), xmax-xmin, ymax-ymin, linewidth=1, edgecolor='r', facecolor='none') # Add the patch to the Axes plt.gca().add_patch(rect) - + plt.subplot(232) plt.imshow(img1_sm, cmap='gray') plt.title('img reduced') plt.plot(xc/resize_factor, yc/resize_factor, 'ro') return (yc-h//2)/resize_factor, (xc-w//2)/resize_factor, intermediate_images, mask - + else: return (yc-h//2)/resize_factor, (xc-w//2)/resize_factor, mask - + def create_mask(img, dark_bg, display=False, bin_factor=None): @@ -287,7 +329,7 @@ def create_mask(img, dark_bg, display=False, bin_factor=None): 2. Resize the image 3. Blur the image again to remove high frequency noise 4. Perform adaptive thresholding to binarize the image - 5. Erode the image to remove small white spots + 5. Erode the image to remove small white spots 6. Dilate the image to fill in the holes and roughly get back the original size Parameters @@ -301,7 +343,7 @@ def create_mask(img, dark_bg, display=False, bin_factor=None): bin_factor : int Factor by which the image should be binned. If None, the image is resized such that the width is 200 pixels and the height is scaled accordingly - + Returns ------- img : np.array @@ -312,42 +354,39 @@ def create_mask(img, dark_bg, display=False, bin_factor=None): List of intermediate images for debugging ''' intermediate_images = [] # keep track of intermediate images for debugging - - # Compute resize_factor such that the width of image is 200 + + # Compute resize_factor such that the width of image is 200 # pixels and the height is scaled accordingly if bin_factor is None: resize_factor = 200 / img.shape[1] else: resize_factor = 1/bin_factor - - # check if the range is not between 0 and 1 - # if not then rescale the image - if np.max(img) > 1: - img = img / 255 + + img = normalize_image(img) if dark_bg: try: img = downscale_local_mean(img, (int(1/resize_factor), int(1/resize_factor)), clip=True) - + except ValueError as e: print(f'Error computing mask: {e}') - + # gamma correction - gamma = np.log(np.mean(img))/np.log(0.5) - gamma = np.clip(gamma, 0.5, 2) + mean = float(np.mean(img)) + gamma = np.log(mean) / np.log(0.5) if 0 < mean < 1 else 1.0 + gamma = np.clip(gamma, 0.5, 2.0) img = img**(1/gamma) else: img = cv2.GaussianBlur(img, (7, 7), 0) img = cv2.resize(img, (0, 0), fx=resize_factor, fy=resize_factor) - + intermediate_images.append(img) - + # rescale the image to [0, 255] so that further steps # including adaptive thresholding works properly - img = img * 255 - img = img.astype(np.uint8) + img = np.rint(img * 255).astype(np.uint8) # blur the image to remove high frequency noise/content img = cv2.GaussianBlur(img, (7, 7), 3) @@ -373,7 +412,7 @@ def create_mask(img, dark_bg, display=False, bin_factor=None): if display: return img, resize_factor, intermediate_images - + else: return img, resize_factor, None @@ -395,19 +434,22 @@ def find_CMS(mask, K=5, display=False): the CMS is always at the center or close to it) K : int Number of regions to keep - + Returns ------- cms_x_center x coordinate of the CMS cms_y_center y coordinate of the CMS - + Raise ------- ValueError The input image is invalid. Either completely black or white. ''' + if mask.size == 0 or not np.any(mask) or np.all(mask != 0): + raise ValueError('Cannot find a centroid in a uniform mask.') + labels = measure.label(mask) regionprop = regionprops_table(labels, properties=('centroid', 'area')) props = pd.DataFrame(regionprop) @@ -416,34 +458,34 @@ def find_CMS(mask, K=5, display=False): raise ValueError("Cannot find any centroid.") props = props.rename(columns={'centroid-1': 'x', 'centroid-0': 'y'}) - + # keep only the K biggest regions props = props.sort_values(by='area', ascending=False).head(K) middle_point = (mask.shape[1]//2, mask.shape[0]//2) # (x, y) props['dist'] = np.sqrt((props['x'] - middle_point[0])**2 + (props['y'] - middle_point[1])**2) - + # keep the closest to the previous center - cms_x_center, cms_y_center = props.sort_values(by='dist').iloc[0][['x', 'y']] - + cms_x_center, cms_y_center = props.sort_values(by='dist').iloc[0][['x', 'y']] + if display: annotated_mask = mask.copy() for i in range(len(props['y'])): # Draw circle cv2.circle(annotated_mask, (int(props['x'].iloc[i]), int(props['y'].iloc[i])), 2, (128, 0, 0), -1) - + # Write their area - dist = np.sqrt((props['y'].iloc[i] - (annotated_mask.shape[0] // 2)) ** 2 + + dist = np.sqrt((props['y'].iloc[i] - (annotated_mask.shape[0] // 2)) ** 2 + (props['x'].iloc[i] - (annotated_mask.shape[1] // 2)) ** 2) - + # Convert distance to string dist_str = f'd={dist:.1f}' - + # Put text on the image font_scale = min(*annotated_mask.shape) / 500 # adjust the font size based on the image size cv2.putText(annotated_mask, dist_str, (int(props['x'].iloc[i]), int(props['y'].iloc[i])), cv2.FONT_HERSHEY_SIMPLEX, font_scale, (128, 0, 0), 1, cv2.LINE_AA) - + if display: return (cms_x_center, cms_y_center), annotated_mask else: @@ -452,37 +494,37 @@ def find_CMS(mask, K=5, display=False): class ImageSaver: """An image saver static class that manages multiples small saving threads. - """ + """ def __init__(self) -> None: pass - + @staticmethod def startSavingImageInQueueThread(imageQueue: Queue, numMaxThreads: int | None = None) -> None: """An image saving thread manager that spawn a fix number of threads that iteratively consume an image from a queue and save it. Args: - imageQueue (Queue): + imageQueue (Queue): numMaxThreads (int | None, optional): Maximum number of small threads. Defaults to None. """ # Create a thread pool consumerThreadPool = ThreadPool(processes= numMaxThreads) - + # Start spawn image saving workers for i in range(consumerThreadPool._processes): consumerThreadPool.apply_async( - func= ImageSaver._imageSavingThreadWorker, + func= ImageSaver._imageSavingThreadWorker, args= (imageQueue,) ) - + # Wait until all workers are done consumerThreadPool.close() consumerThreadPool.join() print(f'Finished saving all the images') - + @staticmethod def _imageSavingThreadWorker(imageQueue: Queue): @@ -492,7 +534,7 @@ def _imageSavingThreadWorker(imageQueue: Queue): Args: imageQueue (Queue): _description_ - """ + """ # Run until there is no more work while True: @@ -501,9 +543,9 @@ def _imageSavingThreadWorker(imageQueue: Queue): # check for signal of no more work if queueItem is not None: - + img, imgPath, imgFileName = queueItem - # Save the image + # Save the image tifffile.imwrite(os.path.join(imgPath, imgFileName), img) else: @@ -523,7 +565,7 @@ def cropCenterImage( image: np.ndarray, cropWidth: int, cropHeight: int) -> np.n Returns: croppedImage (np.ndarray): the center cropped image - """ + """ h,w = image.shape @@ -531,7 +573,7 @@ def cropCenterImage( image: np.ndarray, cropWidth: int, cropHeight: int) -> np.n halfCropWidth = cropWidth // 2 halfCropHeight = cropHeight // 2 - + if halfCropWidth > 0 : xmin = np.max([0, w//2 - halfCropWidth]) xmax = np.min([w, w//2 + halfCropWidth]) @@ -539,7 +581,7 @@ def cropCenterImage( image: np.ndarray, cropWidth: int, cropHeight: int) -> np.n if halfCropHeight > 0 : ymin = np.max([0, h//2 - halfCropHeight]) ymax = np.min([h, h//2 + halfCropHeight]) - + # Crop the region of interest. # Also, we have to copy. Otherwise, we would modified the original image. img1_sm = np.copy( image[ymin:ymax, xmin:xmax] ) @@ -548,7 +590,7 @@ def cropCenterImage( image: np.ndarray, cropWidth: int, cropHeight: int) -> np.n def swapMatXYOrder(matrix: np.ndarray) -> np.ndarray: - """Modified a matrix such that the the multiplication operation + """Modified a matrix such that the the multiplication operation is suitable for vectors of order (y,x,..) from (x,y,...) or vice versa. Args: @@ -556,7 +598,7 @@ def swapMatXYOrder(matrix: np.ndarray) -> np.ndarray: Returns: matrixXYSwapped: An X,Y swapped version of the matrix - """ + """ matrixXYSwapped = np.copy(matrix) # Swap 1st and 2nd row @@ -564,7 +606,7 @@ def swapMatXYOrder(matrix: np.ndarray) -> np.ndarray: # Swap 1st and 2nd column matrixXYSwapped[:, [0, 1]] = matrixXYSwapped[:, [1, 0]] - + return matrixXYSwapped @@ -577,10 +619,10 @@ def createTranslationMatrix(translation_x: float, translation_y: float) -> np.fl Returns: translationMat (np.float32): A 3x3 translation matrix - """ + """ translationMat = np.array([ - [1, 0, translation_x], + [1, 0, translation_x], [0, 1, translation_y], [0, 0, 1] ], np.float32) @@ -599,7 +641,7 @@ def createScaleAndRotationMatrix(scale: float, rotation: float, center_rot_x: fl Returns: matrix (np.ndarray): A 3x3 transformation matrix. - """ + """ cos = scale * math.cos(rotation) sin = scale * math.sin(rotation) @@ -622,7 +664,7 @@ def createRigidTransformationMat(translation_x: float, translation_y: float, rot Returns: mat (np.ndarray): the transformation matrix - """ + """ cos = math.cos(rotation) sin = math.sin(rotation) @@ -631,12 +673,12 @@ def createRigidTransformationMat(translation_x: float, translation_y: float, rot [sin, cos, translation_y], [0, 0, 1], ], np.float32) - + return matrix def computeAngleBetweenTwo2DVecs(vec1: np.ndarray, vec2: np.ndarray) -> float: - """Compute angle between the two vector + """Compute angle between the two vector Args: vec1 (np.ndarray): vector of starting angle @@ -651,7 +693,10 @@ def computeAngleBetweenTwo2DVecs(vec1: np.ndarray, vec2: np.ndarray) -> float: # Sin(theta) cosTheta = np.dot(vec1normalized, vec2normalized) # Cos(theta) - sinTheta = np.cross(vec1normalized, vec2normalized) + sinTheta = ( + vec1normalized[0] * vec2normalized[1] + - vec1normalized[1] * vec2normalized[0] + ) # Compute angle theta = math.atan2(sinTheta, cosTheta) @@ -667,7 +712,7 @@ def rotatePointAboutOrig(point: np.ndarray, rotation: float) -> np.ndarray: Returns: point (np.ndarray): the rotated point - """ + """ rotationMatrix = createScaleAndRotationMatrix(1, rotation, 0, 0)[:2,:2] return rotationMatrix @ point @@ -699,7 +744,7 @@ def takeCalibrationImage(self, camera: basler.Camera, stage: zaber.Stage, stepsi Returns: - None: if taking images is not successful - Tuple(basisImageOrig, basisImageX, basisYImage): if taking images is successful - """ + """ self.stepsize = stepsize self.stepunits = stepunits @@ -721,7 +766,7 @@ def takeCalibrationImage(self, camera: basler.Camera, stage: zaber.Stage, stepsi # If taking image is not successful then return None if not (isSuccessImageOrig and isSuccessImageX and isSuccessImageY): return None, None, None - + # If in dual color mode then crop only relavent region if dualColorMode: h, w = self.basisXImage.shape @@ -735,9 +780,9 @@ def takeCalibrationImage(self, camera: basler.Camera, stage: zaber.Stage, stepsi self.basisOrigImage = self.basisOrigImage[:,w//2:] self.basisXImage = self.basisXImage[:,w//2:] self.basisYImage = self.basisYImage[:,w//2:] - + return self.basisOrigImage, self.basisXImage, self.basisYImage - + def calibrateCameraAndStageTransform(self) -> None | Tuple[float, int, float]: """Estimate the transformation from stage space to image space using phase cross correlation in X and Y bases. @@ -749,17 +794,17 @@ def calibrateCameraAndStageTransform(self) -> None | Tuple[float, int, float]: - rotationStageToCam (float): rotation angle from stage - imageNormalDir (int): image plane normal vector's direction (+X cross +Y in image space). Use to imply the direction of Y axis in camera-stage change of basis matrix. Possible results are +1 (for +Z) and -1 (for -Z). - pixelsize (float): ratio bettween unit in stage space and pixel space (e.g. mm/px). - """ + """ + + # Estimate camera basis X + basisXPhaseShift, _, _ = phase_cross_correlation(self.basisOrigImage, self.basisXImage, upsample_factor= 100, space= 'real', overlap_ratio= 0.5) - # Estimate camera basis X - basisXPhaseShift, _, _ = phase_cross_correlation(self.basisOrigImage, self.basisXImage, upsample_factor= 1, space= 'real', overlap_ratio= 0.5) - camBasisXVec = np.array([basisXPhaseShift[1], -basisXPhaseShift[0]], np.float32) camBasisXLen = np.linalg.norm(camBasisXVec) # Estimate camera basis Y - basisYPhaseShift, _, _ = phase_cross_correlation(self.basisOrigImage, self.basisYImage, upsample_factor= 1, space= 'real', overlap_ratio= 0.5) - + basisYPhaseShift, _, _ = phase_cross_correlation(self.basisOrigImage, self.basisYImage, upsample_factor= 100, space= 'real', overlap_ratio= 0.5) + camBasisYVec = np.array([basisYPhaseShift[1], -basisYPhaseShift[0]], np.float32) camBasisYLen = np.linalg.norm(camBasisYVec) @@ -768,7 +813,7 @@ def calibrateCameraAndStageTransform(self) -> None | Tuple[float, int, float]: or np.equal(camBasisXLen, 0) or np.equal(camBasisYLen, 0): return None - # Compute angle between the two basis + # Compute angle between the two basis angleBetweenXYBasis = computeAngleBetweenTwo2DVecs( camBasisXVec, camBasisYVec ) signAngleBetweenXYBasis = int(np.sign(angleBetweenXYBasis)) @@ -780,9 +825,9 @@ def calibrateCameraAndStageTransform(self) -> None | Tuple[float, int, float]: diffAngleHalf = absDiffAngle / 2 basisXCompensatedAngle = 0 basisYCompensatedAngle = 0 - + if absAngleBetweenXYBasis < math.pi/2: - + basisXCompensatedAngle = -1 * signAngleBetweenXYBasis * diffAngleHalf basisYCompensatedAngle = +1 * signAngleBetweenXYBasis * diffAngleHalf @@ -790,14 +835,14 @@ def calibrateCameraAndStageTransform(self) -> None | Tuple[float, int, float]: basisXCompensatedAngle = +1 * signAngleBetweenXYBasis * diffAngleHalf basisYCompensatedAngle = -1 * signAngleBetweenXYBasis * diffAngleHalf - + camBasisXVec = rotatePointAboutOrig(camBasisXVec, basisXCompensatedAngle) camBasisYVec = rotatePointAboutOrig(camBasisYVec, basisYCompensatedAngle) # Compute rotation angle from stage to camera normCamBasisXVec = camBasisXVec / camBasisXLen - rotationStageToCam = computeAngleBetweenTwo2DVecs( - np.array([1., 0.], np.float32), + rotationStageToCam = computeAngleBetweenTwo2DVecs( + np.array([1., 0.], np.float32), normCamBasisXVec ) @@ -807,9 +852,17 @@ def calibrateCameraAndStageTransform(self) -> None | Tuple[float, int, float]: # Average between the two pixelSize = (pixelSize_X + pixelSize_Y) / 2 + print( + f'[calib] step={self.stepsize} {self.stepunits} | ' + f'shiftX={camBasisXLen:.1f}px shiftY={camBasisYLen:.1f}px | ' + f'pxX={pixelSize_X:.4f} pxY={pixelSize_Y:.4f} (avg {pixelSize:.4f}) | ' + f'angle(X^Y)={math.degrees(angleBetweenXYBasis):.2f} deg | ' + f'rotation={math.degrees(rotationStageToCam):.2f} deg' + ) + return (rotationStageToCam, signAngleBetweenXYBasis, pixelSize) - + @staticmethod def genImageToStageMatrix(rotation: float, imageNormalDir: int, pixelSize: float) -> Tuple[np.ndarray, np.ndarray]: """Compute trasnformation matrix from image space to stage space using the given rotation angle, sign of cross product between Camera Space X,Y basis, and pixelsize. Assume only rotation and uniform scaling. @@ -822,7 +875,7 @@ def genImageToStageMatrix(rotation: float, imageNormalDir: int, pixelSize: float Returns: - imageToStageMat (np.ndarray): transformation matrix from image space to stage space - imageToStageRotOnlyMat (np.ndarray): transformation matrix from image space to stage space without uniform scaling - """ + """ # Stage to Image. Standard 2D rotation matrix cosval, sinval = math.cos(rotation), math.sin(rotation) @@ -842,7 +895,7 @@ def genImageToStageMatrix(rotation: float, imageNormalDir: int, pixelSize: float return pixelSize * imageToStageMat, imageToStageMat - + @staticmethod def renderChangeOfBasisImage(stageToImageMat: np.ndarray) -> np.ndarray: """A utility function for plotting a 2D Change of Basis matrix and saving into an image data. @@ -852,7 +905,7 @@ def renderChangeOfBasisImage(stageToImageMat: np.ndarray) -> np.ndarray: Returns: np.ndarray: _description_ - """ + """ # Define the coordinates for the vectors stageX = [1, 0] # Vector from origin to (1,0) stageY = [0, 1] # Vector from origin to (0,1) @@ -861,12 +914,12 @@ def renderChangeOfBasisImage(stageToImageMat: np.ndarray) -> np.ndarray: # Create the plot fig = plt.figure(figsize=(6, 6)) - + def drawVectorFromOrigWithAnnotation(point: List[float], color: str, name: str, linestyle: str) -> None: plt.quiver(*[0, 0], *point, color= color, scale= 1, scale_units= 'xy', angles= 'xy', label= name, linestyle= linestyle, linewidth= 1, facecolor= color) plt.annotate(name, (point[0], point[1]), textcoords= 'offset points', xytext= (10,10), \ ha= 'center', fontsize= 12, color= 'black') - + drawVectorFromOrigWithAnnotation(stageX, 'r', 'Stage +X', linestyle= 'solid') drawVectorFromOrigWithAnnotation(stageY, 'r', 'Stage +Y', linestyle= 'solid') drawVectorFromOrigWithAnnotation(imageX, 'g', 'Image +X', linestyle= 'dashed') @@ -897,19 +950,19 @@ def drawVectorFromOrigWithAnnotation(point: List[float], color: str, name: str, class DualColorImageCalibrator: - + # Class attributes mainSide: str dualColorImage: np.ndarray mainSideImage: np.ndarray minorSideImage: np.ndarray - + def __init__(self) -> None: pass - + def processDualColorImage(self, dualColorImage: np.ndarray, mainSide: str) -> None: - """Crop dual color image into main and minor side and apply histrogram equalization + """Crop dual color image into main and minor side and apply histrogram equalization for better visibility. Args: @@ -935,7 +988,7 @@ def processDualColorImage(self, dualColorImage: np.ndarray, mainSide: str) -> No # Main at right side, minor at left self.mainSideImage = self.dualColorImage[:,:fullImg_w//2] self.minorSideImage = self.dualColorImage[:,fullImg_w//2:] - + # Equalize Histogram # create a CLAHE object (Arguments are optional). clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) @@ -943,7 +996,7 @@ def processDualColorImage(self, dualColorImage: np.ndarray, mainSide: str) -> No self.minorSideImage = clahe.apply(self.minorSideImage) return self.mainSideImage, self.minorSideImage - + def calibrateMinorToMainTransformationMatrix(self) -> Tuple[float, float, float]: """Estimate transformation from minor side to main side. Only account for translation and rotation. @@ -952,12 +1005,12 @@ def calibrateMinorToMainTransformationMatrix(self) -> Tuple[float, float, float] translation_x (float): translation x translation_y (float): translation y rotation (float): rotation in radian - """ + """ # Create an ITK image from the numpy array mainSideImageITK = itk.GetImageFromArray(self.mainSideImage) minorSideImageITK = itk.GetImageFromArray(self.minorSideImage) - + # Create registration parameter object parameter_object = itk.ParameterObject.New() # Set regid estimation parameters @@ -975,7 +1028,7 @@ def calibrateMinorToMainTransformationMatrix(self) -> Tuple[float, float, float] parameter_object= parameter_object, log_to_console= False ) - + # Get transformation matrix result result_parameter_map = result_transform_parameters.GetParameterMap(0) rotation, translation_x, translation_y = ( float(x) for x in result_parameter_map['TransformParameters'] ) @@ -994,11 +1047,11 @@ def calibrateMinorToMainTransformationMatrix(self) -> Tuple[float, float, float] theta = math.atan2( sinTheta, cosTheta ) return translation_x, translation_y, theta - + @staticmethod def genMinorToMainMatrix(translation_x: float, translation_y: float, rotation: float, center_x: float, center_y: float): - """Create a rigid transformation matrix (scale = 1) from minor to main side. + """Create a rigid transformation matrix (scale = 1) from minor to main side. Args: translation_x (float): translation in X axis @@ -1009,7 +1062,7 @@ def genMinorToMainMatrix(translation_x: float, translation_y: float, rotation: f Returns: transformationMatrix (np.ndarray): transformation matrix from minor to main - """ + """ # Compute the rotation matrix. rotationMat = createScaleAndRotationMatrix(1, rotation, center_x, center_y) @@ -1018,14 +1071,14 @@ def genMinorToMainMatrix(translation_x: float, translation_y: float, rotation: f # Compute transformation matrix transformationMat = translationMat @ rotationMat - + return transformationMat class DepthOfFieldEstimator: - + def __init__(self): - # Create a DataFrame to store DoF data + # Create a DataFrame to store DoF data self.dofDataFrame = pd.DataFrame(columns=['pos_z', 'image', 'estimatedFocus']) # C, A, mu, alpha, beta self.normDistParams: list[float, float, float, float, float] = [0, 0, 0, 0, 0] @@ -1050,9 +1103,9 @@ def estimate(self, camera: basler.Camera, stage: zaber.Stage, searchDistance: fl Returns: dof (float): estimated Depth of Field """ - + self.takeCalibrationImages(camera, stage, searchDistance, numImages, focusEstimationMethod, dualColorMode, dualColorModeMainSide, capturedRadius) - + self.fitDataToNormalDist() # Find the x position where cumulative area from mu to x is 10% @@ -1066,9 +1119,9 @@ def estimate(self, camera: basler.Camera, stage: zaber.Stage, searchDistance: fl estimatedDof = x_pct_end - x_pct_begin return estimatedDof - - def takeCalibrationImages(self, camera: basler.Camera, stage: zaber.Stage, searchDistance: float, numImages: int, focusEstimationMethod: FocusEstimationMethod, dualColorMode: bool = False, dualColorModeMainSide: str = 'Right', capturedRadius: float = 0) -> None: + + def takeCalibrationImages(self, camera: basler.Camera, stage: zaber.Stage, searchDistance: float, numImages: int, focusEstimationMethod: FocusEstimationMethod, dualColorMode: bool = False, dualColorModeMainSide: str = 'Right', capturedRadius: float = 0, followWorm: bool = False, imageToStageMat = None, stageUnits: str = 'um', threshold: float = 150, minPixels: int = 50) -> None: """Scan over the searchDistance area and take sample images. Args: @@ -1084,16 +1137,23 @@ def takeCalibrationImages(self, camera: basler.Camera, stage: zaber.Stage, searc Raises: RuntimeError: When taking an image is unsuccessful """ - + + if numImages < 1: + raise ValueError('numImages must be at least 1') + # Create an empty DataFrame df = pd.DataFrame(columns=['pos_z', 'image', 'estimatedFocus'], index= range(numImages)) - + # Save current position startingPos = stage.get_position(unit='mm') # Compute moving position - currentPos = [startingPos[0], startingPos[1], startingPos[2] - searchDistance / 2] - stepSize_z = searchDistance / (numImages - 1) + if numImages == 1: + currentPos = list(startingPos) + stepSize_z = 0.0 + else: + currentPos = [startingPos[0], startingPos[1], startingPos[2] - searchDistance / 2] + stepSize_z = searchDistance / (numImages - 1) # Go to the beginning position stage.move_abs(currentPos, wait_until_idle= True) @@ -1107,7 +1167,7 @@ def takeCalibrationImages(self, camera: basler.Camera, stage: zaber.Stage, searc raise RuntimeError('Taking an image is unsuccessful') h, w = image.shape - + if dualColorMode: if dualColorModeMainSide == 'Left': @@ -1116,28 +1176,51 @@ def takeCalibrationImages(self, camera: basler.Camera, stage: zaber.Stage, searc elif dualColorModeMainSide == 'Right': image = image[:, w//2:] - w = image.shape[1] - + h, w = image.shape + + r = int(capturedRadius) + + if followWorm: + # Locate the worm and measure focus on a fixed window around it. + present, offset = detect_worm(image, threshold, minPixels) + if present: + cx = min(max(int(w/2 + offset[0]), r), w - r) + cy = min(max(int(h/2 + offset[1]), r), h - r) + crop = image[cy-r:cy+r, cx-r:cx+r] + estimatedFocus = estimateFocus(focusEstimationMethod, crop) + # Move the camera (XY) to recenter on the worm for the next Z step. + if imageToStageMat is not None: + dy, dx = getStageDistances(np.array([-offset[1], offset[0]]), imageToStageMat) + stage.move_rel((dx, dy, 0), unit= stageUnits, wait_until_idle= True) + else: + estimatedFocus = np.nan # reject this sample + else: + crop = cropCenterImage(image, r * 2, r * 2) + estimatedFocus = estimateFocus(focusEstimationMethod, crop) - # Center-crop the image - image = cropCenterImage(image, capturedRadius * 2, capturedRadius * 2) - - # Estimate focus of the image - estimatedFocus = estimateFocus(focusEstimationMethod, image) - # Store the image df.iloc[i] = [currentPos[2], image, estimatedFocus] - - # Move to a new position - currentPos[2] = currentPos[2] + stepSize_z - stage.move_abs(currentPos, wait_until_idle= True) - # Return stage to starting position - stage.move_abs(startingPos) + # Step Z. In follow mode move Z relatively so the XY following is preserved. + if i + 1 < numImages: + currentPos[2] = currentPos[2] + stepSize_z + if followWorm: + stage.move_z(stepSize_z, unit= 'mm', wait_until_idle= True) + else: + stage.move_abs(currentPos, wait_until_idle= True) + + if followWorm: + # Keep the followed XY; only return Z to the starting height. + endPos = stage.get_position(unit= 'mm') + endPos[2] = startingPos[2] + stage.move_abs(endPos, wait_until_idle= True) + else: + # Return stage to starting position + stage.move_abs(startingPos) self.dofDataFrame = df - + @staticmethod def shiftedGeneralizedNormalDist(x: float, C: float, A: float, mu: float, alpha: float, beta: float) -> float: """Shifted and scaled generalized normal distribution model @@ -1165,7 +1248,7 @@ def shiftedGeneralizedNormalDist(x: float, C: float, A: float, mu: float, alpha: ) ) return y - + def fitDataToNormalDist(self): """Fit self.normDistParams into a shifted generalized normal distribution model that best represent self.dofDataFrame @@ -1181,11 +1264,11 @@ def fitDataToNormalDist(self): beta0 = 2 # normal distribution p0 = [C0, A0, mu0, alpha0, beta0] - + # Curve fitting self.normDistParams, _ = curve_fit(DepthOfFieldEstimator.shiftedGeneralizedNormalDist, x_data, y_data, p0= p0) - + def genEstimatedDofPlot(self) -> np.ndarray: """Generate pyplot image of the fitted self.normDistParams and the estimated DoF from it. @@ -1197,7 +1280,7 @@ def genEstimatedDofPlot(self) -> np.ndarray: x_data = self.dofDataFrame['pos_z'].tolist() y_data = self.dofDataFrame['estimatedFocus'].tolist() - + # Plotting x_fit = np.linspace(min(x_data), max(x_data), 500) @@ -1242,7 +1325,7 @@ def genEstimatedDofPlot(self) -> np.ndarray: plt.close(fig= fig) return plotImage - + def getBestFocusImage(self) -> Tuple[float, np.ndarray, float]: """Get a sampled image that has the best focus @@ -1259,6 +1342,173 @@ def getBestFocusImage(self) -> Tuple[float, np.ndarray, float]: return bestFocusPosition, bestFocusImage, bestFocusValue +class IntensitySweeper: + + def __init__(self): + self.dataFrame = pd.DataFrame(columns=['pos_z', 'mean_intensity']) + self.peakZ = None + self.zeroDerivZ = None + self.midZ = None + + + def sweep(self, camera: basler.Camera, stage: zaber.Stage, zStart: float, zEnd: float, numImages: int, dualColorMode: bool = False, dualColorModeMainSide: str = 'Right', stopRequested=None) -> None: + if numImages < 1: + raise ValueError('numImages must be at least 1') + + df = pd.DataFrame(columns=['pos_z', 'mean_intensity'], index= range(numImages)) + + startingPos = stage.get_position(unit='mm') + + if numImages == 1: + stepSize_z = 0.0 + currentPos = [startingPos[0], startingPos[1], (zStart + zEnd) / 2] + else: + stepSize_z = (zEnd - zStart) / (numImages - 1) + currentPos = [startingPos[0], startingPos[1], zStart] + + try: + if stopRequested is not None and stopRequested(): + raise InterruptedError('Z sweep cancelled') + if not stage.move_abs(currentPos, wait_until_idle= True): + raise RuntimeError('Moving to the Z-sweep start failed') + + for i in range(numImages): + if stopRequested is not None and stopRequested(): + raise InterruptedError('Z sweep cancelled') + + isSuccess, image = camera.singleTake() + + if not isSuccess: + raise RuntimeError('Taking an image is unsuccessful') + + if dualColorMode: + w = image.shape[1] + if dualColorModeMainSide == 'Left': + image = image[:, :w//2] + elif dualColorModeMainSide == 'Right': + image = image[:, w//2:] + + df.iloc[i] = [currentPos[2], np.mean(image)] + + if i + 1 < numImages: + currentPos[2] = currentPos[2] + stepSize_z + if not stage.move_abs(currentPos, wait_until_idle= True): + raise RuntimeError('Moving during the Z sweep failed') + finally: + if stopRequested is None or not stopRequested(): + stage.move_abs(startingPos) + + self.dataFrame = df + + + def derivatives(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + pos_z = np.array(self.dataFrame['pos_z'].tolist(), dtype=np.float64) + means = np.array(self.dataFrame['mean_intensity'].tolist(), dtype=np.float64) + + n = len(means) + dz = (pos_z[-1] - pos_z[0]) / (n - 1) if n > 1 else 1.0 + + polyorder = 3 + windowLength = min(11, n) + if windowLength % 2 == 0: + windowLength -= 1 + + if windowLength >= polyorder + 2: + smoothedMeans = savgol_filter(means, windowLength, polyorder) + firstDeriv = savgol_filter(means, windowLength, polyorder, deriv=1, delta=dz) + secondDeriv = savgol_filter(means, windowLength, polyorder, deriv=2, delta=dz) + else: + smoothedMeans = means + firstDeriv = np.gradient(means, pos_z) + secondDeriv = np.gradient(firstDeriv, pos_z) + + return pos_z, smoothedMeans, firstDeriv, secondDeriv + + + @staticmethod + def _zeroCrossing(x: np.ndarray, y: np.ndarray, refIndex: int) -> float | None: + crossings = np.where(np.diff(np.sign(y)) != 0)[0] + if len(crossings) == 0: + return None + + zeros = [] + for i in crossings: + y0, y1 = y[i], y[i + 1] + t = 0.0 if y1 == y0 else -y0 / (y1 - y0) + zeros.append(x[i] + t * (x[i + 1] - x[i])) + zeros = np.array(zeros) + + return float(zeros[np.argmin(np.abs(zeros - x[refIndex]))]) + + + def computeFocusEstimates(self) -> None: + pos_z, _, firstDeriv, _ = self.derivatives() + + peakIndex = int(np.argmax(firstDeriv)) + self.peakZ = float(pos_z[peakIndex]) + + self.zeroDerivZ = self._zeroCrossing(pos_z, firstDeriv, peakIndex) + + self.midZ = None if self.zeroDerivZ is None else (self.peakZ + self.zeroDerivZ) / 2 + + + def findGradientPeak(self) -> float: + self.computeFocusEstimates() + return self.peakZ + + + def findScanZ(self) -> float: + self.computeFocusEstimates() + return self.peakZ if self.midZ is None else self.midZ + + + def genPlot(self) -> np.ndarray: + pos_z, smoothedMeans, firstDeriv, secondDeriv = self.derivatives() + means = np.array(self.dataFrame['mean_intensity'].tolist(), dtype=np.float64) + + self.computeFocusEstimates() + + fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 13), sharex=True) + + def markLines(ax): + ax.axvline(self.peakZ, color='k', linestyle='--', label=f'max slope (z={self.peakZ:.4f})') + if self.zeroDerivZ is not None: + ax.axvline(self.zeroDerivZ, color='m', linestyle='--', label=f"d=0 (z={self.zeroDerivZ:.4f})") + if self.midZ is not None: + ax.axvline(self.midZ, color='c', linestyle='-.', label=f'midpoint (z={self.midZ:.4f})') + + ax1.plot(pos_z, means, 'b.', label='mean') + ax1.plot(pos_z, smoothedMeans, 'b-', label='fit') + markLines(ax1) + ax1.set_ylabel('Intensity (brightness)') + ax1.legend() + + ax2.plot(pos_z, firstDeriv, 'r-', label="d(intensity) (fit)") + ax2.axhline(0, color='gray', linewidth=0.8) + markLines(ax2) + ax2.set_ylabel('d(intensity)') + ax2.legend() + + ax3.plot(pos_z, secondDeriv, 'g-', label="d²(intensity) (fit)") + ax3.axhline(0, color='gray', linewidth=0.8) + markLines(ax3) + ax3.set_xlabel('Position Z') + ax3.set_ylabel('d²(intensity)') + ax3.legend() + + fig.tight_layout() + + canvas = FigureCanvasAgg(fig) + canvas.draw() + width, height = fig.get_size_inches() * fig.get_dpi() + plotImage = np.frombuffer(canvas.tostring_argb(), dtype='uint8').reshape(int(height), int(width), 4) + plotImage = plotImage[:, :, 1:4] + + plt.close(fig= fig) + + return plotImage + + class Exterior(Enum): Zero = 'Zero' Constant = 'Constant' @@ -1270,36 +1520,36 @@ def __init__(self, point: np.ndarray, value: float, name: str = ''): self.point = point self.value = value self.name = name - + @staticmethod def lerp2d(a: Vertex2D, b: Vertex2D, t: float) -> Vertex2D: point = a.point + (b.point - a.point) * t value = a.value + (b.value - a.value) * t return Vertex2D(point, value) - + @staticmethod def cross(x: np.ndarray, y: np.ndarray) -> np.ndarray: return x[..., 0] * y[..., 1] - x[..., 1] * y[..., 0] - + @staticmethod def isInsideFourPoints(v0: Vertex2D, v1: Vertex2D, v2: Vertex2D, v3: Vertex2D, p: np.ndarray) -> bool: - + def isOnLeftSide(a: np.ndarray, b: np.ndarray, p: np.ndarray) -> bool: vab = b - a vap = p - a if Vertex2D.cross(vab, vap) >= 0: return True return False - + return isOnLeftSide(v0.point, v1.point, p) \ and isOnLeftSide(v1.point, v2.point, p) \ and isOnLeftSide(v2.point, v3.point, p) \ and isOnLeftSide(v3.point, v0.point, p) - - + + @staticmethod def invBilinear( a, b, c, d, p ) -> np.ndarray: """Solve for u,v parameter in quadrilateral through bilinear. @@ -1316,49 +1566,49 @@ def invBilinear( a, b, c, d, p ) -> np.ndarray: np.ndarray: u,v parameters """ uv = np.array([0,0], np.float32) - + e = b-a f = d-a g = a-b+c-d h = p-a - + k2 = Vertex2D.cross( g, f ) k1 = Vertex2D.cross( e, f ) + Vertex2D.cross( h, g ) k0 = Vertex2D.cross( h, e ) - + w = k1*k1 - 4*k0*k2 - + if w<=0.001: uv[0] = -1 uv[1] = -1 return uv w = np.sqrt( w ) - - # will fail for k0=0, which is only on the ba edge + + # will fail for k0=0, which is only on the ba edge if k0 <= 0.001 and k0 >= -0.001: uv[0] = -1 uv[1] = -1 return uv - - v = 2*k0/(-k1 - w) + + v = 2*k0/(-k1 - w) if v < 0 or v > 1: v = 2*k0 / (-k1 + w) ta = e[0] + g[0]*v ta = ta + 0.001 * (1 - np.abs(math.copysign(1,ta))) - + u = (h[0] - f[0]*v)/ta if u < 0 or u > 1 or v < 0 or v > 1: uv[0] = -1 uv[1] = -1 return uv - + uv[0] = u uv[1] = v return uv - + @staticmethod def bilerp(v0: Vertex2D, v1: Vertex2D, v2: Vertex2D, v3: Vertex2D, p: np.ndarray, exterior: Exterior = Exterior.Zero, exteriorConstant: float = 0) -> float: @@ -1393,12 +1643,11 @@ def bilerp(v0: Vertex2D, v1: Vertex2D, v2: Vertex2D, v3: Vertex2D, p: np.ndarray val = 0 else: - + if exterior == Exterior.Zero: val = 0 else: val = exteriorConstant - + return val - \ No newline at end of file diff --git a/glowtracker/Scan_map.py b/glowtracker/Scan_map.py new file mode 100644 index 0000000..da0d1a0 --- /dev/null +++ b/glowtracker/Scan_map.py @@ -0,0 +1,53 @@ +from kivy.uix.widget import Widget +from kivy.properties import ListProperty, BooleanProperty +from kivy.graphics import Color, Line +from kivy.app import App + +class ScanMinimap(Widget): + stage_min = ListProperty([0, 72]) + stage_max = ListProperty([136, 145]) + + def __init__(self, **kw): + super().__init__(**kw) + self.bind(pos=self.redraw, size=self.redraw, + stage_min=self.redraw, stage_max=self.redraw) + + def on_kv_post(self, *args): + app = App.get_running_app() + if app is not None: + app.bind(plateCenter=self.redraw, plateRadius=self.redraw) + self.redraw() + + def _fit(self): + sw = self.stage_max[0] - self.stage_min[0] + sh = self.stage_max[1] - self.stage_min[1] + bw, bh = sh, sw + scale = min(self.width / bw, self.height / bh) * 0.9 + ox = self.center_x - bw * scale / 2 + oy = self.center_y - bh * scale / 2 + return scale, ox, oy + + def mm_to_px(self, x, y): + scale, ox, oy = self._fit() + dx = x - self.stage_min[0] + dy = y - self.stage_min[1] + sw = self.stage_max[0] - self.stage_min[0] + px = ox + dy * scale + py = oy + (sw - dx) * scale + return px, py + + def redraw(self, *a): + self.canvas.clear() + app = App.get_running_app() + scale, _, _ = self._fit() + with self.canvas: + Color(0.4, 0.7, 1, 1) + x0, y0 = self.mm_to_px(*self.stage_min) + x1, y1 = self.mm_to_px(*self.stage_max) + Line(rectangle=(min(x0, x1), min(y0, y1), + abs(x1 - x0), abs(y1 - y0)), width=1.2) + + if app is not None and app.plateCenter is not None and app.plateRadius is not None: + Color(1, 0.6, 0.2, 1) + cx, cy = self.mm_to_px(app.plateCenter[0], app.plateCenter[1]) + Line(circle=(cx, cy, app.plateRadius * scale), width=1.2) diff --git a/glowtracker/SharedMemory/__init__.py b/glowtracker/SharedMemory/__init__.py new file mode 100644 index 0000000..46aad70 --- /dev/null +++ b/glowtracker/SharedMemory/__init__.py @@ -0,0 +1,4 @@ +from .shared_memory_util import ArraySpec, SharedAtomicCounter +from .shared_ndarray import SharedNDArray +from .shared_memory_queue import SharedMemoryQueue +from .shared_memory_ring_buffer import SharedMemoryRingBuffer diff --git a/glowtracker/SharedMemory/shared_memory_queue.py b/glowtracker/SharedMemory/shared_memory_queue.py new file mode 100644 index 0000000..4aeae5c --- /dev/null +++ b/glowtracker/SharedMemory/shared_memory_queue.py @@ -0,0 +1,217 @@ +# Adopted from https://github.com/real-stanford/diffusion_policy/blob/main/diffusion_policy/shared_memory +from typing import Dict, List, Union +import numbers +from queue import (Empty, Full) +from multiprocessing.managers import SharedMemoryManager +import numpy as np +from .shared_memory_util import ArraySpec, SharedAtomicCounter +from .shared_ndarray import SharedNDArray + + +class SharedMemoryQueue: + """ + A FIFO Shared Memory Data Structure. + Stores a sequence of dict of numpy arrays. + """ + + def __init__(self, + shm_manager: SharedMemoryManager, + array_specs: List[ArraySpec], + buffer_size: int, + context=None, + ): + + write_counter = SharedAtomicCounter(context) + read_counter = SharedAtomicCounter(context) + + # allocate shared memory + shared_arrays = dict() + for spec in array_specs: + key = spec.name + assert key not in shared_arrays + array = SharedNDArray.create_from_shape( + mem_mgr=shm_manager, + shape=(buffer_size,) + tuple(spec.shape), + dtype=spec.dtype) + shared_arrays[key] = array + + self.buffer_size = buffer_size + self.array_specs = array_specs + self.write_counter = write_counter + self.read_counter = read_counter + self.shared_arrays = shared_arrays + + @classmethod + def create_from_examples(cls, + shm_manager: SharedMemoryManager, + examples: Dict[str, Union[np.ndarray, numbers.Number]], + buffer_size: int, + context=None, + ): + specs = list() + for key, value in examples.items(): + shape = None + dtype = None + if isinstance(value, np.ndarray): + shape = value.shape + dtype = value.dtype + assert dtype != np.dtype('O') + elif isinstance(value, numbers.Number): + shape = tuple() + dtype = np.dtype(type(value)) + else: + raise TypeError(f'Unsupported type {type(value)}') + + spec = ArraySpec( + name=key, + shape=shape, + dtype=dtype + ) + specs.append(spec) + + obj = cls( + shm_manager=shm_manager, + array_specs=specs, + buffer_size=buffer_size, + context=context, + ) + return obj + + def qsize(self): + read_count = self.read_counter.load() + write_count = self.write_counter.load() + n_data = write_count - read_count + return n_data + + def empty(self): + n_data = self.qsize() + return n_data <= 0 + + def clear(self): + self.read_counter.store(self.write_counter.load()) + + def put(self, data: Dict[str, Union[np.ndarray, numbers.Number]]): + expected_keys = {spec.name for spec in self.array_specs} + actual_keys = set(data) + if actual_keys != expected_keys: + raise KeyError( + f'queue fields differ: expected {sorted(expected_keys)}, ' + f'got {sorted(actual_keys)}' + ) + + for spec in self.array_specs: + value = data[spec.name] + if isinstance(value, np.ndarray): + if value.shape != spec.shape: + raise ValueError( + f'{spec.name!r} shape changed: expected {spec.shape}, ' + f'got {value.shape}' + ) + if value.dtype != spec.dtype: + raise TypeError( + f'{spec.name!r} dtype changed: expected {spec.dtype}, ' + f'got {value.dtype}' + ) + elif not isinstance(value, numbers.Number): + raise TypeError( + f'{spec.name!r} must be a numpy array or number, ' + f'got {type(value).__name__}' + ) + + read_count = self.read_counter.load() + write_count = self.write_counter.load() + n_data = write_count - read_count + if n_data >= self.buffer_size: + raise Full() + + next_idx = write_count % self.buffer_size + + # write to shared memory + for key, value in data.items(): + arr: np.ndarray + arr = self.shared_arrays[key].get() + if isinstance(value, np.ndarray): + arr[next_idx] = value + else: + arr[next_idx] = np.array(value, dtype=arr.dtype) + + # update idx + self.write_counter.add(1) + + def get(self, out=None) -> Dict[str, np.ndarray]: + write_count = self.write_counter.load() + read_count = self.read_counter.load() + n_data = write_count - read_count + if n_data <= 0: + raise Empty() + + if out is None: + out = self._allocate_empty() + + next_idx = read_count % self.buffer_size + for key, value in self.shared_arrays.items(): + arr = value.get() + np.copyto(out[key], arr[next_idx]) + + # update idx + self.read_counter.add(1) + return out + + def get_k(self, k, out=None) -> Dict[str, np.ndarray]: + write_count = self.write_counter.load() + read_count = self.read_counter.load() + n_data = write_count - read_count + if n_data <= 0: + raise Empty() + assert k <= n_data + + out = self._get_k_impl(k, read_count, out=out) + self.read_counter.add(k) + return out + + def get_all(self, out=None) -> Dict[str, np.ndarray]: + write_count = self.write_counter.load() + read_count = self.read_counter.load() + n_data = write_count - read_count + if n_data <= 0: + raise Empty() + + out = self._get_k_impl(n_data, read_count, out=out) + self.read_counter.add(n_data) + return out + + def _get_k_impl(self, k, read_count, out=None) -> Dict[str, np.ndarray]: + if out is None: + out = self._allocate_empty(k) + + curr_idx = read_count % self.buffer_size + for key, value in self.shared_arrays.items(): + arr = value.get() + target = out[key] + + start = curr_idx + end = min(start + k, self.buffer_size) + target_start = 0 + target_end = (end - start) + target[target_start: target_end] = arr[start:end] + + remainder = k - (end - start) + if remainder > 0: + # wrap around + start = 0 + end = start + remainder + target_start = target_end + target_end = k + target[target_start: target_end] = arr[start:end] + + return out + + def _allocate_empty(self, k=None): + result = dict() + for spec in self.array_specs: + shape = spec.shape + if k is not None: + shape = (k,) + shape + result[spec.name] = np.empty( + shape=shape, dtype=spec.dtype) + return result diff --git a/glowtracker/SharedMemory/shared_memory_ring_buffer.py b/glowtracker/SharedMemory/shared_memory_ring_buffer.py new file mode 100644 index 0000000..78e9bfd --- /dev/null +++ b/glowtracker/SharedMemory/shared_memory_ring_buffer.py @@ -0,0 +1,221 @@ +# Adopted from https://github.com/real-stanford/diffusion_policy/blob/main/diffusion_policy/shared_memory +from typing import Dict, List, Union + +import numbers +import time +from multiprocessing.managers import SharedMemoryManager +import numpy as np + +from .shared_ndarray import SharedNDArray +from .shared_memory_util import ArraySpec, SharedAtomicCounter + +class SharedMemoryRingBuffer: + """ + A FILO Shared Memory Data Structure. + Stores a sequence of dict of numpy arrays. + """ + + def __init__(self, + shm_manager: SharedMemoryManager, + array_specs: List[ArraySpec], + get_max_k: int, + get_time_budget: float, + put_desired_frequency: float, + safety_margin: float=1.5, + context=None, + ): + """ + shm_manager: Manages the life cycle of share memories + across processes. Remember to run .start() before passing. + array_specs: Name, shape and type of arrays for a single time step. + get_max_k: The maxmum number of items can be queried at once. + get_time_budget: The maxmum amount of time spent copying data from + shared memory to local memory. Increase this number for larger arrays. + put_desired_frequency: The maximum frequency that .put() can be called. + This influces the buffer size. + """ + + counter = SharedAtomicCounter(context) + + # compute buffer size + # At any given moment, the past get_max_k items should never + # be touched (to be read freely). Assuming the reading is reading + # these k items, which takes maximum of get_time_budget seconds, + # we need enough empty slots to make sure put_desired_frequency Hz + # of put can be sustaied. + buffer_size = int(np.ceil( + put_desired_frequency * get_time_budget + * safety_margin)) + get_max_k + + # allocate shared memory + shared_arrays = dict() + for spec in array_specs: + key = spec.name + assert key not in shared_arrays + array = SharedNDArray.create_from_shape( + mem_mgr=shm_manager, + shape=(buffer_size,) + tuple(spec.shape), + dtype=spec.dtype) + shared_arrays[key] = array + + # allocate timestamp array + timestamp_array = SharedNDArray.create_from_shape( + mem_mgr=shm_manager, + shape=(buffer_size,), + dtype=np.float64) + timestamp_array.get()[:] = -np.inf + + self.buffer_size = buffer_size + self.array_specs = array_specs + self.counter = counter + self.shared_arrays = shared_arrays + self.timestamp_array = timestamp_array + self.get_time_budget = get_time_budget + self.get_max_k = get_max_k + self.put_desired_frequency = put_desired_frequency + + + @property + def count(self): + return self.counter.load() + + @classmethod + def create_from_examples(cls, + shm_manager: SharedMemoryManager, + examples: Dict[str, Union[np.ndarray, numbers.Number]], + get_max_k: int=32, + get_time_budget: float=0.01, + put_desired_frequency: float=60, + context=None, + ): + specs = list() + for key, value in examples.items(): + shape = None + dtype = None + if isinstance(value, np.ndarray): + shape = value.shape + dtype = value.dtype + assert dtype != np.dtype('O') + elif isinstance(value, numbers.Number): + shape = tuple() + dtype = np.dtype(type(value)) + else: + raise TypeError(f'Unsupported type {type(value)}') + + spec = ArraySpec( + name=key, + shape=shape, + dtype=dtype + ) + specs.append(spec) + + obj = cls( + shm_manager=shm_manager, + array_specs=specs, + get_max_k=get_max_k, + get_time_budget=get_time_budget, + put_desired_frequency=put_desired_frequency, + context=context, + ) + return obj + + def clear(self): + self.counter.store(0) + + def put(self, data: Dict[str, Union[np.ndarray, numbers.Number]], wait: bool=True): + count = self.counter.load() + next_idx = count % self.buffer_size + # Make sure the next self.get_max_k elements in the ring buffer have at least + # self.get_time_budget seconds untouched after written, so that + # get_last_k can safely read k elements from any count location. + # Sanity check: when get_max_k == 1, the element pointed by next_idx + # should be rewritten at minimum self.get_time_budget seconds later. + timestamp_lookahead_idx = (next_idx + self.get_max_k - 1) % self.buffer_size + old_timestamp = self.timestamp_array.get()[timestamp_lookahead_idx] + t = time.monotonic() + if (t - old_timestamp) < self.get_time_budget: + deltat = t - old_timestamp + if wait: + # sleep the remaining time to be safe + time.sleep(self.get_time_budget - deltat) + else: + # throw an error + past_iters = self.buffer_size - self.get_max_k + hz = past_iters / deltat + raise TimeoutError( + 'Put executed too fast {}items/{:.4f}s ~= {}Hz'.format( + past_iters, deltat,hz)) + + # write to shared memory + for key, value in data.items(): + arr: np.ndarray + arr = self.shared_arrays[key].get() + if isinstance(value, np.ndarray): + arr[next_idx] = value + else: + arr[next_idx] = np.array(value, dtype=arr.dtype) + + # update timestamp + self.timestamp_array.get()[next_idx] = time.monotonic() + self.counter.add(1) + + def _allocate_empty(self, k=None): + result = dict() + for spec in self.array_specs: + shape = spec.shape + if k is not None: + shape = (k,) + shape + result[spec.name] = np.empty( + shape=shape, dtype=spec.dtype) + return result + + def get(self, out=None) -> Dict[str, np.ndarray]: + if out is None: + out = self._allocate_empty() + start_time = time.monotonic() + count = self.counter.load() + curr_idx = (count - 1) % self.buffer_size + for key, value in self.shared_arrays.items(): + arr = value.get() + np.copyto(out[key], arr[curr_idx]) + end_time = time.monotonic() + dt = end_time - start_time + if dt > self.get_time_budget: + raise TimeoutError(f'Get time out {dt} vs {self.get_time_budget}') + return out + + def get_last_k(self, k:int, out=None) -> Dict[str, np.ndarray]: + assert k <= self.get_max_k + if out is None: + out = self._allocate_empty(k) + start_time = time.monotonic() + count = self.counter.load() + assert k <= count + curr_idx = (count - 1) % self.buffer_size + for key, value in self.shared_arrays.items(): + arr = value.get() + target = out[key] + + end = curr_idx + 1 + start = max(0, end - k) + target_end = k + target_start = target_end - (end - start) + target[target_start: target_end] = arr[start:end] + + remainder = k - (end - start) + if remainder > 0: + # wrap around + end = self.buffer_size + start = end - remainder + target_start = 0 + target_end = end - start + target[target_start: target_end] = arr[start:end] + end_time = time.monotonic() + dt = end_time - start_time + if dt > self.get_time_budget: + raise TimeoutError(f'Get time out {dt} vs {self.get_time_budget}') + return out + + def get_all(self) -> Dict[str, np.ndarray]: + k = min(self.count, self.get_max_k) + return self.get_last_k(k=k) diff --git a/glowtracker/SharedMemory/shared_memory_util.py b/glowtracker/SharedMemory/shared_memory_util.py new file mode 100644 index 0000000..7f6366b --- /dev/null +++ b/glowtracker/SharedMemory/shared_memory_util.py @@ -0,0 +1,30 @@ +# Adopted from https://github.com/real-stanford/diffusion_policy/blob/main/diffusion_policy/shared_memory +from typing import Tuple +from dataclasses import dataclass +import multiprocessing as mp +import numpy as np + +@dataclass +class ArraySpec: + name: str + shape: Tuple[int] + dtype: np.dtype + + +class SharedAtomicCounter: + def __init__(self, context=None): + if context is None: + context = mp.get_context() + self.value = context.Value('Q', 0, lock=True) + + def load(self) -> int: + with self.value.get_lock(): + return self.value.value + + def store(self, value: int): + with self.value.get_lock(): + self.value.value = value + + def add(self, value: int): + with self.value.get_lock(): + self.value.value += value diff --git a/glowtracker/SharedMemory/shared_ndarray.py b/glowtracker/SharedMemory/shared_ndarray.py new file mode 100644 index 0000000..e71ff4d --- /dev/null +++ b/glowtracker/SharedMemory/shared_ndarray.py @@ -0,0 +1,174 @@ +# Adopted from https://github.com/real-stanford/diffusion_policy/blob/main/diffusion_policy/shared_memory +from __future__ import annotations + +import multiprocessing +import multiprocessing.synchronize +from multiprocessing.managers import SharedMemoryManager +from multiprocessing.shared_memory import SharedMemory +from typing import Any, TYPE_CHECKING, Generic, Optional, Tuple, TypeVar, Union + +import numpy as np +import numpy.typing as npt + + +SharedMemoryLike = Union[str, SharedMemory] # shared memory or name of shared memory +SharedT = TypeVar("SharedT", bound=np.generic) + + +class SharedNDArray(Generic[SharedT]): + """Class to keep track of and retrieve the data in a shared array + Attributes + ---------- + shm + SharedMemory object containing the data of the array + shape + Shape of the NumPy array + dtype + Type of the NumPy array. Anything that may be passed to the `dtype=` argument in `np.ndarray`. + lock + (Optional) multiprocessing.Lock to manage access to the SharedNDArray. This is only created if + lock=True is passed to the constructor, otherwise it is set to `None`. + A SharedNDArray object may be created either directly with a preallocated shared memory object plus the + dtype and shape of the numpy array it represents: + >>> from multiprocessing.shared_memory import SharedMemory + >>> import numpy as np + >>> from shared_ndarray2 import SharedNDArray + >>> x = np.array([1, 2, 3]) + >>> shm = SharedMemory(name="x", create=True, size=x.nbytes) + >>> arr = SharedNDArray(shm, x.shape, x.dtype) + >>> arr[:] = x[:] # copy x into the array + >>> print(arr[:]) + [1 2 3] + >>> shm.close() + >>> shm.unlink() + Or using a SharedMemoryManager either from an existing array or from arbitrary shape and nbytes: + >>> from multiprocessing.managers import SharedMemoryManager + >>> mem_mgr = SharedMemoryManager() + >>> mem_mgr.start() # Better yet, use SharedMemoryManager context manager + >>> arr = SharedNDArray.from_shape(mem_mgr, x.shape, x.dtype) + >>> arr[:] = x[:] # copy x into the array + >>> print(arr[:]) + [1 2 3] + >>> # -or in one step- + >>> arr = SharedNDArray.from_array(mem_mgr, x) + >>> print(arr[:]) + [1 2 3] + `SharedNDArray` does not subclass numpy.ndarray but rather generates an ndarray on-the-fly in get(), + which is used in __getitem__ and __setitem__. Thus to access the data and/or use any ndarray methods + get() or __getitem__ or __setitem__ must be used + >>> arr.max() # ERROR: SharedNDArray has no `max` method. + Traceback (most recent call last): + .... + AttributeError: SharedNDArray object has no attribute 'max'. To access NumPy ndarray object use .get() method. + >>> arr.get().max() # (or arr[:].max()) OK: This gets an ndarray on which we can operate + 3 + >>> y = np.zeros(3) + >>> y[:] = arr # ERROR: Cannot broadcast-assign a SharedNDArray to ndarray `y` + Traceback (most recent call last): + ... + ValueError: setting an array element with a sequence. + >>> y[:] = arr[:] # OK: This gets an ndarray that can be copied element-wise to `y` + >>> mem_mgr.shutdown() + """ + + shm: SharedMemory + # shape: Tuple[int, ...] # is a property + dtype: np.dtype + lock: Optional[multiprocessing.synchronize.Lock] + + def __init__( + self, shm: SharedMemoryLike, shape: Tuple[int, ...], dtype: npt.DTypeLike): + """Initialize a SharedNDArray object from existing shared memory, object shape, and dtype. + To initialize a SharedNDArray object from a memory manager and data or shape, use the `from_array() + or `from_shape()` classmethods. + Parameters + ---------- + shm + `multiprocessing.shared_memory.SharedMemory` object or name for connecting to an existing block + of shared memory (using SharedMemory constructor) + shape + Shape of the NumPy array to be represented in the shared memory + dtype + Data type for the NumPy array to be represented in shared memory. Any valid argument for + `np.dtype` may be used as it will be converted to an actual `dtype` object. + lock : bool, optional + If True, create a multiprocessing.Lock object accessible with the `.lock` attribute, by default + False. If passing the `SharedNDArray` as an argument to a `multiprocessing.Pool` function this + should not be used -- see this comment to a Stack Overflow question about `multiprocessing.Lock`: + https://stackoverflow.com/questions/25557686/python-sharing-a-lock-between-processes#comment72803059_25558333 + Raises + ------ + ValueError + The SharedMemory size (number of bytes) does not match the product of the shape and dtype + itemsize. + """ + if isinstance(shm, str): + shm = SharedMemory(name=shm, create=False) + dtype = np.dtype(dtype) # Try to convert to dtype + assert shm.size >= (dtype.itemsize * np.prod(shape)) + self.shm = shm + self.dtype = dtype + self._shape: Tuple[int, ...] = shape + + def __getstate__(self): + return (self.shm.name, self._shape, self.dtype) + + def __setstate__(self, state): + name, self._shape, self.dtype = state + self.shm = SharedMemory(name=name, create=False) + + def __repr__(self): + # Like numpy's ndarray repr + cls_name = self.__class__.__name__ + nspaces = len(cls_name) + 1 + array_repr = str(self.get()) + array_repr = array_repr.replace("\n", "\n" + " " * nspaces) + return f"{cls_name}({array_repr}, dtype={self.dtype})" + + @classmethod + def create_from_array( + cls, mem_mgr: SharedMemoryManager, arr: npt.NDArray[SharedT] + ) -> SharedNDArray[SharedT]: + """Create a SharedNDArray from a SharedMemoryManager and an existing numpy array. + Parameters + ---------- + mem_mgr + Running `multiprocessing.managers.SharedMemoryManager` instance from which to create the + SharedMemory for the SharedNDArray + arr + NumPy `ndarray` object to copy into the created SharedNDArray upon initialization. + """ + # Simply use from_shape() to create the SharedNDArray and copy the data into it. + shared_arr = cls.create_from_shape(mem_mgr, arr.shape, arr.dtype) + shared_arr.get()[:] = arr[:] + return shared_arr + + @classmethod + def create_from_shape( + cls, mem_mgr: SharedMemoryManager, shape: Tuple, dtype: npt.DTypeLike) -> SharedNDArray: + """Create a SharedNDArray directly from a SharedMemoryManager + Parameters + ---------- + mem_mgr + SharedMemoryManager instance that has been started + shape + Shape of the array + dtype + Data type for the NumPy array to be represented in shared memory. Any valid argument for + `np.dtype` may be used as it will be converted to an actual `dtype` object. + """ + dtype = np.dtype(dtype) # Convert to dtype if possible + shm = mem_mgr.SharedMemory(np.prod(shape) * dtype.itemsize) + return cls(shm=shm, shape=shape, dtype=dtype) + + @property + def shape(self) -> Tuple[int, ...]: + return self._shape + + + def get(self) -> npt.NDArray[SharedT]: + """Get a numpy array with access to the shared memory""" + return np.ndarray(self.shape, dtype=self.dtype, buffer=self.shm.buf) + + def __del__(self): + self.shm.close() \ No newline at end of file diff --git a/glowtracker/StageEnvironment.yml b/glowtracker/StageEnvironment.yml index af1cbbe..8134a1a 100644 --- a/glowtracker/StageEnvironment.yml +++ b/glowtracker/StageEnvironment.yml @@ -2,21 +2,7 @@ name: glowtracker channels: - conda-forge dependencies: - - kivy=2.2.1 - - matplotlib=3.7.2 - - numpy=1.25.1 - - opencv=4.7.0 - - pillow=10.0.0 - - pip=23.1.2 - - python=3.10.12 - - scikit-image=0.22.0 - - scipy=1.11.1 - - zaber-motion=4.2.0 - - overrides=7.7.0 - - pandas=2.2.0 + - python=3.12 + - pip>=24 - pip: - - pypylon==2.2.0 - - itk-elastix==0.19.0 - - platformdirs==3.9.1 - - pyparsing==3.0.9 - + - -e .[test] diff --git a/glowtracker/Zaber_control.py b/glowtracker/Zaber_control.py index eb25497..e8706f3 100644 --- a/glowtracker/Zaber_control.py +++ b/glowtracker/Zaber_control.py @@ -1,4 +1,7 @@ import asyncio +from collections import deque +import threading +import time from zaber_motion import Library, Units, MotionLibException, MovementFailedException, CommandFailedException from zaber_motion.units import units_from_literals, LITERALS_TO_UNITS, UnitsAndLiterals, Units from zaber_motion.ascii import Connection, Axis, Device @@ -12,6 +15,10 @@ DEFAULT_MAXSPEED_UNIT = 'mm/s' DEFAULT_ACCEL = 60.0 DEFAULT_ACCEL_UNIT = 'mm/s^2' +POSITION_POLL_INTERVAL = 0.2 +JOG_SAFETY_POLL_INTERVAL = 0.1 +POSITION_POLLER_JOIN_TIMEOUT = 2.0 +_POLL_INTERRUPTED = object() # Declare common type Vec3: TypeAlias = Tuple[float, float, float] @@ -57,8 +64,8 @@ def __init__(self, port:str , maxspeed: float = DEFAULT_MAXSPEED, maxspeed_unit: maxspeed_unit (str, optional): maximum axes' speed unit. Defaults to DEFAULT_MAXSPEED_UNIT. accel (float, optional): axes' acceleration. Defaults to DEFAULT_ACCEL. accel_unit (str, optional): axes' acceleration unit. Defaults to DEFAULT_ACCEL_UNIT. - """ - + """ + # Define class properties self.connection: Connection | None = None self.axis_x: Axis | None = None @@ -66,19 +73,37 @@ def __init__(self, port:str , maxspeed: float = DEFAULT_MAXSPEED, maxspeed_unit: self.axis_z: Axis | None = None self.devices: List[Device] = [] - + self._accel_signature = None + # Try connecting to the stage self.connection = self.connect_stage(port) - + if self.connection is not None: print(f'Connection to stage: {self.connection}') self.assign_axes() self.maxspeed = self.set_maxspeed(maxspeed, units_from_literals(maxspeed_unit)) self.accel = self.set_accel(accel, units_from_literals(accel_unit)) - + self.state = StageState() - - + + self._jog_velocity = [0.0, 0.0, 0.0] + self._disconnecting = False + + self._last_pos: List[float] | None = None + self._last_pos_time: float = 0.0 + self._position_cache_condition = threading.Condition() + self._position_read_lock = threading.Lock() + self._position_poller_lock = threading.Lock() + self._position_poll_stop = threading.Event() + self._position_poll_wake = threading.Event() + self._position_poll_thread: threading.Thread | None = None + self._jog_command_lock = threading.Lock() + self._jog_commands = deque() + self._requested_jog_axes = [False, False, False] + self._jog_generations = [0, 0, 0] + self._queued_stop_generations = [-1, -1, -1] + + def connect_stage(self, port='COM3'): """ Connects to the zaber stage and pass the connection including the axes @@ -87,7 +112,7 @@ def connect_stage(self, port='COM3'): """ try: self.connection = Connection.open_serial_port(port) - + device_list = self.connection.detect_devices() print("Found {} devices".format(len(device_list))) @@ -110,15 +135,15 @@ def assign_axes(self) -> None: print("Found {} devices".format(len(self.devices))) - # Get each axes' handler and inject them into the + # Get each axes' handler and inject them into the # Zaber's Connection class for ease of access. self.axis_x = self.devices[0].get_axis(1) self.axis_y = self.devices[1].get_axis(1) - + # Activate devices self.axis_x.device.identify() self.axis_y.device.identify() - + self.no_axes = 2 # Optional 3rd axis @@ -142,12 +167,12 @@ def set_maxspeed(self, maxspeed: float = DEFAULT_MAXSPEED, unit: str = DEFAULT_M return axes: List[Axis] = [] - + if self.no_axes == 2: axes = [self.axis_x, self.axis_y] elif self.no_axes == 3: axes = [self.axis_x, self.axis_y, self.axis_z] - + for axis in axes: # Set axis max speed try: @@ -159,9 +184,9 @@ def set_maxspeed(self, maxspeed: float = DEFAULT_MAXSPEED, unit: str = DEFAULT_M # Retrieve actual axis max speed self.maxspeed = axis.settings.get("maxspeed", unit) print(f'Maximum speed: {self.maxspeed:.4f} {units_to_literals(unit)}') - + return self.maxspeed - + def set_accel(self, accel: float = DEFAULT_ACCEL, unit: str = DEFAULT_ACCEL_UNIT) -> float: """Set acceleration to every axes. @@ -177,12 +202,12 @@ def set_accel(self, accel: float = DEFAULT_ACCEL, unit: str = DEFAULT_ACCEL_UNIT return axes: List[Axis] = [] - + if self.no_axes == 2: axes = [self.axis_x, self.axis_y] elif self.no_axes == 3: axes = [self.axis_x, self.axis_y, self.axis_z] - + for axis in axes: try: # Set axis acceleration @@ -191,26 +216,43 @@ def set_accel(self, accel: float = DEFAULT_ACCEL, unit: str = DEFAULT_ACCEL_UNIT except CommandFailedException as e: print(f'Setting acceleration error: {e}') - # Retrieve actual axis acceleration + # Retrieve actual axis acceleration self.accel = axis.settings.get("accel", units_from_literals(unit)) print(f'Acceleration: {self.accel:.4f} {units_to_literals(unit)}') + self._accel_signature = (float(accel), units_to_literals(unit)) return self.accel + def set_motion(self, maxspeed: float, accel: float, maxspeed_unit: str = DEFAULT_MAXSPEED_UNIT, accel_unit: str = DEFAULT_ACCEL_UNIT) -> None: + self.set_maxspeed(maxspeed, maxspeed_unit) + self.set_accel(accel, accel_unit) + + # Stage homing - def home_stage(self): + def home_stage(self, cancel_event=None) -> bool: ''' homes all connected devices & moves axes to starting positions necessary if device was disconnected from power source ''' - if self.connection is not None: - # Home and wait the Z axis first to prevent accident - if self.axis_z is not None: - self.axis_z.home(wait_until_idle= True) - self.axis_y.home(wait_until_idle= False) - self.axis_x.home(wait_until_idle= True) - + if self.connection is None: + return False + + def cancelled(): + return cancel_event is not None and cancel_event.is_set() + + if cancelled(): + return False + if self.axis_z is not None: + self.axis_z.home(wait_until_idle= True) + if cancelled(): + return False + self.axis_y.home(wait_until_idle= False) + if cancelled(): + return False + self.axis_x.home(wait_until_idle= True) + return not cancelled() + def wait_until_idle(self) -> None: """Wait until all axes """ @@ -221,129 +263,519 @@ def wait_until_idle(self) -> None: self.axis_y.wait_until_idle() if self.axis_z is not None: self.axis_z.wait_until_idle() - - # Stage moving to a given absolute position - def move_abs(self, position: List[float], unit: str = 'mm', wait_until_idle: bool = False) -> None: + + # Stage moving to a given absolute position + KEEPOUT_Y = 45.0 # mm + KEEPOUT_Z = 130.0 + KEEPOUT_MARGIN = 1.0 + + _UNIT_TO_MM = {'mm': 1.0, 'um': 0.001, 'cm': 10.0} + + def is_safe(self, x: float, y: float, z: float) -> bool: + y_lim = self.KEEPOUT_Y + self.KEEPOUT_MARGIN + z_lim = self.KEEPOUT_Z - self.KEEPOUT_MARGIN + return not (y < y_lim and z > z_lim) + + def _execute_safe_moves(self, target: List[float], cur: List[float], wait_until_idle: bool) -> None: + mm = units_from_literals('mm') + z_lim = self.KEEPOUT_Z - self.KEEPOUT_MARGIN + + def go_xy(): + self.axis_x.move_absolute(target[0], mm, False) + self.axis_y.move_absolute(target[1], mm, False) + self.axis_x.wait_until_idle() + self.axis_y.wait_until_idle() + + def go_z(): + if self.axis_z is not None: + self.axis_z.move_absolute(target[2], mm, wait_until_idle) + + if self.axis_z is not None and target[2] > z_lim: + go_xy() + go_z() + elif self.axis_z is not None and cur[2] > z_lim: + self.axis_z.move_absolute(target[2], mm, True) + go_xy() + else: + go_xy() + go_z() + + def move_abs(self, position: List[float], unit: str = 'mm', wait_until_idle: bool = False) -> bool: """Move to a given absolute position. Args: position (List[float]): The absolute position in order of x, y, z. Supports from 1 axis to 3 axes. unit (str, optional): Unit of the position. Defaults to 'mm'. wait_until_idle (bool, optional): Is the function return only after all axes finished moving. Defaults to False. + + Returns: + bool: True if the move completed, False if refused or faulted. """ if self.connection is None: - return - - pos_len = len(position) - + return False + + factor = self._UNIT_TO_MM.get(unit) + if factor is None: + print(f'move_abs: unknown unit {unit!r}; refusing for safety') + return False + + cur = self.get_position(unit='mm', isAsync=False) + if cur is None: + print('move_abs: cannot read current position; refusing') + return False + + target = list(cur) + for i in range(min(len(position), 3)): + target[i] = float(position[i]) * factor + + if self.axis_z is not None and not self.is_safe(*target): + print(f'move_abs refused: target {target} mm is inside the keep-out zone') + return False + try: - if pos_len >= 1 and self.axis_x is not None and position[0] != 0: - self.axis_x.move_absolute(float(position[0]), units_from_literals(unit), wait_until_idle) - - if pos_len >= 2 and self.axis_y is not None and position[1] != 0: - self.axis_y.move_absolute(float(position[1]), units_from_literals(unit), wait_until_idle) - - if pos_len == 3 and self.axis_z is not None and position[2] != 0: - self.axis_z.move_absolute(float(position[2]), units_from_literals(unit), wait_until_idle) - + self._execute_safe_moves(target, cur, wait_until_idle) except MotionLibException as e: - print(e) + print(f'move_abs to {target} mm failed: {e}') + return False + + return True + - # move single axis - def move_x(self, step: float, unit: str = 'um', wait_until_idle: bool = False): + def move_x(self, step: float, unit: str = 'um', wait_until_idle: bool = False) -> bool: """Move to a given relative location Args: step (float): can be positive or negative, position indicates which axis to move eg. (0,1,0) moves y axis only. unit (str, optional): Unit of the step. Defaults to 'um'. wait_until_idle (bool, optional): is wait until finished moving. Defaults to False. - """ + + Returns: + bool: True if the move command was issued without fault, False otherwise. + """ try: if self.axis_x is not None: self.axis_x.move_relative(float(step), units_from_literals(unit), wait_until_idle) except MotionLibException as e: - print(e) - - + print(f'move_x by {step} {unit} failed: {e}') + return False + + return True + + # move single axis - def move_y(self, step, unit = 'um', wait_until_idle = False): - """Move to a given relative location - Parameters: + def move_y(self, step, unit = 'um', wait_until_idle = False, check_safety: bool = True) -> bool: + """Move to a given relative location. + Parameters: step (tuple): can be positive or negative, position indicates which axis to move eg. (0,1,0) moves y axis only. units(str): string units, commonly used + check_safety (bool): when False, skip the keep-out check and its position read. Used in the tracking loop where Z is fixed. + Returns: + bool: True if the move command was issued without fault, False otherwise. """ + if check_safety: + factor = self._UNIT_TO_MM.get(unit) + if factor is None: + print(f'move_y: unknown unit {unit!r}; refusing for safety') + return False + cur = self._safe_position_mm() + if cur is None: + print('move_y: cannot read current position; refusing') + return False + if self.axis_z is not None and len(cur) > 2: + target_y = cur[1] + float(step) * factor + if not self.is_safe(cur[0], target_y, cur[2]): + print(f'move_y refused: would enter keep-out (y={target_y:.1f}, z={cur[2]:.1f})') + return False try: if self.axis_y is not None: self.axis_y.move_relative(float(step), units_from_literals(unit), wait_until_idle) - + except MotionLibException as e: - print(e) - - + print(f'move_y by {step} {unit} failed: {e}') + return False + + return True + + # move single axis - def move_z(self, step, unit = 'um', wait_until_idle = False): - """Move to a given relative location - Parameters: + def move_z(self, step, unit = 'um', wait_until_idle = False) -> bool: + """Move to a given relative location. + Parameters: step (tuple): can be positive or negative, position indicates which axis to move eg. (0,1,0) moves y axis only. units(str): string units, commonly used + Returns: + bool: True if the move command was issued without fault, False otherwise. """ + factor = self._UNIT_TO_MM.get(unit) + if factor is None: + print(f'move_z: unknown unit {unit!r}; refusing for safety') + return False + cur = self._safe_position_mm() + if cur is None: + print('move_z: cannot read current position; refusing') + return False + if self.axis_z is not None and len(cur) > 2: + target_z = cur[2] + float(step) * factor + if not self.is_safe(cur[0], cur[1], target_z): + print(f'move_z refused: would enter keep-out (y={cur[1]:.1f}, z={target_z:.1f})') + return False try: if self.axis_z is not None: self.axis_z.move_relative(float(step), units_from_literals(unit), wait_until_idle) - + except MotionLibException as e: - print(e) + print(f'move_z by {step} {unit} failed: {e}') + return False + return True - # define generic movement function - def move_rel(self, steps: Tuple[float], unit: str = 'um', wait_until_idle: bool = False) -> None: - """Move to a given relative steps + + def move_rel(self, steps: Tuple[float], unit: str = 'um', wait_until_idle: bool = False) -> bool: + """Move by relative steps, routed through the same collision-safe planner + as move_abs. Refuses if the resulting position is inside the keep-out zone. Args: steps (Tuple[float]): The relative movement step vector in order of x, y, z. Supports from 1 axis to 3 axes. unit (str, optional): Unit of the steps. Defaults to 'um'. wait_until_idle (bool, optional): Is the function return only after all axes finished moving. Defaults to False. - """ + + Returns: + bool: True if the move completed, False if refused or faulted. + """ if self.connection is None: - return - - pos_len = len(steps) - - if pos_len >= 1 and steps[0] != 0: - self.move_x(float(steps[0]), unit = unit, wait_until_idle=wait_until_idle) - - if pos_len >= 2 and steps[1] != 0: - self.move_y(float(steps[1]), unit = unit, wait_until_idle=wait_until_idle) - - if pos_len == 3 and steps[2] != 0: - self.move_z(float(steps[2]), unit = unit, wait_until_idle=wait_until_idle) - - - def start_move(self, velocity: Vec3, unit: str = 'um/s') -> None: + return False + + factor = self._UNIT_TO_MM.get(unit) + if factor is None: + print(f'move_rel: unknown unit {unit!r}; refusing for safety') + return False + + cur = self.get_position(unit='mm', isAsync=False) + if cur is None: + print('move_rel: cannot read current position; refusing') + return False + + target = list(cur) + for i in range(min(len(steps), 3)): + target[i] = cur[i] + float(steps[i]) * factor + + if self.axis_z is not None and not self.is_safe(*target): + print(f'move_rel refused: target {target} mm is inside the keep-out zone') + return False + + try: + self._execute_safe_moves(target, cur, wait_until_idle) + except MotionLibException as e: + print(f'move_rel by {steps} {unit} failed: {e}') + return False + + return True + + + def move_xy(self, x: float, y: float, unit: str = 'mm', wait_until_idle: bool = True) -> bool: + if self.connection is None: + return False + try: + u = units_from_literals(unit) + self.axis_x.move_absolute(float(x), u, False) + self.axis_y.move_absolute(float(y), u, False) + if wait_until_idle: + self.axis_x.wait_until_idle() + self.axis_y.wait_until_idle() + except MotionLibException as e: + print(f'move_xy to ({x}, {y}) {unit} failed: {e}') + return False + return True + + def start_move(self, velocity: Vec3, unit: str = 'um/s') -> bool: """Start moving in a given velocity's direction. ALWAYS call in conjuction with self.stop() to stop moving. - Parameters: + Parameters: velocity (Vec3, float): can be positive or negative, position indicates which axis to move eg. (0,1,0) moves y axis only. units(str, optional): has to be zaber units eg. Units.LENGTH_MICROMETRES """ + if self.connection is None or self._disconnecting: + return False + + safety_poll_required = self.axis_z is not None \ + and (velocity[1] != 0 or velocity[2] != 0) + if safety_poll_required and not self.start_position_poller(): + self.emergency_stop() + return False + try: # Move each axis simultaneously if self.axis_x is not None and not self.state.isMoving_x and velocity[0] != 0: self.state.isMoving_x = True + self._jog_velocity[0] = velocity[0] self.axis_x.move_velocity(float(velocity[0]), units_from_literals(unit)) - + if self.axis_y is not None and not self.state.isMoving_y and velocity[1] != 0: self.state.isMoving_y = True + self._jog_velocity[1] = velocity[1] self.axis_y.move_velocity(float(velocity[1]), units_from_literals(unit)) if self.axis_z is not None and not self.state.isMoving_z and velocity[2] != 0: self.state.isMoving_z = True + self._jog_velocity[2] = velocity[2] self.axis_z.move_velocity(float(velocity[2]), units_from_literals(unit)) - except MovementFailedException as e: - print(e) + except MotionLibException as e: + print(f'start_move at velocity {velocity} {unit} failed: {e}') + self.emergency_stop() + return False + + if safety_poll_required: + self._position_poll_wake.set() + + return True + + def request_start_move( + self, + velocity: Vec3, + unit: str = 'um/s', + accel: float | None = None, + accel_unit: str | None = None) -> bool: + if self.connection is None or self._disconnecting: + return False + if not self.start_position_poller(): + return False + with self._jog_command_lock: + moving_axes = [value != 0 for value in velocity] + if any( + requested and moving + for requested, moving + in zip(self._requested_jog_axes, moving_axes)): + return False + for index, moving in enumerate(moving_axes): + if moving: + self._requested_jog_axes[index] = True + self._jog_generations[index] += 1 + self._jog_commands.append( + ('start', tuple(velocity), unit, accel, accel_unit) + ) + self._position_poll_wake.set() + return True + + def request_stop(self, stopAxis: AxisEnum = AxisEnum.ALL) -> bool: + if self.connection is None or self._disconnecting: + return False + if not self.start_position_poller(): + return False + with self._jog_command_lock: + if stopAxis == AxisEnum.ALL: + indices = range(3) + else: + indices = [stopAxis.value - 1] + indices = list(indices) + if all( + self._queued_stop_generations[index] + == self._jog_generations[index] + for index in indices): + return True + for index in indices: + self._requested_jog_axes[index] = False + self._queued_stop_generations[index] = self._jog_generations[index] + self._jog_commands.append(('stop', stopAxis)) + self._position_poll_wake.set() + return True + + def _has_jog_commands(self) -> bool: + with self._jog_command_lock: + return bool(self._jog_commands) + + def _clear_jog_commands(self) -> None: + with self._jog_command_lock: + self._jog_commands.clear() + self._requested_jog_axes = [False, False, False] + self._jog_generations = [0, 0, 0] + self._queued_stop_generations = [-1, -1, -1] + + def _process_jog_commands(self) -> None: + while not self._disconnecting: + with self._jog_command_lock: + if not self._jog_commands: + return + command = self._jog_commands.popleft() + + try: + if command[0] == 'start': + _, velocity, unit, accel, accel_unit = command + if accel is not None and accel_unit is not None: + signature = (float(accel), units_to_literals(accel_unit)) + if signature != self._accel_signature: + self.set_accel(float(accel), accel_unit) + self.start_move(velocity, unit) + else: + self._stop_jog_no_response(command[1]) + except Exception as e: + print(f'Stage jog command failed: {e}') + self.emergency_stop() + return + + def _stop_jog_no_response(self, stopAxis: AxisEnum = AxisEnum.ALL) -> bool: + if self.connection is None: + return False + + axes = ( + (AxisEnum.X, self.axis_x, 'isMoving_x', 0), + (AxisEnum.Y, self.axis_y, 'isMoving_y', 1), + (AxisEnum.Z, self.axis_z, 'isMoving_z', 2), + ) + selected = [ + entry for entry in axes + if stopAxis == AxisEnum.ALL or entry[0] == stopAxis + ] + + try: + for _, axis, _, _ in selected: + if axis is not None: + axis.generic_command_no_response('stop') + except Exception as e: + print(f'Stage jog stop failed: {e}') + self.emergency_stop() + return False + + for _, axis, state_name, index in selected: + if axis is not None: + setattr(self.state, state_name, False) + self._jog_velocity[index] = 0.0 + self._position_poll_wake.set() + return True + + def _check_jog_safety(self, pos: List[float]) -> None: + y_lim = self.KEEPOUT_Y + self.KEEPOUT_MARGIN + z_lim = self.KEEPOUT_Z - self.KEEPOUT_MARGIN + BUFFER = 5.0 + + y, z = pos[1], pos[2] + vy, vz = self._jog_velocity[1], self._jog_velocity[2] + + if self.state.isMoving_y and z > z_lim and vy < 0 and y < y_lim + BUFFER: + self.stop(AxisEnum.Y) + if self.state.isMoving_z and y < y_lim and vz > 0 and z > z_lim - BUFFER: + self.stop(AxisEnum.Z) + + def _position_poll_loop(self) -> None: + while not self._position_poll_stop.is_set(): + if self.connection is None or self._disconnecting: + break + + self._process_jog_commands() + if self._position_poll_stop.is_set() \ + or self.connection is None or self._disconnecting: + break + + safety_poll = self.axis_z is not None and ( + self.state.isMoving_y or self.state.isMoving_z + ) + try: + pos = self._get_polled_position() + except Exception as e: + print(f'Stage position polling failed: {e}') + pos = None + + if pos is _POLL_INTERRUPTED: + continue + + if safety_poll: + if pos is None or len(pos) < 3: + self.emergency_stop() + else: + self._check_jog_safety(pos) + + interval = JOG_SAFETY_POLL_INTERVAL if safety_poll else POSITION_POLL_INTERVAL + self._position_poll_wake.wait(interval) + self._position_poll_wake.clear() + + def _get_polled_position(self): + positions = [] + axes = [self.axis_x, self.axis_y] + if self.axis_z is not None: + axes.append(self.axis_z) + + with self._position_read_lock: + for axis in axes: + if self._has_jog_commands() or self._position_poll_stop.is_set(): + return _POLL_INTERRUPTED + positions.append(axis.get_position(units_from_literals('mm'))) + + if self.axis_z is None: + positions.append(0.0) + + if self._has_jog_commands() or self._position_poll_stop.is_set(): + return _POLL_INTERRUPTED + self._cache_position(positions, 'mm') + return positions + + def start_position_poller(self) -> bool: + if self.connection is None or self._disconnecting: + return False + with self._position_poller_lock: + if self._position_poll_thread is not None \ + and self._position_poll_thread.is_alive(): + return True + self._position_poll_stop.clear() + self._position_poll_wake.clear() + self._position_poll_thread = threading.Thread( + target=self._position_poll_loop, + daemon=True, + name='StagePositionPoller', + ) + self._position_poll_thread.start() + return True + + def stop_position_poller(self, timeout: float = POSITION_POLLER_JOIN_TIMEOUT) -> bool: + self._clear_jog_commands() + self._position_poll_stop.set() + self._position_poll_wake.set() + with self._position_poller_lock: + thread = self._position_poll_thread + if thread is None or thread is threading.current_thread(): + return True + thread.join(timeout) + return not thread.is_alive() + + def get_cached_position(self, unit: str = 'mm', max_age: float | None = None) -> List[float] | None: + factor = self._UNIT_TO_MM.get(unit) + if factor is None: + return None + with self._position_cache_condition: + if self._last_pos is None: + return None + if max_age is not None and time.monotonic() - self._last_pos_time > max_age: + return None + return [value / factor for value in self._last_pos] + + + def emergency_stop(self) -> bool: + self._clear_jog_commands() + if self.connection is None: + self.state = StageState() + self._jog_velocity = [0.0, 0.0, 0.0] + return False + + stopped = False + try: + self.connection.stop_all(wait_until_idle=False) + stopped = True + except Exception as e: + print(f'Stage stop-all failed: {e}') + for axis in (self.axis_x, self.axis_y, self.axis_z): + if axis is None: + continue + try: + axis.stop(wait_until_idle=False) + stopped = True + except Exception as axis_error: + print(f'Stage axis stop failed: {axis_error}') + finally: + self.state = StageState() + self._jog_velocity = [0.0, 0.0, 0.0] + + return stopped def stop(self, stopAxis: AxisEnum = AxisEnum.ALL) -> None: @@ -354,32 +786,28 @@ def stop(self, stopAxis: AxisEnum = AxisEnum.ALL) -> None: """ if self.connection is None: return - - try: - if stopAxis == AxisEnum.ALL: - self.axis_x.stop(wait_until_idle = False) - self.axis_y.stop(wait_until_idle = False) - if self.axis_z is not None: - self.axis_z.stop(wait_until_idle = False) - - self.state.isMoving_x = False - self.state.isMoving_y = False - self.state.isMoving_z = False + if stopAxis == AxisEnum.ALL: + self.emergency_stop() + return - elif stopAxis == AxisEnum.X: + try: + if stopAxis == AxisEnum.X: self.axis_x.stop(wait_until_idle = False) self.state.isMoving_x = False - + self._jog_velocity[0] = 0.0 + elif stopAxis == AxisEnum.Y: self.axis_y.stop(wait_until_idle = False) self.state.isMoving_y = False - + self._jog_velocity[1] = 0.0 + elif stopAxis == AxisEnum.Z and self.no_axes == 3: self.axis_z.stop(wait_until_idle = False) self.state.isMoving_z = False - - except MovementFailedException as e: + self._jog_velocity[2] = 0.0 + + except MotionLibException as e: print(e) @@ -396,32 +824,33 @@ def get_position(self, unit: str = 'mm', isAsync: bool = True) -> Vec3 | None: if self.connection is None: return None - + pos: Vec3 | None = None - + try: - if isAsync: + with self._position_read_lock: + if isAsync: - loop = [] + loop = [] - loop.append(self.axis_x.get_position_async(units_from_literals(unit))) - loop.append(self.axis_y.get_position_async(units_from_literals(unit))) - if self.axis_z is not None: - loop.append(self.axis_z.get_position_async(units_from_literals(unit))) + loop.append(self.axis_x.get_position_async(units_from_literals(unit))) + loop.append(self.axis_y.get_position_async(units_from_literals(unit))) + if self.axis_z is not None: + loop.append(self.axis_z.get_position_async(units_from_literals(unit))) - move_coroutine = asyncio.gather(*loop) - event_loop = asyncio.get_event_loop() - pos = event_loop.run_until_complete(move_coroutine) - - else: + move_coroutine = asyncio.gather(*loop) + event_loop = asyncio.get_event_loop() + pos = event_loop.run_until_complete(move_coroutine) + + else: + + pos = [] + + pos.append(self.axis_x.get_position(units_from_literals(unit))) + pos.append(self.axis_y.get_position(units_from_literals(unit))) + if self.axis_z is not None: + pos.append(self.axis_z.get_position(units_from_literals(unit))) - pos = [] - - pos.append(self.axis_x.get_position(units_from_literals(unit))) - pos.append(self.axis_y.get_position(units_from_literals(unit))) - if self.axis_z is not None: - pos.append(self.axis_z.get_position(units_from_literals(unit))) - except MotionLibException as e: # Handle exception # This is usually a DeviceNotIdentifiedException from trying @@ -432,8 +861,34 @@ def get_position(self, unit: str = 'mm', isAsync: bool = True) -> Vec3 | None: if self.axis_z is None: pos = list(pos) + [0.0] + if pos is not None: + self._cache_position(pos, unit) + return pos + def _cache_position(self, pos, unit: str) -> None: + factor = self._UNIT_TO_MM.get(unit, 1.0) + with self._position_cache_condition: + self._last_pos = [p * factor for p in pos] + self._last_pos_time = time.monotonic() + self._position_cache_condition.notify_all() + + def _safe_position_mm(self, max_age: float = 0.3) -> List[float] | None: + pos = self.get_cached_position(unit='mm', max_age=max_age) + if pos is not None: + return pos + if self._position_poll_thread is not None and self._position_poll_thread.is_alive(): + with self._position_cache_condition: + previous_update = self._last_pos_time + self._position_poll_wake.set() + self._position_cache_condition.wait_for( + lambda: self._last_pos_time > previous_update + or self._position_poll_stop.is_set(), + timeout=max(POSITION_POLL_INTERVAL * 2, 0.5), + ) + return self.get_cached_position(unit='mm', max_age=max_age) + return self.get_position(unit='mm', isAsync=False) + def set_rangelimits(self, limits: List[float] = (160,160,155), unit: str = 'mm') -> List[float]: """Sets limit for every device axis separately. necessary to avoid collision with other set-up elements. @@ -444,13 +899,13 @@ def set_rangelimits(self, limits: List[float] = (160,160,155), unit: str = 'mm') Returns: rangelimits List[float]: the device returned maximum ranges, indicating the actual value it is set to. The list is of lenght 2 or 3 depending how many axes there are - """ + """ # set axes limits in millimetres (max. value is ?) if self.connection is None: return - + rangelimits: List[float] = [0, 0] - + # Axis 1 try: self.axis_x.settings.set('limit.max', limits[0], units_from_literals(unit)) @@ -466,7 +921,7 @@ def set_rangelimits(self, limits: List[float] = (160,160,155), unit: str = 'mm') except CommandFailedException as e: print(f'Setting stage limit error: {e}') - + rangelimits[1] = self.axis_y.settings.get('limit.max', units_from_literals(unit)) # Optional, Axis 3 @@ -476,34 +931,76 @@ def set_rangelimits(self, limits: List[float] = (160,160,155), unit: str = 'mm') except CommandFailedException as e: print(f'Setting stage limit error: {e}') - + rangelimits.append( self.axis_z.settings.get('limit.max', units_from_literals(unit)) ) - + return rangelimits - def on_connect(self, home = True, startloc = True, start = (20,75, 130), limits =(160,160,155)) -> None: + def on_connect(self, home = True, startloc = True, start = (20,75, 130), limits =(160,160,155), cancel_event=None) -> bool: """startup routine to home, set range and move to start if desired. """ + def cancelled(): + return cancel_event is not None and cancel_event.is_set() + + if cancelled() or self.connection is None: + return False + if home: - self.home_stage() - + if not self.home_stage(cancel_event): + return False + + if cancelled() or self.connection is None: + return False + self.set_rangelimits(limits) + if cancelled() or self.connection is None: + return False + if startloc: - self.move_abs(start) - + if not self.move_abs(start): + return False + + if cancelled() or self.connection is None: + return False + device_list = self.connection.detect_devices() for device in device_list: + if cancelled() or self.connection is None: + return False device.all_axes.wait_until_idle(throw_error_on_fault = True) - - def disconnect(self): + return not cancelled() and self.connection is not None + + + def disconnect(self) -> bool: """close com port connection.""" - if self.connection is not None: - self.connection.close() - - + self._disconnecting = True + stopped = False + connection = self.connection + try: + stopped = self.emergency_stop() + if not self.stop_position_poller(): + print('Stage position poller did not stop before disconnect') + finally: + try: + if connection is not None: + connection.close() + except Exception as e: + print(f'Closing stage connection failed: {e}') + finally: + self.connection = None + self.axis_x = None + self.axis_y = None + self.axis_z = None + self.devices = [] + self.state = StageState() + self._jog_velocity = [0.0, 0.0, 0.0] + + return stopped + + def is_busy(self) -> bool: """Check if any of the devices is busy @@ -513,10 +1010,10 @@ def is_busy(self) -> bool: for device in self.devices: if device.all_axes.is_busy(): return True - + return False - - + + def estimateTravelTime( self, dist: float ) -> float: """Estimate the travel time with assumption of 0 acceleration ramping time. The acceleration ramping time is a feature to set the acceleration to a linear function @@ -525,7 +1022,7 @@ def estimateTravelTime( self, dist: float ) -> float: However, it is a bit more tricky to compute an estimated time with such a profile, and since 0 acceleration ramp time is the default setting, we will focus only in this case. https://www.zaber.com/protocol-manual#topic_setting_motion_accel_ramptime - In the case of no acceleration time, the velocity function is a linear pice-wise + In the case of no acceleration time, the velocity function is a linear pice-wise function consist of 3 parts: ramp-up (increase velocity), stable (stable at maximum velocity), and ramp-down. Which forms a trapezoid shape. By Zaber's design, the acceleration when ramping up and ramping down are scalar value @@ -533,7 +1030,7 @@ def estimateTravelTime( self, dist: float ) -> float: and 'motion.decelonly' respectively (https://www.zaber.com/protocol-manual?device=X-LSM150A&peripheral=N%2FA&version=7.34&protocol=ASCII#topic_setting_motion). This resulting in a vertically symmetric trapezoid shape (i.e. isosceles trapezoid), - which we can derive an estimated travel time (x-axis in the velocity graph) which + which we can derive an estimated travel time (x-axis in the velocity graph) which is the width of the shape by the given acceleration and distance (area of the graph). However, in the case where the travelling speed did not get ramp up fast enough to reach the maximum speed before started to slowing down, the shape becomes a triangle, @@ -543,7 +1040,7 @@ def estimateTravelTime( self, dist: float ) -> float: have same exponential unit e.g. accel: mm/s^2, dist: mm, maxspeed: mm/s, the resulting output will be in the second unit (s). Otherwise, please handle it accordingly. - + Args: dist (float): travel distance @@ -559,7 +1056,7 @@ def estimateTravelTime( self, dist: float ) -> float: if dist <= dist_to_reach_v_max: # Isosceles Triangle shape estimated_travel_time = 2.0 * math.sqrt( dist / self.accel ) - + else: # Isosceles Trapezoid shape time_ramp_up = self.maxspeed / self.accel @@ -569,5 +1066,5 @@ def estimateTravelTime( self, dist: float ) -> float: time_travel_at_vel_max = dist_travel_at_vel_max / self.maxspeed estimated_travel_time = 2.0 * time_ramp_up + time_travel_at_vel_max - + return estimated_travel_time diff --git a/glowtracker/__main__.py b/glowtracker/__main__.py index 0319a85..6971ff6 100644 --- a/glowtracker/__main__.py +++ b/glowtracker/__main__.py @@ -10,8 +10,9 @@ sys.path.insert(0, curr_file_dir) # Disable kivy console log -os.environ["KIVY_NO_CONSOLELOG"] = "1" +# os.environ["KIVY_NO_CONSOLELOG"] = "1" -# Start application -from GlowTracker import main -main() \ No newline at end of file +# Start application (guarded so multiprocessing children don't relaunch the GUI) +if __name__ == '__main__': + from GlowTracker import main + main() \ No newline at end of file diff --git a/glowtracker/image_saver.py b/glowtracker/image_saver.py new file mode 100644 index 0000000..919f673 --- /dev/null +++ b/glowtracker/image_saver.py @@ -0,0 +1,179 @@ +import os +import time +from queue import Empty +from threading import Lock, Thread + +import tifffile + + +def close_file_with_timeout(file_object, timeout): + result = {'error': None} + + def close_file(): + try: + file_object.close() + except Exception as error: + result['error'] = error + + thread = Thread(target=close_file, daemon=True, name='CoordinateFileClose') + thread.start() + thread.join(timeout) + return not thread.is_alive() and result['error'] is None, result['error'], thread + + +class SaveAcknowledgements: + def __init__(self, coordinate_file): + self.coordinate_file = coordinate_file + self.pending = {} + self.next_index = 0 + self.saved_frames = 0 + self.failed_frames = 0 + self.lock = Lock() + + def add(self, index, coordinate_row, channels): + channels = set(channels) + if not channels: + raise ValueError('a frame must have at least one save channel') + with self.lock: + if index in self.pending or index < self.next_index: + raise ValueError(f'frame {index} is already registered') + self.pending[index] = { + 'coordinate_row': coordinate_row, + 'channels': channels, + 'failed': False, + } + + def saved(self, index, channel): + with self.lock: + frame = self.pending.get(index) + if frame is None or frame['failed']: + return False + if channel not in frame['channels']: + raise ValueError( + f'frame {index} did not expect channel {channel}' + ) + frame['channels'].discard(channel) + self._flush() + return True + + def failed(self, index): + with self.lock: + frame = self.pending.get(index) + if frame is None or frame['failed']: + return False + frame['failed'] = True + frame['channels'].clear() + self.failed_frames += 1 + self._flush() + return True + + def discard_pending(self): + with self.lock: + discarded = 0 + for frame in self.pending.values(): + if not frame['failed']: + frame['failed'] = True + frame['channels'].clear() + self.failed_frames += 1 + discarded += 1 + self._flush() + return discarded + + @property + def pending_count(self): + with self.lock: + return len(self.pending) + + def _flush(self): + while self.next_index in self.pending: + frame = self.pending[self.next_index] + if frame['channels']: + break + if not frame['failed']: + self.coordinate_file.write(frame['coordinate_row']) + self.saved_frames += 1 + del self.pending[self.next_index] + self.next_index += 1 + + +def _report(status_queue, status): + if status_queue is None: + return True + try: + status_queue.put(status) + return True + except Exception: + return False + + +def _set_failure(failure_event): + if failure_event is not None: + try: + failure_event.set() + except Exception: + pass + + +def save_worker( + image_queue, save_dir, filename_format, stop_event, + status_queue=None, failure_event=None): + get = getattr(image_queue, 'get_nowait', image_queue.get) + while True: + if failure_event is not None: + try: + if failure_event.is_set(): + break + except Exception: + pass + try: + data = get() + except Empty: + try: + if stop_event.is_set(): + break + except Exception as e: + error = f'{type(e).__name__}: {e}' + _set_failure(failure_event) + _report(status_queue, ('failed', -1, -1, error)) + break + time.sleep(0.001) + continue + except Exception as e: + error = f'{type(e).__name__}: {e}' + _set_failure(failure_event) + _report(status_queue, ('failed', -1, -1, error)) + break + + index = -1 + channel = -1 + temporary = None + try: + index = int(data['idx']) + channel = int(data.get('channel', 0)) + fname = filename_format.format(index) + if channel: + root, extension = os.path.splitext(fname) + suffix = '-main' if channel == 1 else '-minor' + fname = root + suffix + extension + + destination = os.path.join(save_dir, fname) + root, extension = os.path.splitext(destination) + temporary = root + '.part' + extension + tifffile.imwrite(temporary, data['img']) + os.replace(temporary, destination) + except Exception as e: + if temporary is not None: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + except Exception: + pass + error = f'{type(e).__name__}: {e}' + _set_failure(failure_event) + _report(status_queue, ('failed', index, channel, error)) + break + + if not _report(status_queue, ('saved', index, channel, '')): + _set_failure(failure_event) + break diff --git a/glowtracker/image_utils.py b/glowtracker/image_utils.py new file mode 100644 index 0000000..e3dc5ab --- /dev/null +++ b/glowtracker/image_utils.py @@ -0,0 +1,64 @@ +import numpy as np + + +def normalize_image(image): + source = np.asarray(image) + if source.size == 0: + raise ValueError('image must not be empty') + + if np.issubdtype(source.dtype, np.bool_): + return source.astype(np.float32) + + values = source.astype(np.float32) + if np.issubdtype(source.dtype, np.integer): + limits = np.iinfo(source.dtype) + scale = float(limits.max - limits.min) + if scale == 0: + return np.zeros(source.shape, dtype=np.float32) + values = (values - limits.min) / scale + else: + finite = values[np.isfinite(values)] + if finite.size == 0: + raise ValueError('image contains no finite pixels') + minimum = float(np.min(finite)) + maximum = float(np.max(finite)) + if minimum < 0 or maximum > 1: + scale = maximum - minimum + if scale == 0: + values = np.zeros(source.shape, dtype=np.float32) + else: + values = (values - minimum) / scale + + return np.clip(np.nan_to_num(values), 0.0, 1.0) + + +def effective_max_brightness(image, configured_max): + if configured_max is None: + return np.inf + source = np.asarray(image) + if configured_max == 255 \ + and np.issubdtype(source.dtype, np.unsignedinteger) \ + and source.dtype.itemsize > 1: + return np.iinfo(source.dtype).max + return configured_max + + +def texture_buffer_format(image): + dtype = np.asarray(image).dtype + if dtype == np.dtype(np.uint8): + return 'ubyte' + if dtype == np.dtype(np.uint16): + return 'ushort' + if dtype == np.dtype(np.float32): + return 'float' + raise TypeError(f'unsupported texture dtype: {dtype}') + + +def prepare_texture_data(image): + source = np.asarray(image) + try: + buffer_format = texture_buffer_format(source) + return np.ascontiguousarray(source), buffer_format + except TypeError: + converted = np.rint(normalize_image(source) * 255).astype(np.uint8) + return np.ascontiguousarray(converted), 'ubyte' diff --git a/glowtracker/layout.kv b/glowtracker/layout.kv index 1737210..676c31b 100644 --- a/glowtracker/layout.kv +++ b/glowtracker/layout.kv @@ -1,6 +1,7 @@ #:kivy 2.0 #:import NumericProperty kivy.properties.NumericProperty #:import Image kivy.uix.image.Image +#:import ScanMinimap Scan_map.ScanMinimap #: import partial functools.partial # main Window has 3 columns # left most is settings and setting displays @@ -41,7 +42,7 @@ MainWindow: text_size: self.size halign: 'center' valign: 'middle' - + Label: size_hint: (1, 1) font_size: 18 @@ -90,7 +91,7 @@ MainWindow: orientation: 'vertical' pos: root.pos spacing: 5 - MyLabel: + MyLabel: text: 'Stage Controls' XControls: id: xcontrols @@ -98,11 +99,9 @@ MainWindow: id: ycontrols ZControls: id: zcontrols - MyButton: - text: 'Autofocus' - pos_hint: {'x': 0.25} - on_release: root.autoFocusButtonCallback() - MyLabel: + GoToControls: + id: gotocontrols + MyLabel: text: 'Camera Settings' CameraProperties: id: camprops @@ -111,8 +110,8 @@ MainWindow: text: 'Load camera settings' pos_hint: {'x': 0.25} on_release: root.show_load() - - MyLabel: + + MyLabel: text: 'Experiment' TextInput: id: saveloc @@ -122,15 +121,15 @@ MainWindow: text: root.savefile on_text_validate: root.createRecordingPath() on_focus: app.toggle_key_binding(self.focus) - on_focus: - if not self.focus: root.createRecordingPath() - + on_focus: + if not self.focus: root.createRecordingPath() + MyButton: text: 'Save Location' pos_hint: {'x': 0.25} on_release: root.show_save() - - + + ### Display camera properties : cols: 3 @@ -155,7 +154,7 @@ MainWindow: on_text_validate: root.change_exposure() on_focus: app.toggle_key_binding(self.focus) on_focus: if not self.focus: root.change_exposure() - Label: + Label: text: 'Framerate (fps)' size_hint: (.25, 1) Slider: @@ -197,9 +196,9 @@ MainWindow: on_focus: app.toggle_key_binding(self.focus) on_focus: if not self.focus: root.change_gain() text: str(root.gain) - - - + + + ################################# # Stage motion buttons ################################# @@ -210,21 +209,21 @@ MainWindow: background_normal: 'icons/LeftButtonNormal_Double.jpg' background_down: 'icons/LeftButtonOnPress_Double.jpg' background_disabled_normal: 'icons/LeftButtonDisabled_Double.jpg' - + : size_hint: .25, .9 pos_hint: {"x":0.1, "y":0.1} background_normal: 'icons/LeftButtonNormal.jpg' background_down: 'icons/LeftButtonOnPress.jpg' background_disabled_normal: 'icons/LeftButtonDisabled.jpg' - + : size_hint: .5, .9 pos_hint: {"x":0.1, "y":0.1} background_normal: 'icons/RightButtonNormal_Double.jpg' background_down: 'icons/RightButtonOnPress_Double.jpg' background_disabled_normal: 'icons/RightButtonDisabled_Double.jpg' - + : size_hint: .25, .9 pos_hint: {"x":0.1, "y":0.1} @@ -236,14 +235,14 @@ MainWindow: orientation: 'horizontal' pos: root.pos size: root.size - + StageButtonLeftFast: id: sbl1 - on_state: app.stage.start_move((-app.vhigh,0,0), app.unit) if self.state=='down' else app.stage.stop() + on_state: app.jog((-1,0,0), True) if self.state=='down' else app.request_stage_stop() #disabled: app.stage.connection is None StageButtonLeft: id: sbl2 - on_state: app.stage.start_move((-app.vlow,0,0), app.unit) if self.state=='down' else app.stage.stop() + on_state: app.jog((-1,0,0), False) if self.state=='down' else app.request_stage_stop() #disabled: app.stage.connection is None Label: text: 'X' @@ -251,11 +250,11 @@ MainWindow: size_hint: (0.2,1) StageButtonRight: id: sbl3 - on_state: app.stage.start_move((app.vlow,0,0), app.unit) if self.state=='down' else app.stage.stop() + on_state: app.jog((1,0,0), False) if self.state=='down' else app.request_stage_stop() #disabled: app.stage.connection is None StageButtonRightFast: - id: sbl4 - on_state: app.stage.start_move((app.vhigh,0,0), app.unit) if self.state=='down' else app.stage.stop() + id: sbl4 + on_state: app.jog((1,0,0), True) if self.state=='down' else app.request_stage_stop() #disabled: app.stage.connection is None : @@ -263,14 +262,14 @@ MainWindow: pos: root.pos size: root.size orientation: 'horizontal' - + StageButtonLeftFast: - id: sbl1 - on_state: app.stage.start_move((0,-app.vhigh,0), app.unit) if self.state=='down' else app.stage.stop() + id: sbl1 + on_state: app.jog((0,-1,0), True) if self.state=='down' else app.request_stage_stop() #disabled: app.stage.connection is None StageButtonLeft: id: sbl2 - on_state: app.stage.start_move((0,-app.vlow,0), app.unit) if self.state=='down' else app.stage.stop() + on_state: app.jog((0,-1,0), False) if self.state=='down' else app.request_stage_stop() #disabled: app.stage.connection is None Label: text: 'Y' @@ -279,27 +278,27 @@ MainWindow: size_hint: (0.2,1) StageButtonRight: id: sbl3 - on_state: app.stage.start_move((0,app.vlow,0), app.unit) if self.state=='down' else app.stage.stop() + on_state: app.jog((0,1,0), False) if self.state=='down' else app.request_stage_stop() #disabled: app.stage.connection is None StageButtonRightFast: id: sbl4 - on_state: app.stage.start_move((0,app.vhigh,0), app.unit) if self.state=='down' else app.stage.stop() + on_state: app.jog((0,1,0), True) if self.state=='down' else app.request_stage_stop() #disabled: app.stage.connection is None - - + + : BoxLayout: pos: root.pos size: root.size orientation: 'horizontal' - - StageButtonLeftFast: + + StageButtonLeftFast: id: sbl1 - on_state: app.stage.start_move((0,0,-app.vhigh), app.unit) if self.state=='down' else app.stage.stop() + on_state: app.jog((0,0,-1), True) if self.state=='down' else app.request_stage_stop() #disabled: app.stage.connection is None StageButtonLeft: id: sbl2 - on_state: app.stage.start_move((0,0,-app.vlow), app.unit) if self.state=='down' else app.stage.stop() + on_state: app.jog((0,0,-1), False) if self.state=='down' else app.request_stage_stop() #disabled: app.stage.connection is None Label: text: 'Z' @@ -307,12 +306,285 @@ MainWindow: size_hint: (0.2,1) StageButtonRight: id: sbl3 - on_state: app.stage.start_move((0,0,app.vlow), app.unit) if self.state=='down' else app.stage.stop() + on_state: app.jog((0,0,1), False) if self.state=='down' else app.request_stage_stop() #disabled: app.stage.connection is None - StageButtonRightFast: - id: sbl4 - on_state: app.stage.start_move((0,0,app.vhigh), app.unit) if self.state=='down' else app.stage.stop() + StageButtonRightFast: + id: sbl4 + on_state: app.jog((0,0,1), True) if self.state=='down' else app.request_stage_stop() #disabled: app.stage.connection is None + +: + orientation: 'horizontal' + size_hint_y: None + height: 40 + spacing: 5 + TextInput: + id: gotox + input_filter: 'float' + multiline: False + hint_text: 'X' + on_focus: app.toggle_key_binding(self.focus) + TextInput: + id: gotoy + input_filter: 'float' + multiline: False + hint_text: 'Y' + on_focus: app.toggle_key_binding(self.focus) + TextInput: + id: gotoz + input_filter: 'float' + multiline: False + hint_text: 'Z' + on_focus: app.toggle_key_binding(self.focus) + Button: + text: 'Fill' + on_release: root.prefill() + Button: + text: 'Go' + on_release: root.go_to() + + +: + orientation: 'horizontal' + padding: 10 + spacing: 10 + BoxLayout: + orientation: 'vertical' + size_hint_x: 0.35 + spacing: 5 + MyLabel: + text: 'Plate Region' + size_hint_y: None + height: 30 + BoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: 30 + spacing: 5 + Button: + text: 'Capture point' + on_release: root.capture_points() + Label: + id: countlabel + text: 'Points: {}'.format(len(root.points)) + size_hint_x: 0.3 + BoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: 30 + spacing: 5 + Button: + text: 'Calculate' + disabled: len(root.points) < 3 + on_release: root.calculate() + Button: + text: 'Reset' + on_release: root.reset() + BoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: 30 + spacing: 5 + TextInput: + id: pointstext + multiline: False + hint_text: 'x,y; x,y; x,y' + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.set_points_from_text(self.text) + Button: + text: 'Set' + size_hint_x: 0.3 + on_release: root.set_points_from_text(pointstext.text) + BoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: 30 + spacing: 5 + TextInput: + id: scenarioname + multiline: False + hint_text: 'scenario name' + on_focus: app.toggle_key_binding(self.focus) + Button: + text: 'Save' + size_hint_x: 0.3 + on_release: root.save_scenario(scenarioname.text) + BoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: 30 + spacing: 5 + Spinner: + id: scenariospinner + text: 'load scenario' + values: root.saved_scenarios + Button: + text: 'Load' + size_hint_x: 0.3 + on_release: root.load_scenario(scenariospinner.text) + Label: + id: resultlabel + text: 'Diameter: - Center: -' + size_hint_y: None + height: 30 + MyLabel: + text: 'Scan Settings' + size_hint_y: None + height: 30 + ScrollView: + do_scroll_x: False + GridLayout: + cols: 2 + size_hint_y: None + height: self.minimum_height + row_default_height: 34 + row_force_default: True + spacing: 8 + Label: + text: 'Scan exposure (us)' + TextInput: + text: str(root.scan_exposure) + input_filter: 'float' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.scan_exposure = float(self.text) + Label: + text: 'Z axis (mm)' + TextInput: + text: str(root.scan_z) + input_filter: 'float' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.scan_z = float(self.text) + Label: + text: 'Scan gain' + TextInput: + text: str(root.scan_gain) + input_filter: 'float' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.scan_gain = float(self.text) + Label: + text: 'Track exposure (us)' + TextInput: + text: str(root.track_exposure) + input_filter: 'float' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.track_exposure = float(self.text) + Label: + text: 'Track gain' + TextInput: + text: str(root.track_gain) + input_filter: 'float' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.track_gain = float(self.text) + Label: + text: 'Track framerate (fps)' + TextInput: + text: str(root.track_framerate) + input_filter: 'float' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.track_framerate = float(self.text) + Label: + text: 'Track timeout (s)' + TextInput: + text: str(root.track_interval) + input_filter: 'float' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.track_interval = float(self.text) + Label: + text: 'Settle (s)' + TextInput: + text: str(root.scan_settle) + input_filter: 'float' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.scan_settle = float(self.text) + Label: + text: 'Brightness threshold' + TextInput: + text: str(root.scan_threshold) + input_filter: 'float' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.scan_threshold = float(self.text) + Label: + text: 'Min bright pixels' + TextInput: + text: str(root.scan_min_pixels) + input_filter: 'int' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.scan_min_pixels = int(self.text) + Label: + text: 'Tile overlap W (%)' + TextInput: + text: str(root.scan_overlap_w) + input_filter: 'float' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.scan_overlap_w = float(self.text) + Label: + text: 'Tile overlap H (%)' + TextInput: + text: str(root.scan_overlap_h) + input_filter: 'float' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.scan_overlap_h = float(self.text) + Label: + text: 'Z sweep range (mm)' + TextInput: + text: str(root.scan_z_range) + input_filter: 'float' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.scan_z_range = float(self.text) + Label: + text: 'Z sweep frames' + TextInput: + text: str(root.scan_z_frames) + input_filter: 'int' + multiline: False + on_focus: app.toggle_key_binding(self.focus) + on_text_validate: root.scan_z_frames = int(self.text) + BoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: 40 + spacing: 5 + Button: + text: 'Live' + on_release: root.toggle_preview() + Button: + text: 'Scan' + on_release: root.scan_area() + Button: + text: 'Stop' + on_release: root.stop_scan() + ProgressBar: + max: 1 + value: root.scan_progress + size_hint_y: None + height: 20 + BoxLayout: + orientation: 'vertical' + size_hint_x: 0.65 + spacing: 5 + Image: + id: scanpreview + size_hint_y: 0.6 + fit_mode: 'contain' + texture: app.texture + ScanMinimap: + id: minimap + size_hint_y: 0.4 + + ################################# # Load camera properties - Popup ################################# @@ -336,7 +608,7 @@ MainWindow: Button: text: "Load" on_release: root.load(filechooser2.path, filechooser2.selection) - + ################################# # save location experiment - Popup ################################# @@ -368,20 +640,20 @@ MainWindow: Button: text: "Save" - on_release: root.save(filechooser.path, text_input.text) - + on_release: root.save(filechooser.path, text_input.text) + ################################# # autofocus - Popup ################################# : id: autofocus orientation: 'vertical' - + GridLayout: id: multipleimages cols: root.nsteps//2#app.config.getint('Autofocus', 'nsteps')//2 spacing: 30,30 - + BoxLayout: size_hint_y: 0.05 Label: @@ -405,7 +677,7 @@ MainWindow: Label: text: str(root.stepunits) multiline: False - + BoxLayout: size_hint_y: None height: 30 @@ -415,7 +687,7 @@ MainWindow: Button: text: "Run Autofocus" - on_release: root.run_autofocus() + on_release: root.run_autofocus() ################################# # Middle column @@ -428,7 +700,7 @@ MainWindow: pos: root.pos size_hint: (0.6,1) orientation: 'vertical' - + StencilFloatLayout: id: stencil size_hint: (1, 0.75) @@ -475,7 +747,7 @@ MainWindow: pos: stencil.pos disabled: app.config.getboolean('LiveAnalysis', 'showliveanalysis') is False opacity: 1 if app.config.getboolean('LiveAnalysis', 'showliveanalysis') else 0 - + GridLayout: cols: 2 @@ -497,7 +769,7 @@ MainWindow: id: pixelvalue text: 'x:0, y:0, intensity:0' halign: 'right' - + RuntimeControls: id:runtimecontrols size_hint: (1, 0.2) @@ -511,7 +783,7 @@ MainWindow: Line: circle: self.circle id: circle - + ### toggle tracking or autofocus : @@ -541,7 +813,7 @@ MainWindow: CheckBox: id: livefocuscheckbox on_state: root.startLiveFocus() if self.state=='down' else root.stopLiveFocus() - + MyLabel: text: 'Tracking' color: '#ffffff' @@ -569,7 +841,7 @@ MainWindow: DualColorViewModeQuickButtonLayout: id: dualcolorviewmodequickbuttonlayout padding: [dp(5), dp(5), dp(5), 0] - + ### LiveView, Record, Snap : @@ -579,35 +851,35 @@ MainWindow: recordbutton: recordbutton liveviewbutton: liveviewbutton snapbutton: snapbutton - + LiveViewButton: id: liveviewbutton border: 0,0,0,0 size_hint_x: 0.1 size_hint_y: None - height: self.width + height: self.width pos_hint: {'center_x': .5, 'center_y': .5} background_normal: 'icons/play_off.png' background_down: 'icons/play_on.png' background_disabled_normal: 'icons/play_disabled.png' background_disabled_down: 'icons/play_disabled.png' - RecordButton: + RecordButton: id: recordbutton border: 0,0,0,0 size_hint_x: 0.1 size_hint_y: None - height: self.width + height: self.width pos_hint: {'center_x': .5, 'center_y': .5} background_normal: 'icons/record_inactive.png' background_down: 'icons/record_active.png' - - Button: + + Button: id: snapbutton border: 0,0,0,0 size_hint_x: 0.1 size_hint_y: None - height: self.width + height: self.width pos_hint: {'center_x': .5, 'center_y': .5} background_normal: 'icons/snap_inactive.png' background_down: 'icons/snap_active.png' @@ -633,7 +905,7 @@ MainWindow: size_hint: (1, 0.1) text: 'Macro' on_release: root.open_macro() - MyButton: + MyButton: id:recording size_hint: (1, 0.1) text: 'Recording' @@ -642,6 +914,10 @@ MainWindow: size_hint: (1, 0.1) text: 'Calibrate' on_release: root.show_calibration() + MyButton: + size_hint: (1, 0.1) + text: 'Plate Scan' + on_release: root.show_scan() MyButton: size_hint: (1, 0.1) text: 'DAQ' @@ -650,16 +926,16 @@ MainWindow: text: 'Hardware' size_hint: (1, 0.1) Connections: - id: connections + id: connections size_hint: (1,0.2) - + ### Popup recording Settings : size: root.size pos: root.pos orientation: 'vertical' - Label: + Label: text: " Press Enter after edited Duration or Framerate to update it's counterpart value." text_size: root.width, None size: self.texture_size @@ -675,17 +951,17 @@ MainWindow: padding: [dp(10), dp(5), dp(10), dp(10)] spacing: [0, dp(15)] - Label: + Label: text: 'Framerate (1/s)' Label: text: str(root.framerate) - Label: + Label: text: 'Continuous Recording' ContinuousSwitch: id: continuousswitch - - Label: + + Label: text: 'Duration (sec)' TextInput: id: duration @@ -695,8 +971,8 @@ MainWindow: on_text_validate: root.duration = self.text on_text_validate: root.nframes = int(float(self.text)*root.framerate) disabled: app.config.getboolean('Experiment', 'iscontinuous') - - Label: + + Label: text: 'Frames' TextInput: id: frames @@ -707,7 +983,7 @@ MainWindow: on_text_validate: root.duration = float(self.text)/root.framerate disabled: app.config.getboolean('Experiment', 'iscontinuous') - Label: + Label: text: 'Extension' TextInput: id: fileformat @@ -716,7 +992,7 @@ MainWindow: halign: 'center' on_text_validate: root.fileformat = self.text - + BoxLayout: size_hint_y: None height: dp(30) @@ -739,7 +1015,7 @@ MainWindow: : size: root.size pos: root.pos - + orientation: 'vertical' BoxLayout: @@ -750,7 +1026,7 @@ MainWindow: id: macroscriptfile text: app.config.get('MacroScript', 'recentscript') size_hint_x: 0.8 - + Button: text: "Load" on_release: root.openLoadMacroScriptWidget() @@ -770,15 +1046,15 @@ MainWindow: size_hint_x: 0.9 do_wrap: False auto_indent: True - + BoxLayout: orientation: 'vertical' size_hint_x: None - + BoxLayout: # Filler size_hint_y: 0.05 - + Button: id: runbutton text: "[b]Run[/b]" @@ -786,7 +1062,7 @@ MainWindow: on_release: root.runMacroScript() size_hint_y: 0.2 background_color: [94/255, 190/255, 88/255, 1] - + BoxLayout: # Filler size_hint_y: 0.05 @@ -797,11 +1073,11 @@ MainWindow: on_release: root.stopMacroScript() size_hint_y: 0.2 background_color: [226/255, 54/255, 18/255, 1] - + BoxLayout: # Filler size_hint_y: 0.3 - + Button: text: "Wiki" background_color: (0, 0, 0, 0) # Transparent background @@ -809,17 +1085,17 @@ MainWindow: underline: True size_hint_y: 0.1 - on_release: - # importing webbrowser module - import webbrowser - # it will open google window in your browser - webbrowser.open('https://scholz-lab.github.io/GlowTracker/software/macro_script.html') - + on_release: + # importing webbrowser module + import webbrowser + # it will open google window in your browser + webbrowser.open('https://scholz-lab.github.io/GlowTracker/software/macro_script.html') + Button: text: "Close" on_release: root.closeCallback() size_hint_y: 0.1 - + : BoxLayout: @@ -850,7 +1126,7 @@ MainWindow: pos: root.pos do_default_tab: False tab_width: 260 - + TabbedPanelItem: text: 'Camera & Stage Calibration' @@ -859,16 +1135,22 @@ MainWindow: TabbedPanelItem: text: 'Dual Color Calibration' - + DualColorCalibration: id: dualcolorcalibration - + TabbedPanelItem: text: 'Depth of Field Calibration' DepthOfFieldCalibration: id: depthoffieldcalibration + TabbedPanelItem: + text: 'Intensity Sweep' + + IntensitySweepCalibration: + id: intensitysweepcalibration + : size: root.size @@ -887,7 +1169,7 @@ MainWindow: size_hint_y: 0.1 Image: id: fixedimage - + BoxLayout: orientation: 'vertical' Label: @@ -895,7 +1177,7 @@ MainWindow: size_hint_y: 0.1 Image: id: movingimagex - + BoxLayout: orientation: 'vertical' Label: @@ -903,7 +1185,7 @@ MainWindow: size_hint_y: 0.1 Image: id: movingimagey - + BoxLayout: orientation: 'vertical' Label: @@ -911,7 +1193,7 @@ MainWindow: size_hint_y: 0.1 Image: id: cameraandstageaxes - + BoxLayout: size_hint_y: .08 @@ -938,11 +1220,11 @@ MainWindow: pos: root.pos orientation: 'vertical' spacing: 20 - + GridLayout: cols: 3 spacing: 10 - + BoxLayout: orientation: 'vertical' Label: @@ -950,7 +1232,7 @@ MainWindow: size_hint_y: 0.1 Image: id: mainsideimage - + BoxLayout: orientation: 'vertical' Label: @@ -958,7 +1240,7 @@ MainWindow: size_hint_y: 0.1 Image: id: minorsideimage - + BoxLayout: orientation: 'vertical' Label: @@ -966,10 +1248,10 @@ MainWindow: size_hint_y: 0.1 Image: id: calibratedimage - + BoxLayout: size_hint_y: .08 - + Label: id: translation size_hint_x: .25 @@ -1015,7 +1297,7 @@ MainWindow: size_hint_y: 0.1 Image: id: estimateddofplot - + BoxLayout: size_hint_y: .08 @@ -1033,6 +1315,58 @@ MainWindow: on_release: root.closeCallback() +: + size: root.size + pos: root.pos + orientation: 'vertical' + spacing: 20 + + BoxLayout: + orientation: 'vertical' + Label: + text: 'Mean intensity over Z sweep' + size_hint_y: 0.1 + Image: + id: intensitysweepplot + + BoxLayout: + size_hint_y: .08 + spacing: 10 + + Label: + text: 'Z start (mm)' + size_hint_x: .1 + TextInput: + id: zstart + text: '0' + multiline: False + size_hint_x: .12 + Label: + text: 'Z end (mm)' + size_hint_x: .1 + TextInput: + id: zend + text: '1' + multiline: False + size_hint_x: .12 + Label: + text: 'Num photos' + size_hint_x: .1 + TextInput: + id: numphotos + text: '50' + multiline: False + size_hint_x: .12 + Button: + text: "Sweep" + size_hint_x: .18 + on_release: root.sweep() + Button: + text: "Close" + size_hint_x: .14 + on_release: root.closeCallback() + + : mode: mode @@ -1073,7 +1407,7 @@ MainWindow: pos: root.pos do_default_tab: False tab_width: 220 - + TabbedPanelItem: text: 'Sequencer' @@ -1082,13 +1416,13 @@ MainWindow: TabbedPanelItem: text: 'Stage program' - + StageProgramWidget: id: stageprogramwidget - + TabbedPanelItem: text: 'Reversal' - + ReversalWidget: id: reversalwidget @@ -1096,7 +1430,7 @@ MainWindow: : size: root.size pos: root.pos - + orientation: 'vertical' BoxLayout: @@ -1107,7 +1441,7 @@ MainWindow: id: daqscriptfile text: app.config.get('DaqControl', 'sequencescript') size_hint_x: 0.8 - + Button: text: "Load" on_release: root.openLoadSequencerWidget() @@ -1127,7 +1461,7 @@ MainWindow: size_hint_x: 0.9 do_wrap: False auto_indent: True - + BoxLayout: orientation: 'vertical' size_hint_x: None @@ -1194,7 +1528,7 @@ MainWindow: orientation: 'tb-lr' size_hint_x: 0.5 spacing: [0, dp(5)] - + BoxLayout: orientation: 'horizontal' size_hint_y: 0.1 @@ -1205,7 +1539,7 @@ MainWindow: valign: 'middle' size_hint_x: None text_size: [dp(100), root.height] - + Spinner: id: modeSpinner text: 'Gaussian' @@ -1213,7 +1547,7 @@ MainWindow: on_text: root.updateMode(); root.updateParam() size_hint_x: None width: dp(100) - + BoxLayout: id: exterior_layout orientation: 'horizontal' @@ -1226,7 +1560,7 @@ MainWindow: valign: 'middle' size_hint_x: None text_size: [dp(100), root.height] - + Spinner: id: exteriorSpinner text: 'Zero' @@ -1234,7 +1568,7 @@ MainWindow: on_text: root.updateExteriorChoice(); root.updateParam() size_hint_x: None width: dp(100) - + DaqTextInput: id: constanttextinput on_kv_post: root.updateExteriorChoice() @@ -1254,10 +1588,10 @@ MainWindow: Label: text: 'x (mm)' - + Label: text: 'y (mm)' - + Label: text: 'Voltage' @@ -1278,12 +1612,12 @@ MainWindow: id: p1x configKey: 'p1x' root: root - + DaqTextInput: id: p1y configKey: 'p1y' root: root - + DaqTextInput: id: p1v configKey: 'p1v' @@ -1306,17 +1640,17 @@ MainWindow: id: p2x configKey: 'p2x' root: root - + DaqTextInput: id: p2y configKey: 'p2y' root: root - + DaqTextInput: id: p2v configKey: 'p2v' root: root - + BoxLayout: id: p3_layout orientation: 'horizontal' @@ -1334,12 +1668,12 @@ MainWindow: id: p3x configKey: 'p3x' root: root - + DaqTextInput: id: p3y configKey: 'p3y' root: root - + DaqTextInput: id: p3v configKey: 'p3v' @@ -1362,17 +1696,17 @@ MainWindow: id: p4x configKey: 'p4x' root: root - + DaqTextInput: id: p4y configKey: 'p4y' root: root - + DaqTextInput: id: p4v configKey: 'p4v' root: root - + BoxLayout: id: relative_layout orientation: 'horizontal' @@ -1390,7 +1724,7 @@ MainWindow: halign: 'left' valign: 'middle' text_size: self.size - + AnchorLayout: anchor_x: 'right' anchor_y: 'center' @@ -1401,7 +1735,7 @@ MainWindow: id: relative configKey: 'relative' root: root - + BoxLayout: id: g_amplitude_layout orientation: 'horizontal' @@ -1419,7 +1753,7 @@ MainWindow: id: g_amplitude configKey: 'g_amplitude' root: root - + BoxLayout: id: g_x_mean_layout orientation: 'horizontal' @@ -1437,7 +1771,7 @@ MainWindow: id: g_x_mean configKey: 'g_x_mean' root: root - + BoxLayout: id: g_x_sigma_layout orientation: 'horizontal' @@ -1455,7 +1789,7 @@ MainWindow: id: g_x_sigma configKey: 'g_x_sigma' root: root - + BoxLayout: id: g_y_mean_layout @@ -1474,8 +1808,8 @@ MainWindow: id: g_y_mean configKey: 'g_y_mean' root: root - - + + BoxLayout: id: g_y_sigma_layout orientation: 'horizontal' @@ -1493,7 +1827,7 @@ MainWindow: id: g_y_sigma configKey: 'g_y_sigma' root: root - + BoxLayout: id: g_relative_layout orientation: 'horizontal' @@ -1511,7 +1845,7 @@ MainWindow: halign: 'left' valign: 'middle' text_size: self.size - + AnchorLayout: anchor_x: 'right' anchor_y: 'center' @@ -1522,7 +1856,7 @@ MainWindow: id: g_relative configKey: 'g_relative' root: root - + BoxLayout: orientation: 'vertical' size_hint_x: 0.5 @@ -1531,7 +1865,7 @@ MainWindow: size_hint_y: 0.1 Image: id: visualizationplot - + BoxLayout: size_hint_y: .08 @@ -1541,13 +1875,13 @@ MainWindow: AnchorLayout: anchor_x: 'center' anchor_y: 'center' - + Button: size_hint_x: 0.5 text: "Update" on_release: root.updateParam() background_color: [94.0/255, 190.0/255, 88.0/255, 1] - + BoxLayout: size_hint_x: .3 @@ -1596,7 +1930,7 @@ MainWindow: halign: 'left' valign: 'middle' text_size: self.size - + AnchorLayout: anchor_x: 'right' anchor_y: 'center' @@ -1607,7 +1941,7 @@ MainWindow: id: showtrail configKey: 'showtrail' root: root - + BoxLayout: id: relative_layout orientation: 'horizontal' @@ -1625,7 +1959,7 @@ MainWindow: halign: 'left' valign: 'middle' text_size: self.size - + AnchorLayout: anchor_x: 'right' anchor_y: 'center' @@ -1655,7 +1989,7 @@ MainWindow: configKey: 'animallength' root: root size_hint_x: 0.6 - + BoxLayout: id: traillimit_layout orientation: 'horizontal' @@ -1674,7 +2008,7 @@ MainWindow: configKey: 'traillimit' root: root size_hint_x: 0.6 - + BoxLayout: id: reversalthresholdradian_layout orientation: 'horizontal' @@ -1682,7 +2016,7 @@ MainWindow: size_hint_x: 0.9 Label: - text: 'Reversal threshold angle (radian)' + text: 'Reversal threshold angle (degrees)' halign: 'center' valign: 'middle' size_hint_x: 0.4 @@ -1693,7 +2027,7 @@ MainWindow: configKey: 'reversalthresholdradian' root: root size_hint_x: 0.6 - + BoxLayout: id: velocityhistorypercentage_layout orientation: 'horizontal' @@ -1712,7 +2046,7 @@ MainWindow: configKey: 'velocityhistorypercentage' root: root size_hint_x: 0.6 - + BoxLayout: id: reversalvoltage_layout orientation: 'horizontal' @@ -1731,7 +2065,7 @@ MainWindow: configKey: 'reversalvoltage' root: root size_hint_x: 0.6 - + BoxLayout: id: forwardvoltage_layout orientation: 'horizontal' @@ -1750,7 +2084,7 @@ MainWindow: configKey: 'forwardvoltage' root: root size_hint_x: 0.6 - + BoxLayout: id: relative_layout orientation: 'horizontal' @@ -1768,7 +2102,7 @@ MainWindow: halign: 'left' valign: 'middle' text_size: self.size - + AnchorLayout: anchor_x: 'right' anchor_y: 'center' @@ -1779,7 +2113,7 @@ MainWindow: id: showguideline configKey: 'showguideline' root: root - + BoxLayout: orientation: 'vertical' size_hint_x: 0.5 @@ -1803,14 +2137,14 @@ MainWindow: size_hint_x: 1 write_tab: False -### display if hardware is connected successfully +### display if hardware is connected successfully : orientation: 'vertical' size_hint: (0.3, 1) cam_connection: cam_connection stage_connection: stage_connection daq_connection: daq_connection - + Label: text: 'Camera' size_hint: (1,0.33) @@ -1819,7 +2153,7 @@ MainWindow: border: 0,0,0,0 size_hint_x: 0.3 size_hint_y: None - height: self.width + height: self.width pos_hint: {'center_x': .5, 'center_y': .5} background_normal: 'icons/connection_off.png' background_down: 'icons/connection_on.png' @@ -1829,11 +2163,11 @@ MainWindow: text: 'Stage' size_hint: (1,0.33) OnOffButton: - id: stage_connection + id: stage_connection border: 0,0,0,0 size_hint_x: 0.3 size_hint_y: None - height: self.width + height: self.width pos_hint: {'center_x': .5, 'center_y': .5} background_normal: 'icons/connection_off.png' background_down: 'icons/connection_on.png' @@ -1843,16 +2177,16 @@ MainWindow: text: 'DAQ' size_hint: (1,0.33) DAQConnectionButton: - id: daq_connection + id: daq_connection border: 0,0,0,0 size_hint_x: 0.3 size_hint_y: None - height: self.width + height: self.width pos_hint: {'center_x': .5, 'center_y': .5} background_normal: 'icons/connection_off.png' background_down: 'icons/connection_on.png' - - + + ################################# # Layout settings - custom ################################# @@ -1861,7 +2195,7 @@ MainWindow: size_hint: (0.5, 0.5) font_size: 18 halign: 'center' - + : #size: self.texture_size @@ -1869,19 +2203,19 @@ MainWindow: font_size: 18 halign: 'center' color: '#8e0045ff' - - + + : fit_mode: 'contain' source: 'icons/scholz-lab_logo.png' spacing: -10 border: 0,0,0,0 - - + + background_normal: 'icons/connection_off.png' background_down: 'icons/connection_on.png' - + : value: 0 @@ -1902,7 +2236,7 @@ MainWindow: : BoxLayout: orientation: 'vertical' - + Label: text: root.text size_hint_y: 0.9 diff --git a/glowtracker/runtime_control.py b/glowtracker/runtime_control.py new file mode 100644 index 0000000..04b0a01 --- /dev/null +++ b/glowtracker/runtime_control.py @@ -0,0 +1,91 @@ +from threading import Event, Lock, Thread, current_thread + + +class ManagedStageMove: + def __init__(self, blocked=True): + self._lock = Lock() + self._cancel = Event() + self._thread = None + self._stage = None + self._blocked = blocked + + @property + def thread(self): + with self._lock: + return self._thread + + def start(self, stage, target, on_success=None, teardown_requested=None): + if teardown_requested is None: + teardown_requested = lambda: False + + with self._lock: + if self._blocked or stage is None or teardown_requested(): + return False + if self._thread is not None and self._thread.is_alive(): + return False + + cancel = Event() + self._cancel = cancel + self._stage = stage + + def move(): + try: + moved = stage.move_abs(target, 'mm', wait_until_idle=True) + if moved and not cancel.is_set() \ + and not teardown_requested() \ + and on_success is not None: + on_success() + finally: + with self._lock: + if self._thread is current_thread(): + self._stage = None + + self._thread = Thread( + target=move, daemon=True, name='GoToMovement' + ) + self._thread.start() + return True + + def request_stop(self, block_new=False): + with self._lock: + if block_new: + self._blocked = True + self._cancel.set() + thread = self._thread + stage = self._stage + if thread is not None and thread.is_alive() and stage is not None: + stage.emergency_stop() + + def wait(self, timeout=None): + with self._lock: + thread = self._thread + if thread is None or thread is current_thread(): + return True + thread.join(timeout) + return not thread.is_alive() + + def is_active(self): + with self._lock: + return self._thread is not None and self._thread.is_alive() + + def allow(self): + with self._lock: + self._blocked = False + + +def controller_velocity(value, fast_speed, slow_speed): + velocity = fast_speed * value / 32767 + if abs(velocity) < slow_speed * 0.01: + return None + return velocity + + +def append_new_focus_values(focus_log, graph_x, graph_y, lock): + with lock: + start = len(graph_y) + end = len(focus_log) + if start >= end: + return 0 + graph_x.extend(range(start, end)) + graph_y.extend(focus_log[start:end]) + return end - start diff --git a/glowtracker/scan.py b/glowtracker/scan.py new file mode 100644 index 0000000..c459d8f --- /dev/null +++ b/glowtracker/scan.py @@ -0,0 +1,597 @@ +from __future__ import annotations + +from kivy.uix.boxlayout import BoxLayout +from kivy.properties import NumericProperty, ListProperty +import os +from threading import Event, Thread, current_thread +import asyncio +import json +import time +from kivy.clock import Clock +from kivy.app import App + +import Microscope_macros as macro +import numpy as np + +class CenterRadiusFromThreePoints(BoxLayout): + points = ListProperty([]) + _stop_scan = False + _stop_all = False + _preview_saved = None + scan_progress = NumericProperty(0) + saved_scenarios = ListProperty([]) + scan_z = NumericProperty(140) + scan_exposure = NumericProperty(100000) + scan_gain = NumericProperty(0) + scan_settle = NumericProperty(0.01) + scan_threshold = NumericProperty(150) + scan_min_pixels = NumericProperty(50) + scan_overlap_w = NumericProperty(10) + scan_overlap_h = NumericProperty(10) + scan_recenter_iters = NumericProperty(3) + scan_center_tol = NumericProperty(15) + scan_z_range = NumericProperty(1.0) + scan_z_frames = NumericProperty(30) + track_exposure = NumericProperty(5000) + track_gain = NumericProperty(0) + track_framerate = NumericProperty(30) + track_interval = NumericProperty(3600) # in seconds + _found = False + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._scan_thread = None + self._plates_thread = None + self._teardown_requested = False + self._run_generation = 0 + + def on_kv_post(self, *args): + self.refresh_scenarios() + + def _scenario_path(self): + return os.path.join(os.path.dirname(__file__), 'settings', 'scan_scenarios.json') + + def _read_scenarios(self): + try: + with open(self._scenario_path()) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + def refresh_scenarios(self): + self.saved_scenarios = sorted(self._read_scenarios().keys()) + + def save_scenario(self, name): + name = name.strip() + if not name: + print('enter a scenario name') + return + app = App.get_running_app() + data = self._read_scenarios() + entry = {'points': [list(p) for p in self.points]} + if app.plateCenter is not None: + entry['center'] = list(app.plateCenter) + entry['radius'] = app.plateRadius + data[name] = entry + path = self._scenario_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w') as f: + json.dump(data, f, indent=2) + self.refresh_scenarios() + + def load_scenario(self, name): + entry = self._read_scenarios().get(name) + if entry is None: + return + self.points = [list(p) for p in entry.get('points', [])] + if len(self.points) >= 3: + self.calculate() + elif 'center' in entry and 'radius' in entry: + app = App.get_running_app() + app.plateCenter = tuple(entry['center']) + app.plateRadius = entry['radius'] + self.ids.resultlabel.text = 'Diameter: {:.2f} mm Center: ({:.2f}, {:.2f})'.format(2 * entry['radius'], *entry['center']) + + def set_points_from_text(self, text): + if not text.strip(): + return + pts = [] + for pair in text.replace('\n', ';').split(';'): + pair = pair.strip() + if not pair: + continue + try: + x, y = (float(v) for v in pair.split(',')) + except ValueError: + print(f'bad point: {pair}') + return + pts.append([x, y]) + self.points = pts + + def capture_points(self): + coords = App.get_running_app().coords + self.points.append(list(coords[:2])) + + def compute_circle(self): + if len(self.points) < 3: + print('not enough points, add at least 3 points') + return None + + pts = np.array(self.points, dtype=float) + A = np.column_stack([pts[:, 0], pts[:, 1], np.ones(len(pts))]) + B = -(pts[:, 0] ** 2 + pts[:, 1] ** 2) + + X, _, rank, _ = np.linalg.lstsq(A, B, rcond=None) + if rank < 3: + return None + + xc, yc = -X[0] / 2, -X[1] / 2 + underRoot = xc ** 2 + yc ** 2 - X[2] + if underRoot <= 0: + return None + + radius = np.sqrt(underRoot) + return (float(xc), float(yc)), float(radius) + + def calculate(self): + app = App.get_running_app() + result = self.compute_circle() + if result is None: + app.plateCenter = None + app.plateRadius = None + self.ids.resultlabel.text = 'Diameter: - Center: -' + return + (xc, yc), radius = result + app.plateCenter = (xc, yc) + app.plateRadius = radius + self.ids.resultlabel.text = \ + 'Diameter: {:.2f} mm Center: ({:.2f}, {:.2f})'.format(2 * radius, xc, yc) + + def reset(self): + app = App.get_running_app() + self.points = [] + app.plateCenter = None + app.plateRadius = None + self.ids.resultlabel.text = 'Diameter: - Center: -' + + def scan_area(self): + if self._scan_thread is not None and self._scan_thread.is_alive(): + return + if self._plates_thread is not None and self._plates_thread.is_alive(): + return + app = App.get_running_app() + if app.camera is None or app.stage is None or getattr(app, '_hardware_teardown', False): + print('camera or stage not connected') + return + self._stop_scan = False + self._stop_all = False + self._teardown_requested = False + self._run_generation += 1 + runGeneration = self._run_generation + + def worker(): + asyncio.set_event_loop(asyncio.new_event_loop()) + found = False + cameraPrepared = False + try: + cameraPrepared = self._begin_scan_camera() + if cameraPrepared and not self._stop_scan: + peakZ = self._find_scan_z() + if peakZ is not None and not self._stop_scan: + found = self._scan(z= peakZ) + if cameraPrepared: + self._end_scan_camera(found) + if found and not self._teardown_requested: + Clock.schedule_once( + lambda dt: self._after_scan_found(runGeneration) + ) + except Exception as e: + print(f'scan failed: {e}') + if cameraPrepared: + try: + self._end_scan_camera(False) + except Exception as restoreError: + print(f'restoring camera after scan failed: {restoreError}') + finally: + asyncio.get_event_loop().close() + self._scan_thread = Thread(target= worker, daemon= True) + self._scan_thread.start() + + def _scan(self, z: float = None) -> bool: + app = App.get_running_app() + if app.stage is None: + print('connect the stage first') + return False + if app.plateCenter is None or app.plateRadius is None: + print('calculate plate region first') + return False + if app.camera is None: + print('connect the camera first') + return False + fov = app.get_fov_mm() + if fov is None: + print('no fov returned') + return False + + threshold = self.scan_threshold + min_pixels = self.scan_min_pixels + settle = self.scan_settle + z = self.scan_z if z is None else z + + tiles = macro.generate_scan_tiles(app.plateCenter, app.plateRadius, *fov, + overlap_w= self.scan_overlap_w / 100.0, + overlap_h= self.scan_overlap_h / 100.0) + tiles = [(x, y) for (x, y) in tiles if app.stage.is_safe(x, y, z)] + if not tiles: + print('no safe tiles to scan at this Z') + return False + + speed_unit = app.config.get('Stage', 'speed_unit') + accel_unit = app.config.get('Stage', 'acceleration_unit') + precise_speed = float(app.config.get('Stage', 'precise_speed')) + precise_accel = float(app.config.get('Stage', 'precise_acceleration')) + scan_speed = float(app.config.get('Stage', 'scan_speed')) + scan_accel = float(app.config.get('Stage', 'scan_acceleration')) + + found = False + try: + scan_pass = 0 + while not self._stop_scan and not self._stop_all: + scan_pass += 1 + Clock.schedule_once(lambda dt: setattr(self, 'scan_progress', 0)) + t_move = t_settle = t_grab = t_detect = t_disp = 0.0 + n_tiles = 0 + pass_start = time.perf_counter() + print(f'scan pass {scan_pass}') + app.stage.set_motion(precise_speed, precise_accel, speed_unit, accel_unit) + for i, (x, y) in enumerate(tiles): + if self._stop_scan or self._stop_all: + break + frac = (i + 1) / len(tiles) + Clock.schedule_once(lambda dt, v=frac: setattr(self, 'scan_progress', v)) + + t0 = time.perf_counter() + moved = app.stage.move_abs((x, y, z), 'mm', wait_until_idle= True) + t1 = time.perf_counter() + pos = app.stage.get_position(unit= 'mm', isAsync= False) + if (not moved) or pos is None \ + or abs(pos[0] - x) > 1.0 or abs(pos[1] - y) > 1.0: + print(f'scan aborted: move did not reach target ({x:.2f}, {y:.2f}), got {pos}') + self._stop_scan = True + break + if i == 0: + app.stage.set_motion(scan_speed, scan_accel, speed_unit, accel_unit) + if self._wait_or_stop(settle): + break + t2 = time.perf_counter() + ok, img = app.camera.singleTake() + t3 = time.perf_counter() + if not ok: + print('failed to capture image, skipping tile') + continue + Clock.schedule_once(lambda dt, im=img: setattr(app, 'image', im)) + t4 = time.perf_counter() + present, offset = macro.detect_worm(img, threshold, min_pixels) + t5 = time.perf_counter() + + t_move += t1 - t0 + t_settle += t2 - t1 + t_grab += t3 - t2 + t_disp += t4 - t3 + t_detect += t5 - t4 + n_tiles += 1 + if present: + print('Found a worm !!') + units = app.config.get('Calibration', 'step_units') + for _ in range(int(self.scan_recenter_iters)): + if self._stop_scan or self._stop_all: + break + dy, dx = macro.getStageDistances( + np.array([-offset[1], offset[0]]), app.imageToStageMat) + if not app.stage.move_rel( + (dx, dy, 0), unit= units, wait_until_idle= True): + self._stop_scan = True + break + if self._wait_or_stop(settle): + break + ok2, img2 = app.camera.singleTake() + if not ok2: + break + Clock.schedule_once(lambda dt, im=img2: setattr(app, 'image', im)) + present2, offset2 = macro.detect_worm(img2, threshold, min_pixels) + if not present2: + break + offset = offset2 + if abs(offset[0]) <= self.scan_center_tol and abs(offset[1]) <= self.scan_center_tol: + break + if self._stop_scan or self._stop_all: + break + app.camera.ExposureTime.Value = float(self.track_exposure) + app.camera.Gain.Value = float(self.track_gain) + app.camera.AcquisitionFrameRateEnable.Value = True + app.camera.AcquisitionFrameRate.Value = float(self.track_framerate) + app.update_coordinates(isAsync= False) + found = True + break + + if n_tiles > 0: + pass_elapsed = time.perf_counter() - pass_start + per = lambda s: s / n_tiles * 1000.0 + print( + f'pass {scan_pass}: move {per(t_move):.0f}ms | ' + f'settle {per(t_settle):.0f}ms | grab {per(t_grab):.0f}ms | ' + f'detect {per(t_detect):.0f}ms | disp {per(t_disp):.0f}ms | ' + f'total {pass_elapsed / n_tiles * 1000.0:.0f}ms/tile | ' + f'{n_tiles / pass_elapsed:.1f} tiles/s ({n_tiles} tiles)' + ) + if found: + break + finally: + if app.stage is not None: + app.stage.set_motion(precise_speed, precise_accel, speed_unit, accel_unit) + return found + + def _wait_or_stop(self, duration): + deadline = time.monotonic() + max(0.0, duration) + while time.monotonic() < deadline: + if self._stop_scan or self._stop_all: + return True + time.sleep(max(0.0, min(0.02, deadline - time.monotonic()))) + return self._stop_scan or self._stop_all + + def _begin_scan_camera(self): + app = App.get_running_app() + if app.camera is None or self._teardown_requested \ + or getattr(app, '_hardware_teardown', False): + return False + mrg = app.root.ids.middlecolumn.ids.runtimecontrols.ids.imageacquisitionmanager + self._cam_saved = { + 'live' : mrg.liveviewbutton.state, + 'exposure' : app.camera.ExposureTime(), + 'gain' : app.camera.Gain(), + 'fr_enable' : app.camera.AcquisitionFrameRateEnable(), + 'fr' : app.camera.AcquisitionFrameRate() + } + Clock.schedule_once(lambda dt: setattr(mrg.liveviewbutton, 'state', 'normal')) + t0 = time.perf_counter() + while app.camera.IsGrabbing() and time.perf_counter() - t0 < 2.0: + if self._stop_scan or self._stop_all or self._teardown_requested: + if not self._teardown_requested \ + and not getattr(app, '_hardware_teardown', False): + Clock.schedule_once( + lambda dt: setattr( + mrg.liveviewbutton, 'state', self._cam_saved['live'] + ) + ) + return False + time.sleep(0.02) + if app.camera.IsGrabbing(): + raise RuntimeError('camera did not stop before scan configuration') + app.camera.AcquisitionFrameRateEnable.Value = False + app.camera.ExposureTime.Value = float(self.scan_exposure) + app.camera.Gain.Value = float(self.scan_gain) + return True + + def _end_scan_camera(self, found): + app = App.get_running_app() + if app.camera is None or self._teardown_requested \ + or getattr(app, '_hardware_teardown', False): + return + mrg = app.root.ids.middlecolumn.ids.runtimecontrols.ids.imageacquisitionmanager + if not found: + app.camera.AcquisitionFrameRate.Value = self._cam_saved['fr'] + app.camera.AcquisitionFrameRateEnable.Value = self._cam_saved['fr_enable'] + app.camera.ExposureTime.Value = self._cam_saved['exposure'] + app.camera.Gain.Value = self._cam_saved['gain'] + Clock.schedule_once(lambda dt: setattr(mrg.liveviewbutton, 'state', self._cam_saved['live'])) + + def _after_scan_found(self, runGeneration=None): + app = App.get_running_app() + if self._stop_scan or self._stop_all or self._teardown_requested \ + or getattr(app, '_hardware_teardown', False) \ + or (runGeneration is not None + and runGeneration != self._run_generation): + return + rc = app.root.ids.middlecolumn.ids.runtimecontrols + mgr = rc.ids.imageacquisitionmanager + mgr.liveviewbutton.state = 'down' + + def _go(dt): + if self._stop_scan or self._stop_all or self._teardown_requested \ + or getattr(app, '_hardware_teardown', False) \ + or (runGeneration is not None + and runGeneration != self._run_generation): + return False + if app.camera is None or not app.camera.IsGrabbing(): + return + h, w = app.image.shape[0], app.image.shape[1] + rc.trackingcheckbox.state = 'down' + rc.startTracking(np.array([w / 2.0, h / 2.0]), track_interval=self.track_interval) + rc.livefocuscheckbox.state = 'down' + return False + + Clock.schedule_interval(_go, 0.1) + + + def stop_scan(self): + self._run_generation += 1 + self._stop_scan = True + app = App.get_running_app() + if app.stage is not None: + app.stage.emergency_stop() + + def _find_scan_z(self, searchDistance= None, numImages= None) -> float | None: + app = App.get_running_app() + stage = app.stage + if app.camera is None or app.stage is None: + print('camera or stage not connected') + return None + + if searchDistance is None: + searchDistance = self.scan_z_range + if numImages is None: + numImages = int(self.scan_z_frames) + + if app.plateCenter is None: + print('no stage center found') + return None + if self._stop_scan or self._stop_all: + return None + if not stage.move_abs( + (app.plateCenter[0], app.plateCenter[1], self.scan_z), + 'mm', wait_until_idle= True): + print('failed to move to the Z-sweep position') + return None + dualColorMode = app.config.getboolean('DualColor', 'dualcolormode') + mainSide = app.config.get('DualColor', 'mainside') + + zStart = self.scan_z - searchDistance / 2 + zEnd = self.scan_z + searchDistance / 2 + + sweeper = macro.IntensitySweeper() + try: + sweeper.sweep( + app.camera, app.stage, zStart, zEnd, numImages, + dualColorMode, mainSide, + stopRequested=lambda: self._stop_scan or self._stop_all + ) + scanZ = sweeper.findScanZ() + except Exception as e: + print(f'z-sweep failed: {e}') + return None + + scanZ = float(scanZ) + Clock.schedule_once(lambda dt, v=scanZ: setattr(self, 'scan_z', v)) + print(f'z-sweep picked scan_z = {scanZ:.4f} mm') + return scanZ + + def run_plates(self, plates= None, record_duration= None): + app = App.get_running_app() + + if self._plates_thread is not None and self._plates_thread.is_alive(): + return + if self._scan_thread is not None and self._scan_thread.is_alive(): + return + if app.camera is None or app.stage is None or getattr(app, '_hardware_teardown', False): + print('camera or stage not connected') + return + + if plates is None: + if app.plateCenter is None or app.plateRadius is None: + print('calculate plate region first') + return + plates = [(list(app.plateCenter), app.plateRadius)] + + if record_duration is None: + record_duration = self.track_interval + + self._stop_all = False + self._stop_scan = False + self._teardown_requested = False + self._run_generation += 1 + runGeneration = self._run_generation + + def orchestrator(): + asyncio.set_event_loop(asyncio.new_event_loop()) + rc = app.root.ids.middlecolumn.ids.runtimecontrols + try: + for center, radius in plates: + if self._stop_all or runGeneration != self._run_generation: + break + + plateReady = Event() + + def setPlate(dt, c=center, r=radius): + if not self._stop_all \ + and runGeneration == self._run_generation: + self._set_plate(c, r) + plateReady.set() + + Clock.schedule_once(setPlate) + while not plateReady.wait(0.05): + if self._stop_all or runGeneration != self._run_generation: + break + if self._stop_all or runGeneration != self._run_generation: + break + found = False + cameraPrepared = self._begin_scan_camera() + if not cameraPrepared: + break + try: + z = self._find_scan_z() + if z is not None and not self._stop_all: + found = self._scan(z) + finally: + self._end_scan_camera(found) + if not found or self._stop_all: + continue + rc._track(record_duration, record= True) + finally: + asyncio.get_event_loop().close() + print('finished plate run') + + self._plates_thread = Thread(target= orchestrator, daemon= True) + self._plates_thread.start() + + def _set_plate(self, center, radius): + app = App.get_running_app() + app.plateCenter = np.array(center, np.float32) + app.plateRadius = radius + + def stop_plates(self): + self._run_generation += 1 + self._stop_all = True + self.stop_scan() + app = App.get_running_app() + rc = app.root.ids.middlecolumn.ids.runtimecontrols + rc.track_done.set() + + def request_shutdown(self): + self._run_generation += 1 + self._teardown_requested = True + active = any( + thread is not None and thread.is_alive() + for thread in (self._scan_thread, self._plates_thread) + ) + self._stop_all = True + self._stop_scan = True + app = App.get_running_app() + rc = app.root.ids.middlecolumn.ids.runtimecontrols + rc.track_done.set() + if active and app.stage is not None: + app.stage.emergency_stop() + + def wait(self, timeout=None): + deadline = None if timeout is None else time.monotonic() + timeout + for thread in (self._scan_thread, self._plates_thread): + if thread is None or thread is current_thread() or not thread.is_alive(): + continue + remaining = None if deadline is None else max(0.0, deadline - time.monotonic()) + thread.join(remaining) + return all( + thread is None or thread is current_thread() or not thread.is_alive() + for thread in (self._scan_thread, self._plates_thread) + ) + + def toggle_preview(self): + app = App.get_running_app() + if app.camera is None: + return + btn = app.root.ids.middlecolumn.ids.runtimecontrols.ids.imageacquisitionmanager.liveviewbutton + if btn.state == 'down': + btn.state = 'normal' + if self._preview_saved is not None: + exp, gain, fr_en, fr = self._preview_saved + app.camera.ExposureTime.Value = exp + app.camera.Gain.Value = gain + app.camera.AcquisitionFrameRate.Value = fr + app.camera.AcquisitionFrameRateEnable.Value = fr_en + self._preview_saved = None + else: + self._preview_saved = ( + app.camera.ExposureTime(), app.camera.Gain(), + app.camera.AcquisitionFrameRateEnable(), app.camera.AcquisitionFrameRate()) + app.camera.AcquisitionFrameRateEnable.Value = False + app.camera.ExposureTime.Value = float(self.scan_exposure) + app.camera.Gain.Value = float(self.scan_gain) + btn.state = 'down' diff --git a/glowtracker/settings/gui_settings.json b/glowtracker/settings/gui_settings.json index 9f11f46..11999f0 100644 --- a/glowtracker/settings/gui_settings.json +++ b/glowtracker/settings/gui_settings.json @@ -9,27 +9,63 @@ "section": "Stage", "key": "port" }, + { + "type": "options", + "title": "Speed unit", + "desc": "Unit for all speed settings below", + "section": "Stage", + "key": "speed_unit", + "options": ["mm/s", "um/s"] + }, + { + "type": "options", + "title": "Acceleration unit", + "desc": "Unit for all acceleration settings below", + "section": "Stage", + "key": "acceleration_unit", + "options": ["mm/s^2", "um/s^2"] + }, { "type": "numeric", - "title": "Stage speed", - "desc": "Set the fast stage speed", + "title": "User input fast speed", + "desc": "Jog speed for buttons, arrow keys and joystick", "section": "Stage", - "key": "vhigh" + "key": "input_fast_speed" }, { "type": "numeric", - "title": "Stage speed", - "desc": "Set the slower stage speed", + "title": "User input fast acceleration", + "desc": "Acceleration for fast jogging", "section": "Stage", - "key": "vlow" + "key": "input_fast_acceleration" }, { - "type": "options", - "title": "Stage speed units", - "desc": "micrometer/sec or millimeter/sec", + "type": "numeric", + "title": "User input slow speed", + "desc": "Jog speed for slow buttons and shift + arrow keys", "section": "Stage", - "key": "speed_unit", - "options": ["mm/s", "um/s"] + "key": "input_slow_speed" + }, + { + "type": "numeric", + "title": "User input slow acceleration", + "desc": "Acceleration for slow jogging", + "section": "Stage", + "key": "input_slow_acceleration" + }, + { + "type": "numeric", + "title": "Precise move speed", + "desc": "Speed for automatic positioning moves (go-to, autofocus sweeps, scan recentering)", + "section": "Stage", + "key": "precise_speed" + }, + { + "type": "numeric", + "title": "Precise move acceleration", + "desc": "Acceleration for automatic positioning moves", + "section": "Stage", + "key": "precise_acceleration" }, { "type": "bool", @@ -61,33 +97,31 @@ }, { "type": "numeric", - "title": "Stage maxspeed", - "desc": "Set the maximum stage speed", + "title": "Scan speed", + "desc": "Stage speed during plate scanning", "section": "Stage", - "key": "maxspeed" + "key": "scan_speed" }, { - "type": "options", - "title": "Stage maxspeed units", - "desc": "micrometer/sec or millimeter/sec", + "type": "numeric", + "title": "Scan acceleration", + "desc": "Stage acceleration during plate scanning", "section": "Stage", - "key": "maxspeed_unit", - "options": ["mm/s", "um/s"] + "key": "scan_acceleration" }, { "type": "numeric", - "title": "Stage acceleration", - "desc": "Set the stage acceleration", + "title": "Tracking speed", + "desc": "Stage speed during tracking", "section": "Stage", - "key": "acceleration" + "key": "track_speed" }, { - "type": "options", - "title": "Stage acceleration units", - "desc": "micrometer/sec^2 or millimeter/sec^2", + "type": "numeric", + "title": "Tracking acceleration", + "desc": "Stage acceleration during tracking", "section": "Stage", - "key": "acceleration_unit", - "options": ["mm/s^2", "um/s^2"] + "key": "track_acceleration" }, { "type": "bool", @@ -190,6 +224,20 @@ "section": "Autofocus", "key": "minstepbeforechangedir" }, + { + "type": "numeric", + "title": "Focus frames per Z", + "desc": "Number of frames averaged at each Z position before the autofocus moves. Higher reduces noise but is slower. Defaults to 5.", + "section": "Autofocus", + "key": "buffer_n" + }, + { + "type": "custom_numeric", + "title": "Coarse step (mm)", + "desc": "Initial Z step (mm) the peak-seeking autofocus climbs with; it halves on overshoot down to the depth of field. Defaults to 0.02.", + "section": "Autofocus", + "key": "coarsestep" + }, { "type": "numeric", "title": "Best focus value", @@ -378,14 +426,14 @@ { "type": "numeric", "title": "Minimum Brightness", - "desc": "Minimum brightness threshold for tracking within integer range [0, 255]. \nPixels with brightness value below this threshold will be set to 0.", + "desc": "Minimum raw camera brightness used for tracking. Pixels below this value are ignored.", "section": "Tracking", "key": "min_brightness" }, { "type": "numeric", "title": "Maximum Brightness", - "desc": "Maximum brightness threshold for tracking within integer range [0, 255]. \nPixels with brightness value heigher than this threshold will be set to 0.", + "desc": "Maximum raw camera brightness used for tracking. Pixels above this value are ignored.", "section": "Tracking", "key": "max_brightness" }, @@ -486,7 +534,7 @@ }, { "type": "numeric", - "title": "Reversal threshold (radian)", + "title": "Reversal threshold (degrees)", "desc": "Reversal angle threshold. The movement is considered reversal when estimated direction is greater or less than +,- threshold.", "section": "DaqControl", "key": "reversalthresholdradian" @@ -531,5 +579,3 @@ "key": "showfps" } ] - - diff --git a/pyproject.toml b/pyproject.toml index 9166d4b..c45a9ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=64.0"] +requires = ["setuptools>=75"] build-backend = "setuptools.build_meta" [tool.setuptools] @@ -10,7 +10,8 @@ where = [ "." ] exclude = [ - "glowtracker.TestFunctions*" + "glowtracker.TestFunctions*", + "tests*" ] [tool.setuptools.package-data] @@ -35,27 +36,33 @@ maintainers = [ ] readme = "README.md" license-files = ["LICENSE.txt"] -requires-python = ">=3.10" +requires-python = ">=3.11,<3.14" dependencies = [ - "kivy>=2.2.0", - "matplotlib", - "numpy", - "opencv-python", - "pillow", - "scikit-image>=0.22.0", - "scipy", - "zaber-motion", - "overrides", - "pandas", - "pypylon>=26.1.0", - "itk-elastix", - "platformdirs", - "pyparsing", - "labjackpython" + "itk-elastix==0.21.0; sys_platform == 'darwin' and platform_machine == 'x86_64'", + "itk-elastix==0.23.0; sys_platform != 'darwin' or platform_machine != 'x86_64'", + "kivy>=2.3.1,<3", + "labjackpython>=2.1,<3", + "matplotlib>=3.9,<4", + "numpy>=2.2,<3", + "opencv-python-headless>=4.10,<5", + "overrides>=7.7,<8", + "pandas>=2.2,<4", + "pillow>=11,<13", + "platformdirs>=4,<5", + "pyparsing>=3.2,<4", + "pypylon>=26.5,<27", + "scikit-image>=0.25,<0.27", + "scipy>=1.15,<2", + "tifffile>=2025.5", + "zaber-motion>=9,<10" ] + classifiers = [ "Intended Audience :: Science/Research", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Bio-Informatics", "Topic :: Scientific/Engineering :: Image Recognition", ] @@ -68,6 +75,11 @@ keywords = [ "Basler" ] +[project.optional-dependencies] +test = [ + "pytest>=8,<10" +] + [project.scripts] glowtracker = "glowtracker:initApp" @@ -76,3 +88,8 @@ Homepage = "https://github.com/scholz-lab/GlowTracker" Issues = "https://github.com/scholz-lab/GlowTracker/issues" Documentation = "https://scholz-lab.github.io/GlowTracker/" Repository = "https://github.com/scholz-lab/GlowTracker.git" + +[tool.pytest.ini_options] +testpaths = [ + "tests" +] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4147a9b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path +import sys +import types + + +os.environ.setdefault('KIVY_NO_ARGS', '1') +os.environ.setdefault('KIVY_NO_CONSOLELOG', '1') +os.environ.setdefault('MPLCONFIGDIR', '/tmp') + +PACKAGE_DIR = Path(__file__).resolve().parents[1] / 'glowtracker' +if str(PACKAGE_DIR) not in sys.path: + sys.path.insert(0, str(PACKAGE_DIR)) + +if importlib.util.find_spec('itk') is None: + sys.modules['itk'] = types.ModuleType('itk') diff --git a/tests/test_autofocus.py b/tests/test_autofocus.py new file mode 100644 index 0000000..cb1cc1c --- /dev/null +++ b/tests/test_autofocus.py @@ -0,0 +1,38 @@ +import math + +import numpy as np + +import AutoFocus as autofocus +from AutoFocus import AutoFocusPID, FocusEstimationMethod + + +def test_smoothing_weights_span_the_configured_range(monkeypatch): + controller = AutoFocusPID( + focusEstimationMethod=FocusEstimationMethod.VarianceOfLaplace, + smoothingWindow=4, + SP=1000.0, + buffer_n=1, + ) + controller.focusLog = [10.0, 20.0, 30.0] + monkeypatch.setattr(autofocus, 'estimateFocus', lambda method, image: 40.0) + controller.executePIDStep(np.zeros((2, 2), dtype=np.uint8), pos=0) + positions = np.arange(4) / 3.0 + weights = controller.WEIHT_MIN + ( + controller.WEIGHT_MAX - controller.WEIHT_MIN + ) * positions + expected = np.average([10.0, 20.0, 30.0, 40.0], weights=weights) + assert math.isclose(controller.focusLog[-1], expected) + + +def test_focus_log_updates_only_when_buffer_is_full(monkeypatch): + controller = AutoFocusPID( + focusEstimationMethod=FocusEstimationMethod.VarianceOfLaplace, + buffer_n=3, + ) + monkeypatch.setattr(autofocus, 'estimateFocus', lambda method, image: 12.0) + image = np.zeros((2, 2), dtype=np.uint8) + assert controller.executePIDStep(image, pos=0) == 0 + assert controller.executePIDStep(image, pos=0) == 0 + assert controller.focusLog == [] + controller.executePIDStep(image, pos=0) + assert controller.focusLog == [12.0] diff --git a/tests/test_camera_control.py b/tests/test_camera_control.py new file mode 100644 index 0000000..0454398 --- /dev/null +++ b/tests/test_camera_control.py @@ -0,0 +1,99 @@ +import numpy as np +from pypylon import pylon + +from Basler_control import Camera, CameraGrabParameters, readPFSFile + + +class GrabResult: + def __init__(self, image, succeeded=True, timestamp=2500000): + self.image = image + self.succeeded = succeeded + self.TimeStamp = timestamp + self.released = False + + @property + def Array(self): + return self.image + + def GrabSucceeded(self): + return self.succeeded + + def Release(self): + self.released = True + self.image.fill(0) + + +class CameraTransport: + def __init__(self, result, grabbing=True): + self.result = result + self.grabbing = grabbing + self.requests = [] + + def IsGrabbing(self): + return self.grabbing + + def RetrieveResult(self, timeout, timeout_handling): + self.requests.append((timeout, timeout_handling)) + return self.result + + +def test_camera_wrapper_uses_real_pypylon_base_class(): + assert issubclass(Camera, pylon.InstantCamera) + parameters = CameraGrabParameters( + bufferSize=4, + grabStrategy=pylon.GrabStrategy_OneByOne, + ) + assert parameters.grabStrategy == pylon.GrabStrategy_OneByOne + + +def test_retrieved_frame_owns_its_memory_after_result_release(): + source = np.arange(12, dtype=np.uint16).reshape(3, 4) + expected = source.copy() + result = GrabResult(source) + transport = CameraTransport(result) + success, image, timestamp, retrieved_at = Camera.retrieveGrabbingResult( + transport + ) + assert success + np.testing.assert_array_equal(image, expected) + assert not np.shares_memory(image, source) + assert result.released + assert timestamp == 2.5 + assert retrieved_at is not None + assert transport.requests == [(1000, pylon.TimeoutHandling_Return)] + + +def test_unsuccessful_grab_is_released_without_returning_an_image(): + result = GrabResult(np.ones((2, 2), dtype=np.uint8), succeeded=False) + transport = CameraTransport(result) + success, image, timestamp, retrieved_at = Camera.retrieveGrabbingResult( + transport + ) + assert not success + assert image is None + assert timestamp is None + assert retrieved_at is None + assert result.released + + +def test_retrieve_is_idle_when_camera_is_not_grabbing(): + result = GrabResult(np.ones((2, 2), dtype=np.uint8)) + transport = CameraTransport(result, grabbing=False) + assert Camera.retrieveGrabbingResult(transport) == ( + False, None, None, None + ) + assert transport.requests == [] + assert not result.released + + +def test_pfs_reader_extracts_feature_values(tmp_path): + path = tmp_path / 'camera.pfs' + path.write_text( + '# camera settings\n' + 'Width\tInteger\t1024\n' + 'PixelFormat\tEnumeration\tMono16\n' + ) + assert readPFSFile(path) == { + 'Width': '1024', + 'PixelFormat': 'Mono16', + } diff --git a/tests/test_daq_parser.py b/tests/test_daq_parser.py new file mode 100644 index 0000000..fc665e9 --- /dev/null +++ b/tests/test_daq_parser.py @@ -0,0 +1,107 @@ +import pytest + +import DAQ_control as DAQ + + +class FakeDaq: + def __init__(self): + self.feedback = [] + + def voltageToDACBits(self, volts, dacNumber, is16Bits): + return dacNumber, volts + + def getFeedback(self, *commands): + self.feedback.append(commands) + + +def test_stage_program_accepts_zero_exterior_constant(): + program = DAQ.DAQStageProgram() + program.update(exteriorConstant=2.5) + program.update(exteriorConstant=0) + assert program.exteriorConstant == 0 + + +def test_frame_script_is_validated_and_sorted(): + control = DAQ.DAQControl() + control.parseTextScript('mode: [frame]\n10: [off]\n2: [on, 4.5]') + assert control.sequencerMode is DAQ.SequencerMode.Frame + assert list(control.sequncerDict.items()) == [ + (2, ['on', 4.5]), + (10, ['off']), + ] + + +def test_time_script_accepts_fractional_triggers(): + control = DAQ.DAQControl() + control.parseTextScript('mode: [time]\n0.25: [on, 1]\n1.5: [off]') + assert control.sequencerMode is DAQ.SequencerMode.Time + assert list(control.sequncerDict) == [0.25, 1.5] + + +def test_invalid_script_does_not_replace_active_sequence(): + control = DAQ.DAQControl() + control.parseTextScript('mode: [frame]\n1: [off]') + with pytest.raises(ValueError): + control.parseTextScript('mode: [frame]\n2: [on, 5]') + assert list(control.sequncerDict.items()) == [(1, ['off'])] + + +@pytest.mark.parametrize('script', [ + "mode: [frame]\n0: [on, __import__('os').getcwd()]", + 'mode: [frame]\n0.5: [off]', + 'mode: [time]\n-1: [off]', + 'mode: [frame]\n0: [on, 5]', + 'mode: [frame]\n0: [off, 1]', + '0: [off]', +]) +def test_invalid_or_executable_scripts_are_rejected(script): + with pytest.raises(ValueError): + DAQ.DAQControl().parseTextScript(script) + + +def test_daq_voltage_state_and_safe_off_cover_both_outputs(monkeypatch): + monkeypatch.setattr(DAQ.u3, 'DAC0_8', lambda value: ('dac0', value)) + monkeypatch.setattr(DAQ.u3, 'DAC1_8', lambda value: ('dac1', value)) + control = DAQ.DAQControl() + control.daq = FakeDaq() + + control._executeCommand(['on', 2.5]) + assert control.currentVoltage == 2.5 + assert control.daq.feedback[-1] == ( + ('dac0', (0, 2.5)), + ('dac1', (1, 2.5)), + ) + + assert control.safe_off() + assert control.currentVoltage == 0 + assert control.daq.feedback[-1] == ( + ('dac0', (0, 0.0)), + ('dac1', (1, 0.0)), + ) + + +def test_reversal_detector_handles_short_and_stationary_trails(): + detector = DAQ.ReversalDetector() + detector.trailLimit = 100 + detector.animalLength_mm = 2.5 + detector.velocityHistoryPercentage = 0 + detector.reversalThresholdRadian = 90 + + assert not detector.detectReversal([]) + assert not detector.detectReversal([[0, 0]]) + assert not detector.detectReversal([[0, 0], [0, 0], [0, 0]]) + + +def test_reversal_detector_distinguishes_forward_and_reverse_motion(): + detector = DAQ.ReversalDetector() + detector.trailLimit = 100 + detector.animalLength_mm = 2.5 + detector.velocityHistoryPercentage = 50 + detector.reversalThresholdRadian = 90 + + assert not detector.detectReversal([ + [0, 0], [1, 0], [2, 0], [3, 0], + ]) + assert detector.detectReversal([ + [0, 0], [1, 0], [2, 0], [3, 0], [2.5, 0], + ]) diff --git a/tests/test_image_saver.py b/tests/test_image_saver.py new file mode 100644 index 0000000..c5f0637 --- /dev/null +++ b/tests/test_image_saver.py @@ -0,0 +1,225 @@ +import ast +from pathlib import Path +from queue import Queue +from threading import Event + +import numpy as np + +import image_saver + + +class CoordinateFile: + def __init__(self): + self.rows = [] + + def write(self, row): + self.rows.append(row) + + +class BlockingCoordinateFile: + def __init__(self): + self.close_started = Event() + self.release_close = Event() + + def close(self): + self.close_started.set() + self.release_close.wait() + + +def test_coordinate_close_returns_after_timeout(): + coordinate_file = BlockingCoordinateFile() + closed, error, thread = image_saver.close_file_with_timeout( + coordinate_file, 0.01 + ) + try: + assert coordinate_file.close_started.is_set() + assert not closed + assert error is None + assert thread.is_alive() + finally: + coordinate_file.release_close.set() + thread.join(1) + + +def test_coordinate_close_reports_errors(): + class FailingCoordinateFile: + def close(self): + raise OSError('flush failed') + + closed, error, thread = image_saver.close_file_with_timeout( + FailingCoordinateFile(), 1 + ) + assert not closed + assert isinstance(error, OSError) + assert not thread.is_alive() + + +def test_split_recording_uses_channel_suffixes(monkeypatch, tmp_path): + written = [] + + def write_tiff(path, image): + written.append((path, image.copy())) + Path(path).write_bytes(b'tiff') + + monkeypatch.setattr(image_saver.tifffile, 'imwrite', write_tiff) + image_queue = Queue() + image_queue.put({'img': np.zeros((2, 2)), 'idx': 3, 'channel': 0}) + image_queue.put({'img': np.ones((2, 2)), 'idx': 4, 'channel': 1}) + image_queue.put({'img': np.full((2, 2), 2), 'idx': 4, 'channel': 2}) + status_queue = Queue() + stopped = Event() + failed = Event() + stopped.set() + + image_saver.save_worker( + image_queue, + tmp_path, + 'basler_{}.tiff', + stopped, + status_queue, + failed, + ) + + assert [Path(path).name for path, _ in written] == [ + 'basler_3.part.tiff', + 'basler_4-main.part.tiff', + 'basler_4-minor.part.tiff', + ] + assert sorted(path.name for path in tmp_path.iterdir()) == [ + 'basler_3.tiff', + 'basler_4-main.tiff', + 'basler_4-minor.tiff', + ] + assert [status_queue.get_nowait()[:3] for _ in range(3)] == [ + ('saved', 3, 0), + ('saved', 4, 1), + ('saved', 4, 2), + ] + assert not failed.is_set() + + +def test_write_failure_is_reported_and_stops_worker(monkeypatch, tmp_path): + attempts = [] + + def fail_write(path, image): + attempts.append(Path(path).name) + raise OSError('disk full') + + monkeypatch.setattr(image_saver.tifffile, 'imwrite', fail_write) + image_queue = Queue() + image_queue.put({'img': np.zeros((2, 2)), 'idx': 8, 'channel': 0}) + image_queue.put({'img': np.zeros((2, 2)), 'idx': 9, 'channel': 0}) + status_queue = Queue() + stopped = Event() + failed = Event() + + image_saver.save_worker( + image_queue, + tmp_path, + 'basler_{}.tiff', + stopped, + status_queue, + failed, + ) + + assert attempts == ['basler_8.part.tiff'] + assert failed.is_set() + status = status_queue.get_nowait() + assert status[:3] == ('failed', 8, 0) + assert 'OSError: disk full' in status[3] + assert list(tmp_path.iterdir()) == [] + + +def test_publish_failure_is_reported_and_removes_partial_file( + monkeypatch, tmp_path): + def write_tiff(path, image): + Path(path).write_bytes(b'partial') + + def fail_replace(source, destination): + raise OSError('rename failed') + + monkeypatch.setattr(image_saver.tifffile, 'imwrite', write_tiff) + monkeypatch.setattr(image_saver.os, 'replace', fail_replace) + image_queue = Queue() + image_queue.put({'img': np.zeros((2, 2)), 'idx': 10, 'channel': 0}) + status_queue = Queue() + stopped = Event() + failed = Event() + + image_saver.save_worker( + image_queue, + tmp_path, + 'basler_{}.tiff', + stopped, + status_queue, + failed, + ) + + assert failed.is_set() + status = status_queue.get_nowait() + assert status[:3] == ('failed', 10, 0) + assert 'OSError: rename failed' in status[3] + assert list(tmp_path.iterdir()) == [] + + +def test_coordinates_wait_for_all_channels_and_preserve_frame_order(): + coordinate_file = CoordinateFile() + acknowledgements = image_saver.SaveAcknowledgements(coordinate_file) + acknowledgements.add(0, 'frame 0\n', (1, 2)) + acknowledgements.add(1, 'frame 1\n', (0,)) + + acknowledgements.saved(1, 0) + acknowledgements.saved(0, 1) + assert coordinate_file.rows == [] + + acknowledgements.saved(0, 2) + assert coordinate_file.rows == ['frame 0\n', 'frame 1\n'] + assert acknowledgements.saved_frames == 2 + assert acknowledgements.failed_frames == 0 + + +def test_failed_frame_has_no_coordinates_but_later_saved_frame_does(): + coordinate_file = CoordinateFile() + acknowledgements = image_saver.SaveAcknowledgements(coordinate_file) + acknowledgements.add(0, 'frame 0\n', (0,)) + acknowledgements.add(1, 'frame 1\n', (0,)) + + acknowledgements.saved(1, 0) + acknowledgements.failed(0) + + assert coordinate_file.rows == ['frame 1\n'] + assert acknowledgements.saved_frames == 1 + assert acknowledgements.failed_frames == 1 + assert acknowledgements.pending_count == 0 + + +def test_recording_coordinates_include_voltage_only_through_acknowledgements(): + source = ( + Path(__file__).resolve().parents[1] + / 'glowtracker' + / 'GlowTracker.py' + ).read_text() + tree = ast.parse(source) + record_button = next( + node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == 'RecordButton' + ) + receive = next( + node for node in record_button.body + if isinstance(node, ast.FunctionDef) + and node.name == 'receiveImageCallback' + ) + called_attributes = { + node.func.attr + for node in ast.walk(receive) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + } + referenced_attributes = { + node.attr for node in ast.walk(receive) + if isinstance(node, ast.Attribute) + } + + assert 'currentVoltage' in referenced_attributes + assert 'add' in called_attributes + assert 'write' not in called_attributes + assert 'percentile_95 daqVol' in source diff --git a/tests/test_image_utils.py b/tests/test_image_utils.py new file mode 100644 index 0000000..8399119 --- /dev/null +++ b/tests/test_image_utils.py @@ -0,0 +1,43 @@ +import numpy as np + +from image_utils import ( + effective_max_brightness, + normalize_image, + prepare_texture_data, +) + + +def test_integer_images_are_normalized_by_dtype_range(): + np.testing.assert_allclose( + normalize_image(np.array([0, 255], dtype=np.uint8)), + [0.0, 1.0], + ) + np.testing.assert_allclose( + normalize_image(np.array([0, 65535], dtype=np.uint16)), + [0.0, 1.0], + ) + + +def test_legacy_8_bit_max_expands_for_wider_unsigned_images(): + image = np.zeros((2, 2), dtype=np.uint16) + assert effective_max_brightness(image, 255) == 65535 + assert effective_max_brightness(image, 4095) == 4095 + + +def test_texture_data_preserves_camera_integer_depth(): + image8 = np.zeros((2, 2), dtype=np.uint8) + image16 = np.zeros((2, 2), dtype=np.uint16) + prepared8, format8 = prepare_texture_data(image8) + prepared16, format16 = prepare_texture_data(image16) + assert prepared8.dtype == np.uint8 + assert format8 == 'ubyte' + assert prepared16.dtype == np.uint16 + assert format16 == 'ushort' + + +def test_unsupported_texture_dtype_is_safely_converted(): + image = np.array([[0.0, 2.0]], dtype=np.float64) + prepared, buffer_format = prepare_texture_data(image) + assert prepared.dtype == np.uint8 + assert prepared.tolist() == [[0, 255]] + assert buffer_format == 'ubyte' diff --git a/tests/test_macro_stop.py b/tests/test_macro_stop.py new file mode 100644 index 0000000..9dc531a --- /dev/null +++ b/tests/test_macro_stop.py @@ -0,0 +1,13 @@ +from threading import Event +import time + +from MacroScript import MacroScriptExecutor + + +def test_macro_wait_is_interruptible(): + finished = Event() + executor = MacroScriptExecutor() + executor.executeScript('wait(5)', finished.set) + time.sleep(0.05) + assert executor.stop(timeout=1.0) + assert finished.wait(0.5) diff --git a/tests/test_runtime_control.py b/tests/test_runtime_control.py new file mode 100644 index 0000000..e89fb0a --- /dev/null +++ b/tests/test_runtime_control.py @@ -0,0 +1,78 @@ +from threading import Event, Lock + +from runtime_control import ( + ManagedStageMove, + append_new_focus_values, + controller_velocity, +) + + +class BlockingStage: + def __init__(self): + self.started = Event() + self.release = Event() + self.moves = [] + self.stops = 0 + + def move_abs(self, target, unit, wait_until_idle): + self.moves.append((target, unit, wait_until_idle)) + self.started.set() + self.release.wait(1) + return True + + def emergency_stop(self): + self.stops += 1 + self.release.set() + + +def test_stage_move_is_single_flight_and_cancellable(): + stage = BlockingStage() + completed = [] + worker = ManagedStageMove(blocked=True) + assert not worker.start(stage, [1, 2, 3]) + worker.allow() + assert worker.start(stage, [1, 2, 3], on_success=lambda: completed.append(1)) + assert stage.started.wait(1) + assert not worker.start(stage, [4, 5, 6]) + worker.request_stop(block_new=True) + assert worker.wait(1) + assert stage.stops == 1 + assert len(stage.moves) == 1 + assert completed == [] + assert not worker.start(stage, [4, 5, 6]) + + +def test_stage_move_runs_success_callback_after_completion(): + stage = BlockingStage() + completed = [] + worker = ManagedStageMove(blocked=False) + assert worker.start(stage, [1, 2, 3], on_success=lambda: completed.append(1)) + assert stage.started.wait(1) + stage.release.set() + assert worker.wait(1) + assert completed == [1] + + +def test_stage_move_does_not_start_during_teardown(): + stage = BlockingStage() + worker = ManagedStageMove(blocked=False) + assert not worker.start(stage, [1, 2, 3], teardown_requested=lambda: True) + assert stage.moves == [] + + +def test_controller_velocity_preserves_direction_and_deadband(): + assert controller_velocity(-32767, 20, 0.5) == -20 + assert controller_velocity(32767, 20, 0.5) == 20 + assert controller_velocity(0, 20, 0.5) is None + + +def test_focus_graph_only_appends_available_values_once(): + graph_x = [] + graph_y = [] + lock = Lock() + assert append_new_focus_values([], graph_x, graph_y, lock) == 0 + assert append_new_focus_values([10, 20], graph_x, graph_y, lock) == 2 + assert append_new_focus_values([10, 20], graph_x, graph_y, lock) == 0 + assert append_new_focus_values([10, 20, 30], graph_x, graph_y, lock) == 1 + assert graph_x == [0, 1, 2] + assert graph_y == [10, 20, 30] diff --git a/tests/test_scan_control.py b/tests/test_scan_control.py new file mode 100644 index 0000000..b9b2f73 --- /dev/null +++ b/tests/test_scan_control.py @@ -0,0 +1,33 @@ +from types import SimpleNamespace + +from scan import CenterRadiusFromThreePoints +import scan + + +def test_failed_z_sweep_does_not_start_tile_scan(monkeypatch): + app = SimpleNamespace( + camera=object(), + stage=object(), + _hardware_teardown=False, + ) + monkeypatch.setattr( + scan.App, + 'get_running_app', + staticmethod(lambda: app), + ) + panel = CenterRadiusFromThreePoints.__new__(CenterRadiusFromThreePoints) + panel._scan_thread = None + panel._plates_thread = None + panel._teardown_requested = False + panel._run_generation = 0 + tile_scans = [] + camera_restore = [] + panel._begin_scan_camera = lambda: True + panel._find_scan_z = lambda: None + panel._scan = lambda z=None: tile_scans.append(z) or True + panel._end_scan_camera = lambda found: camera_restore.append(found) + panel.scan_area() + panel._scan_thread.join(1) + assert not panel._scan_thread.is_alive() + assert tile_scans == [] + assert camera_restore == [False] diff --git a/tests/test_shared_memory.py b/tests/test_shared_memory.py new file mode 100644 index 0000000..9d9960b --- /dev/null +++ b/tests/test_shared_memory.py @@ -0,0 +1,41 @@ +import multiprocessing as mp +from multiprocessing.managers import SharedMemoryManager + +import numpy as np + +from SharedMemory import SharedMemoryQueue + + +def consume_frame(queue, result_queue): + result_queue.put(queue.get()) + + +def test_queue_transfers_frames_to_spawned_process(): + context = mp.get_context('spawn') + manager = SharedMemoryManager(ctx=context) + manager.start() + result_queue = context.Queue() + try: + image = np.arange(12, dtype=np.uint16).reshape(3, 4) + queue = SharedMemoryQueue.create_from_examples( + manager, + {'img': image, 'idx': 0, 'channel': 0}, + buffer_size=2, + context=context, + ) + queue.put({'img': image, 'idx': 7, 'channel': 2}) + process = context.Process( + target=consume_frame, + args=(queue, result_queue), + ) + process.start() + process.join(5) + assert process.exitcode == 0 + result = result_queue.get(timeout=1) + np.testing.assert_array_equal(result['img'], image) + assert result['idx'] == 7 + assert result['channel'] == 2 + assert queue.empty() + finally: + result_queue.close() + manager.shutdown() diff --git a/tests/test_stage_position_poller.py b/tests/test_stage_position_poller.py new file mode 100644 index 0000000..dc5f0fd --- /dev/null +++ b/tests/test_stage_position_poller.py @@ -0,0 +1,267 @@ +import ast +from pathlib import Path +import threading +import time + +import Zaber_control as zaber + + +class FakeAxis: + def __init__(self, position): + self.position = position + self.read_threads = [] + self.velocities = [] + self.stops = 0 + self.no_response_commands = [] + + def get_position(self, unit): + self.read_threads.append(threading.current_thread().name) + return self.position + + def move_velocity(self, velocity, unit): + self.velocities.append(velocity) + + def stop(self, wait_until_idle=False): + self.stops += 1 + + def generic_command_no_response(self, command): + self.no_response_commands.append(command) + + +def make_stage(monkeypatch): + axes = [FakeAxis(10.0), FakeAxis(20.0), FakeAxis(30.0)] + connection = object() + + monkeypatch.setattr( + zaber.Stage, + 'connect_stage', + lambda self, port: connection, + ) + + def assign_axes(self): + self.axis_x, self.axis_y, self.axis_z = axes + self.no_axes = 3 + + monkeypatch.setattr(zaber.Stage, 'assign_axes', assign_axes) + monkeypatch.setattr(zaber.Stage, 'set_maxspeed', lambda self, value, unit: value) + monkeypatch.setattr(zaber.Stage, 'set_accel', lambda self, value, unit: value) + return zaber.Stage('test'), axes + + +def wait_until(predicate, timeout=1.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.005) + return predicate() + + +def test_position_poller_populates_cache_off_the_main_thread(monkeypatch): + monkeypatch.setattr(zaber, 'POSITION_POLL_INTERVAL', 0.01) + stage, axes = make_stage(monkeypatch) + try: + assert stage.start_position_poller() + assert wait_until(lambda: stage.get_cached_position() == [10.0, 20.0, 30.0]) + assert all(axis.read_threads for axis in axes) + assert { + thread_name + for axis in axes + for thread_name in axis.read_threads + } == {'StagePositionPoller'} + finally: + assert stage.stop_position_poller() + + +def test_two_axis_stage_exposes_zero_z_in_reads_and_cache(monkeypatch): + monkeypatch.setattr(zaber, 'POSITION_POLL_INTERVAL', 0.01) + stage, axes = make_stage(monkeypatch) + stage.axis_z = None + stage.no_axes = 2 + + assert stage.get_position(unit='mm', isAsync=False) == [10.0, 20.0, 0.0] + axes[0].read_threads.clear() + axes[1].read_threads.clear() + axes[2].read_threads.clear() + + try: + assert stage.start_position_poller() + assert wait_until(lambda: bool(axes[0].read_threads and axes[1].read_threads)) + assert stage.get_cached_position() == [10.0, 20.0, 0.0] + assert axes[2].read_threads == [] + finally: + assert stage.stop_position_poller() + + +def test_failed_y_jog_position_poll_stops_stage(monkeypatch): + monkeypatch.setattr(zaber, 'JOG_SAFETY_POLL_INTERVAL', 0.01) + stage, axes = make_stage(monkeypatch) + stopped = threading.Event() + stage.state.isMoving_y = True + stage._jog_velocity[1] = -1.0 + + def fail_position(unit): + raise RuntimeError('position read failed') + + axes[0].get_position = fail_position + + def emergency_stop(): + stage.state = zaber.StageState() + stopped.set() + return True + + stage.emergency_stop = emergency_stop + try: + assert stage.start_position_poller() + assert stopped.wait(1.0) + finally: + assert stage.stop_position_poller() + + +def test_y_jog_safety_uses_polled_coordinates(monkeypatch): + monkeypatch.setattr(zaber, 'JOG_SAFETY_POLL_INTERVAL', 0.01) + stage, axes = make_stage(monkeypatch) + axes[1].position = 50.0 + axes[2].position = 130.0 + stage.state.isMoving_y = True + stage._jog_velocity[1] = -1.0 + try: + assert stage.start_position_poller() + assert wait_until(lambda: axes[1].stops == 1) + assert axes[1].no_response_commands == [] + assert not stage.state.isMoving_y + finally: + assert stage.stop_position_poller() + + +def test_x_jog_does_not_start_collision_poller(monkeypatch): + stage, axes = make_stage(monkeypatch) + starts = [] + stage.start_position_poller = lambda: starts.append(True) or True + + assert stage.start_move((1.0, 0.0, 0.0), 'mm/s') + assert starts == [] + assert axes[0].velocities == [1.0] + + stage.stop(zaber.AxisEnum.X) + assert stage.start_move((0.0, -1.0, 0.0), 'mm/s') + assert starts == [True] + assert axes[1].velocities == [-1.0] + + +def test_interactive_stop_and_next_start_do_not_block_caller(monkeypatch): + stage, axes = make_stage(monkeypatch) + read_started = threading.Event() + release_read = threading.Event() + events = [] + + def slow_position(unit): + events.append('x-read') + read_started.set() + release_read.wait(1.0) + return 10.0 + + def y_position(unit): + events.append('y-read') + return 20.0 + + def no_response(command): + events.append(command) + axes[0].no_response_commands.append(command) + + axes[0].get_position = slow_position + axes[0].generic_command_no_response = no_response + axes[1].get_position = y_position + stage.state.isMoving_x = True + + try: + assert stage.start_position_poller() + assert read_started.wait(1.0) + assert stage.request_stop(zaber.AxisEnum.X) + release_read.set() + assert wait_until(lambda: axes[0].no_response_commands == ['stop']) + assert wait_until(lambda: 'y-read' in events) + assert events.index('stop') < events.index('y-read') + assert stage.request_start_move((-1.0, 0.0, 0.0), 'mm/s') + assert stage.request_stop(zaber.AxisEnum.X) + assert wait_until(lambda: axes[0].velocities == [-1.0]) + assert wait_until(lambda: axes[0].no_response_commands == ['stop', 'stop']) + assert axes[0].stops == 0 + assert not stage.state.isMoving_x + finally: + release_read.set() + assert stage.stop_position_poller() + + +def test_ui_coordinate_paths_only_read_the_stage_cache(): + source = ( + Path(__file__).resolve().parents[1] + / 'glowtracker' + / 'GlowTracker.py' + ).read_text() + tree = ast.parse(source) + app_class = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == 'GlowTrackerApp' + ) + + for method_name in ('stage_stop', '_keyup', 'update_coordinates'): + method = next( + node + for node in app_class.body + if isinstance(node, ast.FunctionDef) and node.name == method_name + ) + called_attributes = { + node.func.attr + for node in ast.walk(method) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + } + assert 'get_position' not in called_attributes + assert 'get_cached_position' in called_attributes + + key_up = next( + node + for node in app_class.body + if isinstance(node, ast.FunctionDef) and node.name == '_keyup' + ) + key_up_calls = { + node.func.attr + for node in ast.walk(key_up) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + } + assert 'stop' not in key_up_calls + assert 'request_stage_stop' in key_up_calls + + key_down = next( + node + for node in app_class.body + if isinstance(node, ast.FunctionDef) and node.name == '_keydown' + ) + key_down_calls = { + node.func.attr + for node in ast.walk(key_down) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + } + assert 'start_move' not in key_down_calls + assert 'set_accel' not in key_down_calls + assert 'convert_units' not in key_down_calls + assert 'request_jog' in key_down_calls + + key_down_assignments = { + target.attr + for node in ast.walk(key_down) + if isinstance(node, ast.Assign) + for target in node.targets + if isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == 'self' + } + assert 'coords' not in key_down_assignments + + layout = ( + Path(__file__).resolve().parents[1] + / 'glowtracker' + / 'layout.kv' + ).read_text() + assert 'app.stage.stop()' not in layout diff --git a/tests/test_tracking.py b/tests/test_tracking.py new file mode 100644 index 0000000..cce6613 --- /dev/null +++ b/tests/test_tracking.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import math + +import numpy as np +import pytest + +import Microscope_macros as macro +from Microscope_macros import ( + CameraAndStageCalibrator, + computeAngleBetweenTwo2DVecs, + createRigidTransformationMat, + createScaleAndRotationMatrix, + createTranslationMatrix, + create_mask, + cropCenterImage, + extractWormsCMS, + find_CMS, + getStageDistances, + rotatePointAboutOrig, + swapMatXYOrder, +) + + +# --------------------------------------------------------------------------- +# Synthetic image helpers +# --------------------------------------------------------------------------- + + +def _blob_image( + shape: tuple[int, int], + center: tuple[int, int], + radius: int, + fg: int, + bg: int, + dtype=np.uint8, +) -> np.ndarray: + """Filled disk at ``center`` of intensity ``fg`` on a ``bg`` background.""" + h, w = shape + img = np.full(shape, bg, dtype=dtype) + y, x = np.ogrid[:h, :w] + mask = (y - center[0]) ** 2 + (x - center[1]) ** 2 <= radius**2 + img[mask] = fg + return img + + +# --------------------------------------------------------------------------- +# cropCenterImage +# --------------------------------------------------------------------------- + + +class TestCropCenterImage: + def test_crop_returns_requested_shape(self): + img = np.zeros((100, 100), dtype=np.uint8) + out = cropCenterImage(img, cropWidth=40, cropHeight=40) + assert out.shape == (40, 40) + + def test_crop_preserves_center_pixel(self): + img = np.zeros((100, 100), dtype=np.uint8) + img[50, 50] = 200 + out = cropCenterImage(img, 20, 20) + # The center pixel of the cropped image should be the original center. + assert out[10, 10] == 200 + + def test_crop_zero_returns_full_image(self): + img = np.arange(9).reshape(3, 3).astype(np.uint8) + out = cropCenterImage(img, 0, 0) + np.testing.assert_array_equal(out, img) + + def test_crop_larger_than_image_is_clamped(self): + img = np.zeros((10, 10), dtype=np.uint8) + out = cropCenterImage(img, 1000, 1000) + assert out.shape == (10, 10) + + def test_crop_returns_copy_not_view(self): + img = np.zeros((20, 20), dtype=np.uint8) + out = cropCenterImage(img, 10, 10) + out[0, 0] = 255 + assert img[5, 5] == 0 # would be the same pixel if it were a view + + +# --------------------------------------------------------------------------- +# Geometry helpers +# --------------------------------------------------------------------------- + + +class TestGeometry: + def test_swapMatXYOrder_on_2x2(self): + m = np.array([[1, 2], [3, 4]], dtype=np.float32) + swapped = swapMatXYOrder(m) + np.testing.assert_allclose(swapped, [[4, 3], [2, 1]]) + + def test_swapMatXYOrder_does_not_mutate_input(self): + m = np.array([[1, 2], [3, 4]], dtype=np.float32) + original = m.copy() + _ = swapMatXYOrder(m) + np.testing.assert_array_equal(m, original) + + def test_createTranslationMatrix_shape_and_values(self): + t = createTranslationMatrix(5.0, -3.0) + assert t.shape == (3, 3) + np.testing.assert_allclose(t[:, 2], [5.0, -3.0, 1.0]) + np.testing.assert_allclose(t[:2, :2], np.eye(2)) + + def test_createScaleAndRotationMatrix_identity(self): + m = createScaleAndRotationMatrix(scale=1.0, rotation=0.0, + center_rot_x=0.0, center_rot_y=0.0) + np.testing.assert_allclose(m, np.eye(3), atol=1e-6) + + def test_createScaleAndRotationMatrix_preserves_center_of_rotation(self): + cx, cy = 7.0, -4.0 + m = createScaleAndRotationMatrix(scale=1.0, rotation=1.1, + center_rot_x=cx, center_rot_y=cy) + # Rotating the centre point about itself must return the centre. + p = np.array([cx, cy, 1.0]) + out = m @ p + np.testing.assert_allclose(out[:2], [cx, cy], atol=1e-5) + + def test_createRigidTransformationMat_pure_translation(self): + m = createRigidTransformationMat(1.0, 2.0, rotation=0.0) + np.testing.assert_allclose(m[:, 2], [1.0, 2.0, 1.0]) + np.testing.assert_allclose(m[:2, :2], np.eye(2), atol=1e-6) + + def test_rotatePointAboutOrig_quarter_turn(self): + out = rotatePointAboutOrig(np.array([1.0, 0.0]), math.pi / 2) + np.testing.assert_allclose(out, [0.0, 1.0], atol=1e-6) + + def test_rotatePointAboutOrig_half_turn(self): + out = rotatePointAboutOrig(np.array([1.0, 0.0]), math.pi) + np.testing.assert_allclose(out, [-1.0, 0.0], atol=1e-6) + + def test_computeAngleBetweenTwo2DVecs_orthogonal(self): + theta = computeAngleBetweenTwo2DVecs( + np.array([1.0, 0.0]), np.array([0.0, 1.0]) + ) + assert math.isclose(theta, math.pi / 2, abs_tol=1e-6) + + def test_computeAngleBetweenTwo2DVecs_same_direction(self): + theta = computeAngleBetweenTwo2DVecs( + np.array([3.0, 0.0]), np.array([1.0, 0.0]) + ) + assert math.isclose(theta, 0.0, abs_tol=1e-6) + + def test_computeAngleBetweenTwo2DVecs_signed(self): + # [1,0] -> [0,-1] is -90 deg (cross product negative) + theta = computeAngleBetweenTwo2DVecs( + np.array([1.0, 0.0]), np.array([0.0, -1.0]) + ) + assert math.isclose(theta, -math.pi / 2, abs_tol=1e-6) + + +# --------------------------------------------------------------------------- +# genImageToStageMatrix +# --------------------------------------------------------------------------- + + +class TestGenImageToStageMatrix: + def test_identity_case(self): + # No rotation, +Z normal, pixelSize = 1 -> should be identity apart + # from the y,x swap. + scaled, rot_only = CameraAndStageCalibrator.genImageToStageMatrix( + rotation=0.0, imageNormalDir=+1, pixelSize=1.0 + ) + # Applied to a [dy, dx] pixel vector it should yield the matching + # [dy, dx] stage vector for pure identity. + delta = np.array([3.0, -5.0]) # (y, x) + stage = scaled @ delta + np.testing.assert_allclose(stage, delta, atol=1e-5) + + def test_pixel_size_scales_output(self): + pixel_size = 0.1 # um per pixel, say + scaled, _ = CameraAndStageCalibrator.genImageToStageMatrix( + rotation=0.0, imageNormalDir=+1, pixelSize=pixel_size + ) + delta_pixels = np.array([100.0, 100.0]) + stage = scaled @ delta_pixels + np.testing.assert_allclose( + np.linalg.norm(stage), + pixel_size * np.linalg.norm(delta_pixels), + rtol=1e-5, + ) + + def test_rot_only_matrix_is_orthonormal(self): + _, rot_only = CameraAndStageCalibrator.genImageToStageMatrix( + rotation=0.7, imageNormalDir=+1, pixelSize=3.14 + ) + # Rotation-only matrix must preserve vector length. + v = np.array([4.0, -3.0]) + assert math.isclose( + np.linalg.norm(rot_only @ v), np.linalg.norm(v), abs_tol=1e-5 + ) + + +# --------------------------------------------------------------------------- +# getStageDistances (thin wrapper around matmul, but callers depend on it) +# --------------------------------------------------------------------------- + + +class TestGetStageDistances: + def test_identity_matrix_passthrough(self): + out = getStageDistances(np.array([2.0, 3.0]), np.eye(2)) + np.testing.assert_allclose(out, [2.0, 3.0]) + + def test_respects_rotation(self): + # 90deg rotation in y,x order: (y, x) -> (x, -y) + rot = np.array([[0.0, 1.0], [-1.0, 0.0]]) + out = getStageDistances(np.array([1.0, 0.0]), rot) + np.testing.assert_allclose(out, [0.0, -1.0], atol=1e-6) + + +# --------------------------------------------------------------------------- +# create_mask +# --------------------------------------------------------------------------- + + +class TestCreateMask: + def test_dark_bg_bright_blob_produces_nonempty_mask(self): + img = _blob_image((200, 200), center=(100, 100), radius=30, + fg=230, bg=10) + mask, resize_factor, _ = create_mask(img, dark_bg=True, bin_factor=4) + assert mask.ndim == 2 + assert mask.max() == 255 + # Blob should dominate the foreground. + assert mask.sum() > 0 + assert resize_factor == 0.25 + + def test_bright_bg_dark_blob_produces_nonempty_mask(self): + img = _blob_image((200, 200), center=(100, 100), radius=30, + fg=20, bg=240) + mask, _, _ = create_mask(img, dark_bg=False, bin_factor=4) + assert mask.max() == 255 + assert mask.sum() > 0 + + def test_intermediate_images_returned_when_display(self): + img = _blob_image((120, 120), center=(60, 60), radius=10, + fg=230, bg=10) + _, _, intermediates = create_mask(img, dark_bg=True, + display=True, bin_factor=4) + assert isinstance(intermediates, list) and len(intermediates) >= 2 + + def test_uint16_and_uint8_images_produce_equivalent_masks(self): + y, x = np.ogrid[:200, :200] + disk = ((y - 100) ** 2 + (x - 100) ** 2) <= 25**2 + image8 = np.full((200, 200), 3, dtype=np.uint8) + image8[disk] = 230 + image16 = np.full((200, 200), 200, dtype=np.uint16) + image16[disk] = 60000 + mask8, _, _ = create_mask(image8, dark_bg=True, bin_factor=4) + mask16, _, _ = create_mask(image16, dark_bg=True, bin_factor=4) + assert mask8.astype(bool).mean() < 0.3 + assert abs( + mask16.astype(bool).mean() - mask8.astype(bool).mean() + ) < 0.15 + + +# --------------------------------------------------------------------------- +# find_CMS +# --------------------------------------------------------------------------- + + +class TestFindCMS: + def _mask_with_disks(self, shape, centers, radius): + m = np.zeros(shape, dtype=np.uint8) + y, x = np.ogrid[: shape[0], : shape[1]] + for (cy, cx) in centers: + m[((y - cy) ** 2 + (x - cx) ** 2) <= radius**2] = 255 + return m + + def test_single_blob_near_center(self): + mask = self._mask_with_disks((200, 200), [(100, 100)], radius=10) + x, y = find_CMS(mask) + assert math.isclose(x, 100.0, abs_tol=1.0) + assert math.isclose(y, 100.0, abs_tol=1.0) + + def test_two_blobs_picks_the_one_closest_to_image_center(self): + # Current (documented) behaviour: pick the blob closest to image + # centre, not to the previous centroid. A far and a near blob both + # big enough to survive the top-K filter. + mask = self._mask_with_disks( + (200, 200), [(100, 110), (30, 30)], radius=15 + ) + x, y = find_CMS(mask) + assert math.isclose(y, 100.0, abs_tol=2.0) + assert math.isclose(x, 110.0, abs_tol=2.0) + + def test_empty_mask_raises_value_error(self): + mask = np.zeros((100, 100), dtype=np.uint8) + with pytest.raises(ValueError): + find_CMS(mask) + + def test_fully_saturated_mask_raises_value_error(self): + mask = np.full((100, 100), 255, dtype=np.uint8) + with pytest.raises(ValueError): + find_CMS(mask) + + +# --------------------------------------------------------------------------- +# extractWormsCMS +# --------------------------------------------------------------------------- + + +class TestExtractWormsCMS: + def test_centered_blob_returns_small_offset(self): + img = _blob_image((400, 400), center=(200, 200), radius=20, + fg=230, bg=10) + dy, dx, mask = extractWormsCMS( + img, capture_radius=150, bin_factor=4, dark_bg=True + ) + # The returned (dy, dx) is in original-image pixels of offset from + # the centre of the capture crop. For a centred blob it should be + # ~zero. + assert abs(dy) <= 2 + assert abs(dx) <= 2 + assert mask.ndim == 2 + + def test_offset_blob_returns_correct_sign(self): + # Blob moved 50 px down and 30 px to the right of image centre. + img = _blob_image((400, 400), center=(250, 230), radius=20, + fg=230, bg=10) + dy, dx, _ = extractWormsCMS( + img, capture_radius=150, bin_factor=4, dark_bg=True + ) + # Same sign as the displacement, magnitude of the right order. + assert dy > 10 + assert dx > 10 + + def test_display_mode_returns_diagnostics(self): + image = _blob_image( + (400, 400), center=(200, 200), radius=20, fg=255, bg=0 + ) + try: + dy, dx, intermediates, mask = extractWormsCMS( + image, + capture_radius=100, + bin_factor=4, + dark_bg=True, + display=True, + ) + finally: + macro.plt.close('all') + assert abs(dy) <= 2 + assert abs(dx) <= 2 + assert len(intermediates) >= 2 + assert mask.ndim == 2 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6593363 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1264 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <3.14" +resolution-markers = [ + "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "(python_full_version >= '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "(python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "docutils" +version = "0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" }, +] + +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "glowtracker" +version = "1.0.1" +source = { editable = "." } +dependencies = [ + { name = "itk-elastix", version = "0.21.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "itk-elastix", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "kivy" }, + { name = "labjackpython" }, + { name = "matplotlib" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python-headless" }, + { name = "overrides" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "platformdirs" }, + { name = "pyparsing" }, + { name = "pypylon" }, + { name = "scikit-image" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "tifffile", version = "2026.7.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "zaber-motion" }, +] + +[package.optional-dependencies] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "itk-elastix", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = "==0.23.0" }, + { name = "itk-elastix", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = "==0.21.0" }, + { name = "kivy", specifier = ">=2.3.1,<3" }, + { name = "labjackpython", specifier = ">=2.1,<3" }, + { name = "matplotlib", specifier = ">=3.9,<4" }, + { name = "numpy", specifier = ">=2.2,<3" }, + { name = "opencv-python-headless", specifier = ">=4.10,<5" }, + { name = "overrides", specifier = ">=7.7,<8" }, + { name = "pandas", specifier = ">=2.2,<4" }, + { name = "pillow", specifier = ">=11,<13" }, + { name = "platformdirs", specifier = ">=4,<5" }, + { name = "pyparsing", specifier = ">=3.2,<4" }, + { name = "pypylon", specifier = ">=26.5,<27" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8,<10" }, + { name = "scikit-image", specifier = ">=0.25,<0.27" }, + { name = "scipy", specifier = ">=1.15,<2" }, + { name = "tifffile", specifier = ">=2025.5" }, + { name = "zaber-motion", specifier = ">=9,<10" }, +] +provides-extras = ["test"] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imageio" +version = "2.37.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itk" +version = "5.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk-core" }, + { name = "itk-filtering" }, + { name = "itk-io" }, + { name = "itk-numerics" }, + { name = "itk-registration" }, + { name = "itk-segmentation" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/cc/f94aa5c011f3fe073bfc3827025cf1f42f08a57fc3f1b7de462ddf523da6/itk-5.4.6-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7cfeb9e8205c71f3a8d1bcdf5b40364edf50ab5da83b1ab4aae1c4debed93c47", size = 16784, upload-time = "2026-04-23T01:27:15.771Z" }, + { url = "https://files.pythonhosted.org/packages/19/c2/3d592b6936cd8c31a4e041172f9b26bbeeb3535d450053e385127a4d68d8/itk-5.4.6-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:02e8968d91d7290d2fc792d65678049f1cd3ab416d141de78a533036a79f6dfe", size = 16784, upload-time = "2026-04-23T01:27:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/f1/66/61077d2d2ac7d08d7aafb99494d5224aaa04b9cd18359c6b59468729f575/itk-5.4.6-cp311-abi3-manylinux2014_x86_64.whl", hash = "sha256:c850dd3677a35f5561a536b6a59e8511f87de63cc99eedb7e5c7547bdd119de6", size = 16797, upload-time = "2026-04-23T01:27:17.374Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0d/96a5d04a7705bb29b747216f177d9401c37a0048833349dcf419478bfcd0/itk-5.4.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3fa6f536dad2975109f1ea0592b65c2b8f18bd6385e45f4bbc8c999c3ea93ea9", size = 16797, upload-time = "2026-04-23T01:27:18.539Z" }, + { url = "https://files.pythonhosted.org/packages/b4/cf/67bda7684d810d6f942625a00d9c0b8020964647c6a3b1f075e4a66f44ca/itk-5.4.6-cp311-abi3-win_amd64.whl", hash = "sha256:400346915f140a5b5bd1c34867b1741f78b0d49b5e20abf92cc07177ad655344", size = 16780, upload-time = "2026-04-23T01:27:19.71Z" }, +] + +[[package]] +name = "itk-core" +version = "5.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/63/5c8fcfe61fcf33bff2707aae7d66100252fecead7ec6dd0a7a8765872d86/itk_core-5.4.6-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9bff074d1f389e49d056871dc706959307808810240395514cd5d521a9f97217", size = 71044228, upload-time = "2026-04-23T01:27:44.876Z" }, + { url = "https://files.pythonhosted.org/packages/60/b0/d4c4ff5146a45aad3eac7a231c75c3fe73823e1f4788f8224b46034c810f/itk_core-5.4.6-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:25cef58d04743cc0b6d5e8a8e806682ebc4b2280adba118e0179e2c1334d61c8", size = 60225194, upload-time = "2026-04-23T01:27:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/dd/03/2d3a45a2447cef0100a3a777d7a901828f90f043b6e81fafaa858b47b7f2/itk_core-5.4.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5e8a70cd7ec1e6d3fe4a1726a0155fe40755bea05b44549ac106bfb669c53a8d", size = 83554408, upload-time = "2026-04-23T01:27:52.278Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/0a3499479cb19d7e4b7fc38b2ba15c0ea20e1e88cb2b634b75dd3e9ff8b8/itk_core-5.4.6-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa4f10ff8e481f0fb44724dde29ec7bac691726e076494b1a660f0f9f8daa87b", size = 81433183, upload-time = "2026-04-23T01:27:57.018Z" }, + { url = "https://files.pythonhosted.org/packages/c2/da/2e60b34dc3b35f7097ae32f7971f19eeb41e78803cdda4abb65107426372/itk_core-5.4.6-cp311-abi3-win_amd64.whl", hash = "sha256:65c11a8eeb2f516a14bccced09b5f675b641905d9e5bfd89f12ed5c0921799dd", size = 37559425, upload-time = "2026-04-23T01:28:01.072Z" }, +] + +[[package]] +name = "itk-elastix" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "itk", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/f7/dc3420e4402d60e0e63790a2bbd74a660a56677bff3854bbba2640d3fb97/itk_elastix-0.21.0-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4d49df502af3f2234ca4ee2fa449f1b45ae8c15bfd34318ca956f9e612037163", size = 15661000, upload-time = "2024-08-12T13:02:16.436Z" }, +] + +[[package]] +name = "itk-elastix" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "(python_full_version >= '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "(python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", +] +dependencies = [ + { name = "itk", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/ca/f91ca4c037fac506f45270c3d09aa96ce0feffc0f6ef418cae14d8e7c366/itk_elastix-0.23.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:baaf61a1adcaf2225ccfc2a40a259b82005b37541ec7000070b9c35fdf6ccc8f", size = 13215551, upload-time = "2025-04-15T10:54:10.147Z" }, + { url = "https://files.pythonhosted.org/packages/a1/07/218fa776e4ffa6083cce8acd584c20f01e3a2acff5e0946024039b8bad44/itk_elastix-0.23.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b529f17f8f97c22575dbe9ad5987b2bba47999bbfe8ea391dace018e351d2164", size = 21150061, upload-time = "2025-04-15T10:54:12.557Z" }, + { url = "https://files.pythonhosted.org/packages/54/19/5f8c9ebd49b7cb00bea7012c5469cdf885af232d37777947614a596ebdd5/itk_elastix-0.23.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b37a5a767655089ae6d941ec223e39c96dfbe79a790fdfe8045f91d81cf17244", size = 19548452, upload-time = "2025-04-15T10:54:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/c5/46/a8aab730d8d75ccaf155be543e2e314fcf87fc6272f5abe19f1565f78a20/itk_elastix-0.23.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9b1befecc1d3d8c911913a9eecf946cb977abcc9558bd2e51ae7095e636dcb0c", size = 21382392, upload-time = "2025-04-15T10:54:17.139Z" }, + { url = "https://files.pythonhosted.org/packages/b6/79/7937004d1a2a875bb9360280d5b6922b28af8e671904cd8d94c8e679f374/itk_elastix-0.23.0-cp311-abi3-win_amd64.whl", hash = "sha256:b96120d7402e3550db2a686f3af21034bb60ea67df45185e5f6021b9491ed60d", size = 7749495, upload-time = "2025-04-15T10:54:19.148Z" }, +] + +[[package]] +name = "itk-filtering" +version = "5.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk-numerics" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/89/9db957675018af16dfef13a2a4c5e10d5e8265c552613923be3307bebd47/itk_filtering-5.4.6-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:276e5b47ef79375344c72d5ad768faf75eac7580a600fafd4d39c783704abd09", size = 46752047, upload-time = "2026-04-23T01:28:47.185Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/07024c25cdba3c810a2c95ef7de2814a16ea40fe7d49c86d56805dab5db4/itk_filtering-5.4.6-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:0517a0fee02d65023334483cb0cc141905ba54ec9e2fb4f4e08c06ea39fb0274", size = 38992843, upload-time = "2026-04-23T01:28:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/ea/af/28a66c1a68d35ea889e1cb53c80da09613f8b503220c4df3715deb27cacc/itk_filtering-5.4.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e391be3000e33ee63b3b4bbefef3b373e1dd92f13eea08e1b1e09dce9ea5d9d0", size = 69480307, upload-time = "2026-04-23T01:28:53.557Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/ecce86280d00ab2c9e35d89d65044397c12182f2d4b417935e553a5559e5/itk_filtering-5.4.6-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50901cf93df92d9d6099767b6406fed427c00f8c93eb176395b9da65bf9c89d8", size = 67831733, upload-time = "2026-04-23T01:28:57.04Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f1/c6a5a1ab323856c7880a4d2e69a0387506f6056718b74838a3cd4efb4310/itk_filtering-5.4.6-cp311-abi3-win_amd64.whl", hash = "sha256:67e3944a85ef6a5c7eec3b8b5dbd099ad66196a29916a75e417b7168ab112c22", size = 23571251, upload-time = "2026-04-23T01:28:59.951Z" }, +] + +[[package]] +name = "itk-io" +version = "5.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk-core" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/62/fd4a59567e76b3aef57c18bb1d5739803a9b9328222c2bd979f2c191871f/itk_io-5.4.6-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bb9287da3814a8bb1fbda6e9e8e3a1bda115a58ab0db478797702f63d73957d4", size = 22350067, upload-time = "2026-04-23T01:29:31.913Z" }, + { url = "https://files.pythonhosted.org/packages/84/88/7f30db9db2e9e3d9c1c4ffef7bbe0ce53eee4e66e597e1f56ecefff3973a/itk_io-5.4.6-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:84d36df7107ebe83ba57a45e6fef5c394bac68e12a38797cf7509c0600084b3b", size = 17785791, upload-time = "2026-04-23T01:29:34.407Z" }, + { url = "https://files.pythonhosted.org/packages/d6/9b/cab6a7df18d960564bdd593305b28ddda5b16c8d2f1cb094c285d365673f/itk_io-5.4.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:721bc543a8525e96ade0095ee950e3188d69a2c0bec99f24398ef638a9c973c7", size = 27678614, upload-time = "2026-04-23T01:29:37.834Z" }, + { url = "https://files.pythonhosted.org/packages/3e/02/85ef2211a1e9361efd249a9a3ba07ff09120ef555579b03a8c6ac3437a62/itk_io-5.4.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9e31eab429a112ae1505e08f133341126a1a489feb0af1e0cdfdde0e9b4c9d62", size = 28011144, upload-time = "2026-04-23T01:41:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/52/5f/fae3287f34edfd7bf1c6881be1db5eb456459442cf887d7bfea236e29a22/itk_io-5.4.6-cp311-abi3-win_amd64.whl", hash = "sha256:5ca00186c20f777032722bbe3e4bcd2176e1e30d851a819781117d4e6f3367e6", size = 8680896, upload-time = "2026-04-23T01:41:22.489Z" }, +] + +[[package]] +name = "itk-numerics" +version = "5.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk-core" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/0d/a418a9cc68c00103b6821b5a4dc059b35f93dde71db412f5521f5d0fa9ae/itk_numerics-5.4.6-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:d87fdcca3c5717afbb04ba1ab47f17ab0ba009c261cbe741dcb392a665f2a155", size = 35826667, upload-time = "2026-04-23T01:41:54.762Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e4/d5c2dea8ee845bffe8c3342f05a21578d5e8c4c8f886f7f98a992086ccef/itk_numerics-5.4.6-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a6c8172d019116379ba603a4c0db8039b6c952aa0b8ba7b8c6d613e49062bd4", size = 30873456, upload-time = "2026-04-23T01:41:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e1/572e6b2b44393ce09c0f3f2fea194036015f9194a8530a5dff883f8b1e97/itk_numerics-5.4.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f4f1d63f6568133dfe103ae43dfb7caace25de93601cc1cb6d64c9e9091b8057", size = 58139999, upload-time = "2026-04-23T01:42:00.668Z" }, + { url = "https://files.pythonhosted.org/packages/4f/11/a5f16a9c649a2ff9cb612a7473f4a9732a52774ef20c345f126418ace397/itk_numerics-5.4.6-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c61e40c2b1990851c978ca0515d1e36c5a8f34dbb85ba5d4e35569906fb0628", size = 57196801, upload-time = "2026-04-23T01:42:04.291Z" }, + { url = "https://files.pythonhosted.org/packages/64/33/29c2c2a258f2a5884bc5cb4a17bf628511a19e841ddec0da6c93719b32be/itk_numerics-5.4.6-cp311-abi3-win_amd64.whl", hash = "sha256:210bc33aeba0d1c6d05dc9adf628e27e7d5ca51acd5244c92a7b6ae05089149c", size = 19725678, upload-time = "2026-04-23T01:42:07.933Z" }, +] + +[[package]] +name = "itk-registration" +version = "5.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk-filtering" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/49/21a9a803f8345009ac3cd1272bb32dece0ea36a99194e8e1c6dbdb126e17/itk_registration-5.4.6-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7038711ba906b8d390c211d2fd1a16a90e20ca9b78fd137d0969343785900880", size = 22013045, upload-time = "2026-04-23T01:42:40.362Z" }, + { url = "https://files.pythonhosted.org/packages/83/12/c1945c6fa4eba9870b4cab17790a74492c3d9f47ae75a344ed50dfe50571/itk_registration-5.4.6-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:6f8cf2009bf056721ec251e5fa38609fb51e1ca15d1a29fd165766d879995b56", size = 17848602, upload-time = "2026-04-23T01:42:43.004Z" }, + { url = "https://files.pythonhosted.org/packages/98/37/854f9cb2f9337acc08a398c2dc82b8e44680c0315d0e3c4f04fc0c2e541f/itk_registration-5.4.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3accc1b031ba2494092d7b5d39a5b5142418b2bfe73fb528301bdd16074c519", size = 29007216, upload-time = "2026-04-23T01:42:45.358Z" }, + { url = "https://files.pythonhosted.org/packages/aa/47/5771b359183185e314a38b6144daaf77ba1577014100916e75b0d5e194a9/itk_registration-5.4.6-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8ec1f67c925503c9bf5b4f0e1134d6debb2f0adb9a3fd144b96d72309775e12", size = 28539736, upload-time = "2026-04-23T01:42:48.533Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ca/bd0d3dda0e82410c6e554a36b12a92c2906384bbab26c43050b850ae94bc/itk_registration-5.4.6-cp311-abi3-win_amd64.whl", hash = "sha256:b560fabf1c9663b22cd960ae5fa077953e13be06406a2d0f9d9f2e5a70e3ab5f", size = 9527127, upload-time = "2026-04-23T01:42:51.273Z" }, +] + +[[package]] +name = "itk-segmentation" +version = "5.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk-filtering" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/87/85c1bb73358cb72ab26cde074803115474d54798856ffafdee581d09cc00/itk_segmentation-5.4.6-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:46ef607f2bca7fba9f2eeee1620ca0f9f44f574e7d759c74153014d6a51fb46d", size = 13067904, upload-time = "2026-04-23T01:43:17.36Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/043766aa4b0d6ab765c7753b6a45a3cdb0bc388471ba8014d9a186e9af1f/itk_segmentation-5.4.6-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:087277335ca18a8116fabd1ccd0321072e4230faa474e25ae2b546cecb7186b3", size = 11040200, upload-time = "2026-04-23T01:43:19.992Z" }, + { url = "https://files.pythonhosted.org/packages/b1/84/6577b06aa28146820ac8139d3db842b0840a52792c4529a4d96396029153/itk_segmentation-5.4.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6b520618860f4f05b6c30c29e89fea3194f2fe4e4d20a5073fafeb3d82ed0d50", size = 16466550, upload-time = "2026-04-23T01:43:22.273Z" }, + { url = "https://files.pythonhosted.org/packages/0f/b6/6c4c3ee9bf0215ba00d986a9c136c3f521f38c1a135875888001303ae071/itk_segmentation-5.4.6-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb7dbe21fe52d624dc089bffb6e8336390aa59bacb675ed1a09ba752175b619c", size = 15895889, upload-time = "2026-04-23T01:43:25.597Z" }, + { url = "https://files.pythonhosted.org/packages/e5/99/88651df552c2dace67ad142dddd9e92dbeeafdea469258fa225b591aca04/itk_segmentation-5.4.6-cp311-abi3-win_amd64.whl", hash = "sha256:e5dc4c2b68b7400ab3bde3d811e525392d6998127049dfcfc772f52fd38021ae", size = 5034082, upload-time = "2026-04-23T01:43:27.663Z" }, +] + +[[package]] +name = "kivy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "filetype" }, + { name = "kivy-deps-angle", marker = "sys_platform == 'win32'" }, + { name = "kivy-deps-glew", marker = "sys_platform == 'win32'" }, + { name = "kivy-deps-sdl2", marker = "sys_platform == 'win32'" }, + { name = "kivy-garden" }, + { name = "pygments" }, + { name = "pypiwin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/48/c40f023dc103bdbd56db11facbb2d0ecbf4b019f7392875ee2a8227cb51e/Kivy-2.3.1.tar.gz", hash = "sha256:0833949e3502cdb4abcf9c1da4384674045ad7d85644313aa1ee7573f3b4f9d9", size = 23967306, upload-time = "2024-12-26T16:13:49.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/02/c76a94480adcb93e4da1b393a8eb392914b5812a5a5aa5bdb401c03571c7/Kivy-2.3.1-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:acb58843763075818de919989a73657307f4d833a7cc5547c1b16c226e260e5d", size = 11102692, upload-time = "2024-12-26T16:22:39.038Z" }, + { url = "https://files.pythonhosted.org/packages/03/ac/f5021a35b61f36910a2f38741f28a3e2384c7865c5bf7948cc82371dfe74/Kivy-2.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7a1799b19f6ab3bcfcef1e729a0229cee646167a1633e067c2add6978f928bb", size = 22792626, upload-time = "2024-12-26T18:27:43.173Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a1/f0fbcbabf8e285967bbd373275b9bd8e170e79b660a83886f998bdaa5b89/Kivy-2.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f180280df46a8c2f9988159938aa1a3e5a0094060d9586ea79df4b4ead9cad98", size = 22978300, upload-time = "2024-12-26T18:27:48.149Z" }, + { url = "https://files.pythonhosted.org/packages/62/52/a95bb60501aacf2ff8477f8c2e6b90324c1d48a917bd822d3cccb664cdad/Kivy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:002de19fef53955c48108758beea3092cf281326642d2e71eca1c443f4227cce", size = 4596321, upload-time = "2024-12-26T16:13:28.331Z" }, + { url = "https://files.pythonhosted.org/packages/de/57/ae1c82d8e017776934659a8cbba19f9b2e870146ad61d825b9061dd94e40/Kivy-2.3.1-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:3f74679ef305f0ed0d8bb3599a2dddc80ffc81157bdc07947498dd689fc9a5d9", size = 11122180, upload-time = "2024-12-26T16:22:43.165Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6b/227e059c9c124632ca3dbbcb24301ec06aedf80f6708cf15588bb3f0a708/Kivy-2.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:663e9b2fe5002f53371b3ad3712dccdaaa96905bbeaa83d7c7e64f3c44fec94e", size = 22740609, upload-time = "2024-12-26T18:27:51.985Z" }, + { url = "https://files.pythonhosted.org/packages/5b/17/e11a5b5778869ea4ec013d29c2fb50829226d6da2cc4d8ce91f27bf0a773/Kivy-2.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2be79fe1494b6e60cb5aa5f124c37961530417cf27a53171b5a72c9e4c7d41cf", size = 23114729, upload-time = "2024-12-26T18:27:57.197Z" }, + { url = "https://files.pythonhosted.org/packages/25/f2/5dd333fc5917030bdc3daa321eb9c465886618f76b41f9dad92dd2f25928/Kivy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:2046f6608d17b6c1a0530ac9aa127307fa25f6f75764f1d60428a1c0f6c0af88", size = 4562687, upload-time = "2024-12-26T16:13:31.375Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6c/2c7d7e9a1fe593de7e14cdd3eed8a17c22c62078ac41cfcde2e5f8af5f9c/Kivy-2.3.1-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:d8d9e57501961c5d45e5a2c5af0caef24e48f43a0cd88f607eb3b517198cfec4", size = 11033253, upload-time = "2024-12-26T16:22:47.541Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/12342de62d510bcc1a1e58d071c4830adf13b0787b2314b23c70add0e7ff/Kivy-2.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfe25296e9612cbfa2b68cfb0ccd3c80db1441c11261a9e131d5f8fed7618c2c", size = 22514825, upload-time = "2024-12-26T18:28:01.921Z" }, + { url = "https://files.pythonhosted.org/packages/78/04/b00303f94bd6bc1a2cff274110ea843f9976c9b4f7b2eb7af87ddf49481e/Kivy-2.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:950d17e275f817ca34cc7c9d55f9d229067e2f7fbd0fad985a74c94893f7e739", size = 22915702, upload-time = "2024-12-26T18:28:07.482Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/76e559df7250c3e06abf682f6f9d0856299f21629d50c441006b3413a7d0/Kivy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:b5127af11c2fc1299f2331402fe4f6edb0985711c2841fbfdf509830c058c78e", size = 4540500, upload-time = "2024-12-26T16:13:34.158Z" }, +] + +[[package]] +name = "kivy-deps-angle" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/1a/038c0844aa46fc49dcf8a657e890729f8960bfcac72e9c09c10cc18b32f2/kivy_deps.angle-0.4.0-cp311-cp311-win32.whl", hash = "sha256:c3899ff1f3886b80b155955bad07bfa33bbebd97718cdf46dfd788dc467124bc", size = 4588968, upload-time = "2023-12-08T09:46:35.181Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b1/d1ca22a7b18e7b2b90152a78a0c2d09a96fdb924f87be1914d70d9bee543/kivy_deps.angle-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:574381d4e66f3198bc48aa10f238e7a3816ad56b80ec939f5d56fb33a378d0b1", size = 5130936, upload-time = "2023-12-08T09:46:19.348Z" }, + { url = "https://files.pythonhosted.org/packages/c1/89/bb8b9a0fee422972fcf38a406ee9d0b1636968d7d2b5e97aafea8fdec251/kivy_deps.angle-0.4.0-cp312-cp312-win32.whl", hash = "sha256:4fa7a6366899fba13f7624baf4645787165f45731db08d14557da29c12ee48f0", size = 4588969, upload-time = "2023-12-08T09:45:36.62Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/d1500b880d3079454af0f935408ddd37cfce4fd11f53d0917e169d478869/kivy_deps.angle-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:668e670d4afd2551af0af2c627ceb0feac884bd799fb6a3dff78fdbfa2ea0451", size = 5130935, upload-time = "2023-12-08T09:46:38.767Z" }, + { url = "https://files.pythonhosted.org/packages/47/7e/ad805773fb76f07cb1bdf5147e66ba264a94f5ac54553cd9dee809a161bb/kivy_deps.angle-0.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:9afbf702f8bb9a993c48f39c018ca3b4d2ec381a5d3f82fe65bdaa6af0bba29b", size = 5133260, upload-time = "2024-10-07T19:14:24.171Z" }, +] + +[[package]] +name = "kivy-deps-glew" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/4e/4300963410a49c4ab6eaadefce3e09c5e17421bc042b072862a27413b08c/kivy_deps.glew-0.3.1-cp311-cp311-win32.whl", hash = "sha256:ee2f80ef7ac70f4b61c50da8101b024308a8c59a57f7f25a6e09762b6c48f942", size = 126457, upload-time = "2022-10-27T18:37:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/d6/37/884034260818569547347cc2ba89780ff3f83a9ce6b9a894360c1d86e82c/kivy_deps.glew-0.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:22e155ec59ce717387f5d8804811206d200a023ba3d0bc9bbf1393ee28d0053e", size = 123574, upload-time = "2022-10-27T18:36:49.17Z" }, + { url = "https://files.pythonhosted.org/packages/2b/3b/a960053dccd627e4483db4765fa84318a831cbf3af648aee20297ae56815/kivy_deps.glew-0.3.1-cp312-cp312-win32.whl", hash = "sha256:b64ee4e445a04bc7c848c0261a6045fc2f0944cc05d7f953e3860b49f2703424", size = 126458, upload-time = "2023-12-06T21:24:08.893Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3a/37a0a051dd3c7298d9e149a489457a6196665444c1a1473ad4fa617e05af/kivy_deps.glew-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:3acbbd30da05fc10c185b5d4bb75fbbc882a6ef2192963050c1c94d60a6e795a", size = 123573, upload-time = "2023-12-06T21:25:58.922Z" }, + { url = "https://files.pythonhosted.org/packages/21/99/e3478c34afed7a820b3348ce7fefc53f2034fa340348dca57162695e69d9/kivy_deps.glew-0.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:f4aa8322078359862ccd9e16e5cea61976d75fb43125d87922e20c916fa31a11", size = 123595, upload-time = "2024-10-07T18:46:16.273Z" }, +] + +[[package]] +name = "kivy-deps-sdl2" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/a5/d0605aea79495cc430807fb33f0615fe0a9cadb05da45fc6faa6f126a9fd/kivy_deps.sdl2-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ae3735480841ec9a57c0fb26e8647adee474a3d746147e3d75a1fc177c0fbc01", size = 4182250, upload-time = "2024-10-05T09:06:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0a/f34b1593977443e02e83343afb80d4a7d06fac394c1c60ac8590ed0e27c5/kivy_deps.sdl2-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:bfe0cfca77883dde7e297b3b6039fa9cd7ee8df6b0d12516b38addb0551a574c", size = 4182249, upload-time = "2024-10-05T09:07:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/9f/94/ef03ca62e042cd8e6a1455279622648c22cd891577b4ad980e1322dcb4ee/kivy_deps.sdl2-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:56b1c44565b5e8cfc510585db13396edfc605965254f49ed8931189c546d481f", size = 4182250, upload-time = "2024-10-05T09:07:33.029Z" }, +] + +[[package]] +name = "kivy-garden" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/55/cd1555bde62f809219cbc5d8a0836b0293399da2f4ba4e8ee84b6a7cc393/Kivy_Garden-0.1.5-py3-none-any.whl", hash = "sha256:ef50f44b96358cf10ac5665f27a4751bb34ef54051c54b93af891f80afe42929", size = 4623, upload-time = "2022-03-23T23:25:33.752Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "labjackpython" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/c5/f174d72ca2ad85b6f2bb41491a7bc02dc26b380a09e6dc54b515efa06d6d/LabJackPython-2.1.0.tar.gz", hash = "sha256:c4fb8fe1ce161949cb2bb70e5fe8e99549121b065c45c8511293f6ff8037a160", size = 113538, upload-time = "2022-11-03T21:17:16.074Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/98/7d8a4db1b169959e7a3f52110c919ae20d5afbe8853d528f4f2862957bd1/LabJackPython-2.1.0-py2.py3-none-any.whl", hash = "sha256:0ed87a94d3711f50e171a7fd2b45f4654b8186150688a99950319c1cdf3cfc29", size = 115467, upload-time = "2022-11-03T21:17:14.992Z" }, +] + +[[package]] +name = "lazy-loader" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/a2/78f662f1b18968531f67d3fcde1b7ea8496920bacd4f16ddb5b79d112e46/matplotlib-3.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f857524b442f0f36e641868ce2171aafa88cb0bc0644f4e1d8a5df9b32649fef", size = 9436261, upload-time = "2026-06-12T02:27:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/5e/92/044f1de43901310202f4c79acf4f141be53b2ca8d8380e2fcefb3d523a75/matplotlib-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57baa92fdc82948ed716eae6d2579d4d6f40965cd8d2f416755b4a72580a3233", size = 9264669, upload-time = "2026-06-12T02:27:37.413Z" }, + { url = "https://files.pythonhosted.org/packages/53/f4/f0b4f9ba7ec14a7af8151f3ad71ecfe3561e6ba38cfab1db3681ba4ca112/matplotlib-3.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630eee0e67d35cce2019a0e670719f4816e3b86aff0fa72729f6c69786fceb45", size = 10021076, upload-time = "2026-06-12T02:27:39.926Z" }, + { url = "https://files.pythonhosted.org/packages/d7/33/4d679c6dcd594a156542080ac907ddccf7b09ca11655c4b28eca8e9ee5da/matplotlib-3.11.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5106c444d0bf966eee2853548c03772af4ab7199118e086c62fbac8ccb07c055", size = 10828999, upload-time = "2026-06-12T02:27:42.433Z" }, + { url = "https://files.pythonhosted.org/packages/07/74/0a3683802037d8cd013144d77c247219b47f2aabace6fdde74faa12bacf7/matplotlib-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d7aea652b58e686444079be3376ef546bffa1eee9b9bb9c472b9fcf6cf410d3", size = 10913103, upload-time = "2026-06-12T02:27:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/970fcbf381e82ec66fdf5da8ea76e2e9240f61a24011ce9fd1d42c37ac2d/matplotlib-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:70a5b3e9a5dab708c0f039709ae7c68d5b4d254e291ef76492cdba230c8bb5e4", size = 9310945, upload-time = "2026-06-12T02:27:46.867Z" }, + { url = "https://files.pythonhosted.org/packages/14/4e/6e7cfed23611265ded53806852343b5c59339e506e84c474a9b5afc3b249/matplotlib-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:3d68266213e73823ac3be90615bab0cf31f88851e114cdb1dd25dacf3b01e1a7", size = 8999304, upload-time = "2026-06-12T02:27:48.798Z" }, + { url = "https://files.pythonhosted.org/packages/da/17/f5276b496c61477a6c4fc5e7401f4bfe1c2e5ef7c6cd67896f2ade3809cb/matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918", size = 9449976, upload-time = "2026-06-12T02:27:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97", size = 9279307, upload-time = "2026-06-12T02:27:53.247Z" }, + { url = "https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344", size = 10031353, upload-time = "2026-06-12T02:27:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/87/ce/8f25a0e3186aefd61913e7467d1b999465bcd0d0c03ac695c1b26ca559b7/matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1", size = 10839232, upload-time = "2026-06-12T02:27:57.746Z" }, + { url = "https://files.pythonhosted.org/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e", size = 10923899, upload-time = "2026-06-12T02:28:00.223Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e", size = 9329528, upload-time = "2026-06-12T02:28:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb", size = 9003413, upload-time = "2026-06-12T02:28:04.49Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/aa47f156b061d14c98b906f76c428507397708ec63ff94f410ae1752b426/matplotlib-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce3b839b34ae1f430b4616893a2945a2999debaa7e94e7e29a2a8bbf286f7b5", size = 9450532, upload-time = "2026-06-12T02:28:06.769Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4f/5a9eb0375e81413953febf8af7b012a6b6357f53438a15c4f5ad86c6bbb5/matplotlib-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373db8f91214e8ccaf35ac833cc1dd59dd961e148bbd55dd027141591dde1313", size = 9279760, upload-time = "2026-06-12T02:28:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/1117d53077e3ac3152503a84e9cf7a5c239576805ee71276e80c2aaa7471/matplotlib-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be152b7570324dc8d01574cc9474dd2d803237acf528bcbb5b211fa347461a09", size = 10031623, upload-time = "2026-06-12T02:28:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/92/7e/e937138daffad65b71bf831a377809dcbc830fb4f31a31e067dc1faa2575/matplotlib-3.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:126f256df600652d7e4b394cf3164ff75210a00038f287c95a012a6f58d0e83f", size = 10839372, upload-time = "2026-06-12T02:28:14.102Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c2/438ecc197ffb8023b6b9922915542f2172f5fd45b76703b0b4fc47322243/matplotlib-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:03acfeddf87b0dddb11b081ef7740ad445a3ca8bcb6b8e3011b08f2cf802b75c", size = 10924099, upload-time = "2026-06-12T02:28:16.383Z" }, + { url = "https://files.pythonhosted.org/packages/40/2e/395883da416f378b3ed2c9f3e843ac477eae1ce731b671b79adaa6f0bacd/matplotlib-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab3722f04f3ff34c23b5012c5873d2894174e06c3822fcdac3610965a5ac7d06", size = 9329727, upload-time = "2026-06-12T02:28:18.581Z" }, + { url = "https://files.pythonhosted.org/packages/61/82/2c388956abf8bf392dfb5b8917c502f1082df6a941b781ab8c8e5ba2474b/matplotlib-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c945824670fb8915b4ac879e5e61f3c58e0913022f70a0de4c082b17372f8771", size = 9003506, upload-time = "2026-06-12T02:28:20.474Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/34454baa44da7975ada82e9aea37105ec47059514dc967d3be14426ba8dc/matplotlib-3.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3489c3dc487669b4a980bc3068f87856de7a1564248d3f6c629efb2a58b03f24", size = 9499838, upload-time = "2026-06-12T02:28:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c3/98fe79a398cf232219f090163a7fa7e6766e9f2e0ad26df54d6f8934d8ee/matplotlib-3.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6a98f5476ce784a50ce09998f4ae1e6a9f25043cef8a480c98949902eda74620", size = 9332298, upload-time = "2026-06-12T02:28:24.796Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/b4b7c33151e74e5c802f3cde1ba807ebfc38401e329b44e215a5888dd76d/matplotlib-3.11.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565af866fd63e4bd3f987d580afe27c44c2552a3b3305f4ecbb85133601ea6f3", size = 10045491, upload-time = "2026-06-12T02:28:27.141Z" }, + { url = "https://files.pythonhosted.org/packages/71/28/394548efd68354110c1a1be11fe6b6e559e06d1a23da35908a0e316c55a9/matplotlib-3.11.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b3e64dea5062c570f04358e2711859f3531b459f29516274fbad889079e4f3", size = 10857059, upload-time = "2026-06-12T02:28:29.222Z" }, + { url = "https://files.pythonhosted.org/packages/c8/44/e7922e6e2a4d63bdfbc9dc4a53e3850ab438d46cf42e6779bb15ec92c948/matplotlib-3.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:942b37c5db1899610bd1543ce8e13e4ecff9a4633e7f63bb6aa9205d2644ebd1", size = 10939576, upload-time = "2026-06-12T02:28:31.66Z" }, + { url = "https://files.pythonhosted.org/packages/3d/be/b1ca96003a441d619b727fee21d671fdff7a5ce2f1bb797b2521aa2f679a/matplotlib-3.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c08e649a6313e1291e713623b97a38e5bb4aa580b2a100a94a3309bc6b9c8eb3", size = 9379519, upload-time = "2026-06-12T02:28:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/e3/72/4bf3b91821c34596dd6a7bdac5836d94f744144c8208939ef49d8ec43f7e/matplotlib-3.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2746cd2c113742ff6ce37a864c5ac5fd7aa644568f445e66166e457ac78e40e0", size = 9055456, upload-time = "2026-06-12T02:28:35.878Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c2/f5da6cd37ed6871f5c9b3c0507ddb69f14d6c36fac4541e4e0c60cb8cdfc/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:81ae77077a1e16d37a5b61096ccb07c8d90a99b518fa8256b8f21578932f2f62", size = 9434094, upload-time = "2026-06-12T02:29:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/f8/07/56f66906e0f87a0c6d0d0acbd34dbc9432b1931d8f26ef618bd6f92932a9/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ddef37840695f5eef65f9f070fe2d2f510f584c2156203f9f622a5b0584efffd", size = 9262183, upload-time = "2026-06-12T02:29:11.283Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/c4ecab06b7ea36a570c4f3bd2d48d1799fd5d9174470e45c2194199431e7/matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64", size = 10015653, upload-time = "2026-06-12T02:29:13.251Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "(python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "(python_full_version >= '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" }, + { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" }, + { url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pypiwin32" +version = "223" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/e8/4f38eb30c4dae36634a53c5b2cd73b517ea3607e10d00f61f2494449cec0/pypiwin32-223.tar.gz", hash = "sha256:71be40c1fbd28594214ecaecb58e7aa8b708eabfa0125c8a109ebd51edbd776a", size = 622, upload-time = "2018-02-26T00:43:23.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/1b/2f292bbd742e369a100c91faa0483172cd91a1a422a6692055ac920946c5/pypiwin32-223-py3-none-any.whl", hash = "sha256:67adf399debc1d5d14dffc1ab5acacb800da569754fafdc576b2a039485aa775", size = 1674, upload-time = "2018-02-26T00:43:23.108Z" }, +] + +[[package]] +name = "pypylon" +version = "26.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/97/a868da75302d7bfb070edc3dd64d1f8044a56bb7bd67d02e622fa6906f32/pypylon-26.6-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:8c93288fed14cfdc64886e0e93b37c9b81254247241fc86725d3865505818657", size = 34865997, upload-time = "2026-07-02T13:37:47.397Z" }, + { url = "https://files.pythonhosted.org/packages/01/8c/941ac7293f6353bced95c2e02487add44461c71468f9dd76c4fe59cd1da0/pypylon-26.6-cp39-abi3-macosx_14_0_x86_64.whl", hash = "sha256:187800435eee08499c45b549696fc854b420a88ae0a304b4ca0293a8eae3d7f5", size = 34936026, upload-time = "2026-07-02T13:37:50.662Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/e4feae8c46b37de9aceec11f5f3e318b34d5526a964facc7933cde323551/pypylon-26.6-cp39-abi3-manylinux_2_31_aarch64.whl", hash = "sha256:fd74afebe7289709f6c44f0d3272829ba7be02ed54ba7a7bd258a4401f009da5", size = 70414680, upload-time = "2026-07-02T13:38:26.27Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/887d57de55c15984df600fe53985d4749db6f213cc8881665865b48bcabe/pypylon-26.6-cp39-abi3-manylinux_2_31_x86_64.whl", hash = "sha256:59171e2d0b17912fffd7ffc9fe2c99a967ea663efc0398e9e5635ba864c8b6f5", size = 88188443, upload-time = "2026-07-02T13:38:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/65/da/8a66f1cc352906f53637f11ec60ae5b7e95ab9f8043a26a91e583e8e110f/pypylon-26.6-cp39-abi3-win_amd64.whl", hash = "sha256:c1bf1d91bcb860e93ec8e748fe99285fe712069755b76cf0d8b753162761ac5e", size = 110755338, upload-time = "2026-07-02T13:37:51.556Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, +] + +[[package]] +name = "reactivex" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/af/38a4b62468e4c5bd50acf511d86fe62e65a466aa6abb55b1d59a4a9e57f3/reactivex-4.1.0.tar.gz", hash = "sha256:c7499e3c802bccaa20839b3e17355a7d939573fded3f38ba3d4796278a169a3d", size = 113482, upload-time = "2025-11-05T21:44:24.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/9e/3c2f5d3abb6c5d82f7696e1e3c69b7279049e928596ce82ed25ca97a08f3/reactivex-4.1.0-py3-none-any.whl", hash = "sha256:485750ec8d9b34bcc8ff4318971d234dc4f595058a1b4435a74aefef4b2bc9bd", size = 218588, upload-time = "2025-11-05T21:44:23.015Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "scikit-image" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "lazy-loader" }, + { name = "networkx" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "tifffile", version = "2026.7.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/16/8a407688b607f86f81f8c649bf0d68a2a6d67375f18c2d660aba20f5b648/scikit_image-0.26.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b1ede33a0fb3731457eaf53af6361e73dd510f449dac437ab54573b26788baf0", size = 12355510, upload-time = "2025-12-20T17:10:31.628Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f9/7efc088ececb6f6868fd4475e16cfafc11f242ce9ab5fc3557d78b5da0d4/scikit_image-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7af7aa331c6846bd03fa28b164c18d0c3fd419dbb888fb05e958ac4257a78fdd", size = 12056334, upload-time = "2025-12-20T17:10:34.559Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1e/bc7fb91fb5ff65ef42346c8b7ee8b09b04eabf89235ab7dbfdfd96cbd1ea/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ea6207d9e9d21c3f464efe733121c0504e494dbdc7728649ff3e23c3c5a4953", size = 13297768, upload-time = "2025-12-20T17:10:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2a/e71c1a7d90e70da67b88ccc609bd6ae54798d5847369b15d3a8052232f9d/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74aa5518ccea28121f57a95374581d3b979839adc25bb03f289b1bc9b99c58af", size = 13711217, upload-time = "2025-12-20T17:10:40.935Z" }, + { url = "https://files.pythonhosted.org/packages/d4/59/9637ee12c23726266b91296791465218973ce1ad3e4c56fc81e4d8e7d6e1/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d5c244656de905e195a904e36dbc18585e06ecf67d90f0482cbde63d7f9ad59d", size = 14337782, upload-time = "2025-12-20T17:10:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5c/a3e1e0860f9294663f540c117e4bf83d55e5b47c281d475cc06227e88411/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21a818ee6ca2f2131b9e04d8eb7637b5c18773ebe7b399ad23dcc5afaa226d2d", size = 14805997, upload-time = "2025-12-20T17:10:45.93Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c6/2eeacf173da041a9e388975f54e5c49df750757fcfc3ee293cdbbae1ea0a/scikit_image-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:9490360c8d3f9a7e85c8de87daf7c0c66507960cf4947bb9610d1751928721c7", size = 11878486, upload-time = "2025-12-20T17:10:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a4/a852c4949b9058d585e762a66bf7e9a2cd3be4795cd940413dfbfbb0ce79/scikit_image-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:0baa0108d2d027f34d748e84e592b78acc23e965a5de0e4bb03cf371de5c0581", size = 11346518, upload-time = "2025-12-20T17:10:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/e13757982264b33a1621628f86b587e9a73a13f5256dad49b19ba7dc9083/scikit_image-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c", size = 12376452, upload-time = "2025-12-20T17:10:52.796Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/f8dd17d0510f9911f9f17ba301f7455328bf13dae416560126d428de9568/scikit_image-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37", size = 12061567, upload-time = "2025-12-20T17:10:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/c70120a6880579fb42b91567ad79feb4772f7be72e8d52fec403a3dde0c6/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44", size = 13084214, upload-time = "2025-12-20T17:10:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a2/70401a107d6d7466d64b466927e6b96fcefa99d57494b972608e2f8be50f/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466", size = 13561683, upload-time = "2025-12-20T17:10:59.49Z" }, + { url = "https://files.pythonhosted.org/packages/13/a5/48bdfd92794c5002d664e0910a349d0a1504671ef5ad358150f21643c79a/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932", size = 14112147, upload-time = "2025-12-20T17:11:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b5/ac71694da92f5def5953ca99f18a10fe98eac2dd0a34079389b70b4d0394/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70", size = 14661625, upload-time = "2025-12-20T17:11:04.528Z" }, + { url = "https://files.pythonhosted.org/packages/23/4d/a3cc1e96f080e253dad2251bfae7587cf2b7912bcd76fd43fd366ff35a87/scikit_image-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0", size = 11911059, upload-time = "2025-12-20T17:11:06.61Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/d1b8055f584acc937478abf4550d122936f420352422a1a625eef2c605d8/scikit_image-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8", size = 11348740, upload-time = "2025-12-20T17:11:09.118Z" }, + { url = "https://files.pythonhosted.org/packages/4f/48/02357ffb2cca35640f33f2cfe054a4d6d5d7a229b88880a64f1e45c11f4e/scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7", size = 12346329, upload-time = "2025-12-20T17:11:11.599Z" }, + { url = "https://files.pythonhosted.org/packages/67/b9/b792c577cea2c1e94cda83b135a656924fc57c428e8a6d302cd69aac1b60/scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5", size = 12031726, upload-time = "2025-12-20T17:11:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/9564250dfd65cb20404a611016db52afc6268b2b371cd19c7538ea47580f/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21", size = 13094910, upload-time = "2025-12-20T17:11:16.2Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b8/0d8eeb5a9fd7d34ba84f8a55753a0a3e2b5b51b2a5a0ade648a8db4a62f7/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b", size = 13660939, upload-time = "2025-12-20T17:11:18.464Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d6/91d8973584d4793d4c1a847d388e34ef1218d835eeddecfc9108d735b467/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb", size = 14138938, upload-time = "2025-12-20T17:11:20.919Z" }, + { url = "https://files.pythonhosted.org/packages/39/9a/7e15d8dc10d6bbf212195fb39bdeb7f226c46dd53f9c63c312e111e2e175/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd", size = 14752243, upload-time = "2025-12-20T17:11:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/8f/58/2b11b933097bc427e42b4a8b15f7de8f24f2bac1fd2779d2aea1431b2c31/scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1", size = 11906770, upload-time = "2025-12-20T17:11:25.297Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ec/96941474a18a04b69b6f6562a5bd79bd68049fa3728d3b350976eccb8b93/scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219", size = 11342506, upload-time = "2025-12-20T17:11:27.399Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/c1a9962b0cf1952f42d32b4a2e48eed520320dbc4d2ff0b981c6fa508b6b/scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49", size = 12663278, upload-time = "2025-12-20T17:11:29.358Z" }, + { url = "https://files.pythonhosted.org/packages/ae/97/c1a276a59ce8e4e24482d65c1a3940d69c6b3873279193b7ebd04e5ee56b/scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3", size = 12405142, upload-time = "2025-12-20T17:11:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4a/f1cbd1357caef6c7993f7efd514d6e53d8fd6f7fe01c4714d51614c53289/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604", size = 12942086, upload-time = "2025-12-20T17:11:33.683Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/74d9fb87c5655bd64cf00b0c44dc3d6206d9002e5f6ba1c9aeb13236f6bf/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37", size = 13265667, upload-time = "2025-12-20T17:11:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/a7/73/faddc2413ae98d863f6fa2e3e14da4467dd38e788e1c23346cf1a2b06b97/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc", size = 14001966, upload-time = "2025-12-20T17:11:38.55Z" }, + { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "(python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "(python_full_version >= '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", +] +dependencies = [ + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tifffile" +version = "2026.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "(python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/e4/e804505f87627cd8cdae9c010c47c4485fd8c1ce31a7dd0ab7fcc4707377/tifffile-2026.3.3-py3-none-any.whl", hash = "sha256:e8be15c94273113d31ecb7aa3a39822189dd11c4967e3cc88c178f1ad2fd1170", size = 243960, upload-time = "2026-03-03T19:14:35.808Z" }, +] + +[[package]] +name = "tifffile" +version = "2026.7.14" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "(python_full_version >= '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", +] +dependencies = [ + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/2f/e5fe51c8f782241d86fdf7251594b195f0d6c2fcf9d389079de212599246/tifffile-2026.7.14.tar.gz", hash = "sha256:ce2703e5ef22c868f1528d5f5b4ef75eefb019cf628a1c9ec0d17e0afeca8ef5", size = 437660, upload-time = "2026-07-14T23:41:31.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/e8/d381de4a3cc4e3682cba0338f43250893508aad0b310af1d0635f7b04413/tifffile-2026.7.14-py3-none-any.whl", hash = "sha256:4eb20372e76edf2c9fed922b1e3a0a0567be3560bd2008336115763bb1f3c034", size = 270614, upload-time = "2026-07-14T23:41:30.078Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "zaber-motion" +version = "9.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "reactivex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/bb16072c1cafdb18e4399b685ca6c15e59049dfcbaa53a42edab50ce84de/zaber_motion-9.3.2.tar.gz", hash = "sha256:bda8ef02797f1e21bbead9087de01f76f4bd5b73cd20eb72ab5ab514284e2bc7", size = 306039, upload-time = "2026-06-20T03:11:14.366Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/6c/929418f588dd737fb479474330337b0d3e7a48acf6144700e7dd0af32fe8/zaber_motion-9.3.2-py3-none-macosx_10_4_universal2.whl", hash = "sha256:0c01b14a73032625803e66a5189b502bf8c91fcdb35b715d843657a9111f01b8", size = 14496325, upload-time = "2026-06-20T03:11:00.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/bf573c1328a86990b49f48b2485a70f7c9f5d1ea0789ad79d9325c6d5a14/zaber_motion-9.3.2-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:373441f93a85b81217caeb636ad220d95876dd2669a152e6a34e7b72e2eecd6e", size = 7303706, upload-time = "2026-06-20T03:11:02.995Z" }, + { url = "https://files.pythonhosted.org/packages/80/a8/64ad44f42ad6249a7b4e1993799e9a506abad876729e2748fa288e913c4b/zaber_motion-9.3.2-py3-none-manylinux_2_27_armv7l.whl", hash = "sha256:6327adec8ec9756391a6495256ef2d1343ceb145940e4528f4de0f42e273203e", size = 7608714, upload-time = "2026-06-20T03:11:04.752Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/9797a8a4f2f0d8e6edfe9bb9e786b08b51ff848ab4bb0055a645ff301903/zaber_motion-9.3.2-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:2605c20579416cc8e219fd58ccc7ac063d25726a57eee2fe3b3976e0775b379b", size = 8052144, upload-time = "2026-06-20T03:11:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/36/41/013f508ed6d8450fb551089c6e58f9d847cde6227e41c1111c030f2a9934/zaber_motion-9.3.2-py3-none-win32.whl", hash = "sha256:7c01b19b72088f46193aca4f24798c3c31a6b6a7bffe73da988d1a35f661e965", size = 7658845, upload-time = "2026-06-20T03:11:08.68Z" }, + { url = "https://files.pythonhosted.org/packages/f8/be/8a311ebcb50535dee928675304c80a8a3c28ffc525cc41604079efc4842a/zaber_motion-9.3.2-py3-none-win_amd64.whl", hash = "sha256:b35861f72cd9711419e567ac3f58e0279f9860e65d8ad5713ae16dbe87a86a4e", size = 7836852, upload-time = "2026-06-20T03:11:10.521Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b4/1adc0ae718209a198ba4406744c1ea56764ef105bfea54645adcb3f19315/zaber_motion-9.3.2-py3-none-win_arm64.whl", hash = "sha256:3c52dbcdc4317907032a0f4fdfd62dc8b499bacaf99aeb3ea37964143d57dd79", size = 7103414, upload-time = "2026-06-20T03:11:12.961Z" }, +]