diff --git a/.gitignore b/.gitignore index ed8ebf5..a6da9e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ -__pycache__ \ No newline at end of file +__pycache__ +.vscode +*.wpr +*.wpu +*.npy +*.csv diff --git a/README.md b/README.md index d6b8673..abc545f 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ -# ht301_hacklib -Thermal camera opencv python lib. +# IRPyThermal +### Thermal camera python library Supported thermal cameras: - Hti HT-301 -- Xtherm T3S, thanks to Angel-1024! -- Xtherm T2L, T2S+, thanks to Christoph Mair +- Xtherm T3S, thanks to Angel-1024 +- Xtherm T2L, T2S+, thanks to Christoph Mair and DiminDDL -It's a very simple hacked together lib, might be useful for somebody, -uses `matplotlib` which is a little bit on the slow side, +It's a very simple hacked together lib, might be useful for somebody, +uses `matplotlib` which is a little bit on the slow side, or pure `opencv` - much faster but without most features -Tested on ubuntu 20.04 and windows 11: +Upon startup the app automatically looks for supported cameras by matching the resolutions. +Running `python ./pyplot.py` will start the camera stream and show the controls below: ``` -$ ./pyplot.py keys: 'h' - help 'q' - quit @@ -30,26 +30,52 @@ keys: 'a', 'z' - auto exposure on/off, auto exposure type 'k', 'l' - set the thermal range to normal/high (only for the TS2+) 'o' - change the camera orientation (rotate 90 degs CW). Only in OpenCV - + left, right, up, down - set exposure limits mouse: left button - add Region Of Interest (ROI) right button - add user temperature annotation ``` +While the program is running you can use these controls in the camera view to control how it looks and behaves. + + +You can specify the video device directly with `-d` to the startup command to skip the search routine. + +Also for T2S+ V2 users (and potentially for others, where the camera sends raw data and doesn't do onboard processing resulting in the image looking like a pixelated mess) you can specify the `-r` option to tell the program to treat the camera stream as raw sensor data. This will perform a few calibration routines and present you with a usable image doing the sensor processing in python. There is also the `-o` parameter which adds an offset to the entire frames temperature values. This is useful for calibrating the camera since the raw mode sometimes ends up with a constant offset to the image, but this is specific to the camera and thus can be found once and then used for subsequent runs. + +### Examples + +Example that reads device `/dev/video14` in raw mode and adds an offset of -12.3 to the temperature values: + +``` +python ./pyplot.py -d /dev/video14 -r -o -12.3 +``` + ![pyplot output](docs/pyplot-output1.png) ![pyplot output](docs/pyplot-output2.png) View saved ('r' key) raw data file: ``` -$ ./pyplot.py 2022-09-11_18-49-07.npy +python ./pyplot.py 2022-09-11_18-49-07.npy ``` -Opencv version: +### Lock In Thermography + +This feature allows you to use the camera in a lock-in mode. This allows for very precise measurements of hotspots on anything that can be modulated in some way. Examples include silicon dies, circuit boards, people (lock in based on heart rate) and potentially many others. + +As mentioned above the device under test needs to be stimulated on command, the script sends commands into a provided serial port to do this. +A `1\n` indicates that we want to turn the supply/stimulant on, a `0\n` turns it off. The modulation frequency, serial port and integration time are all provided as command line arguments. + +For example, the below command will use a modulation frequency of 1Hz, an integration time of 10s and send the commands to `/dev/ttyACM0`: + ``` -$ ./opencv.py +python ./pyplot.py -l 1 -p /dev/ttyACM0 -i 10 ``` -![opencv output](docs/opencv-output.png) + +The modulation frequency should ideally be at least set 10x lower than the camera's frame rate, this is needed to get multiple samples as the sample heats up and cools down. The longer the integration time the more cycles will be "averaged" together, reducing noise, but the longer it takes to get a single frame. More info can be found in the paper linked below. + +![lockin output](docs/lock-in.png)
@@ -59,8 +85,10 @@ $ ./opencv.py - https://github.com/MCMH2000/OpenHD_HT301_Driver - https://github.com/sumpster/ht301_viewer - https://github.com/cmair/ht301_hacklib +- https://github.com/stawel/ht301_hacklib - https://github.com/mcguire-steve/ht301_ircam ## Related materials - https://www.mdpi.com/1424-8220/17/8/1718 +- https://www-old.mpi-halle.mpg.de/mpi/publi/pdf/540_02.pdf diff --git a/display.py b/display.py new file mode 100644 index 0000000..2fd4604 --- /dev/null +++ b/display.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.widgets import Button + +# Load the numpy file +data = np.load("./2024-06-21_23-02-55.npy") + + +# Function to get the pixel number +def get_pixel_number(x, y, width, height, origin=(0, 0)): + zero_x, zero_y = origin + pixel_number = (y - zero_y) * width + (x - zero_x) + return pixel_number + + +# Click event handler +def onclick(event): + global arrow, rect + x, y = int(event.xdata), int(event.ydata) + height, width = data.shape + + if x >= 0 and y >= 0 and x < width and y < height: + pixel_number = get_pixel_number(x, y, width, height, origin=(zero_x, zero_y)) + pixel_value = data[y, x] + print(f"Pixel ({x}, {y}) has number: {pixel_number} and value: {pixel_value}") + + # Clear the previous highlight + if arrow: + arrow.remove() + if rect: + rect.remove() + + # Draw an arrow pointing to the clicked pixel with detailed information + arrow = ax.annotate( + f"({x}, {y})\nNumber: {pixel_number}\nValue: {pixel_value}", + xy=(x, y), + xytext=(x + 10, y + 10), + arrowprops=dict(facecolor="red", shrink=0.05), + bbox=dict(boxstyle="round,pad=0.3", edgecolor="red", facecolor="white"), + ) + + # Draw a rectangle around the clicked pixel + rect = plt.Rectangle( + (x - 0.5, y - 0.5), 1, 1, edgecolor="red", facecolor="none" + ) + ax.add_patch(rect) + + fig.canvas.draw() + + +# Function to highlight all non-zero pixels +def highlight_non_zero(event): + global scatter + if scatter: + scatter.remove() + scatter = None + else: + non_zero_pixels = np.argwhere(data != 0) + y, x = non_zero_pixels[:, 0], non_zero_pixels[:, 1] + scatter = ax.scatter(x, y, color="blue", s=10) + + fig.canvas.draw() + + +# Load and display the image +fig, ax = plt.subplots() +plt.subplots_adjust(bottom=0.2) # Adjust to make space for the button +ax.imshow(data, cmap="gray") + +# Define origin +zero_x, zero_y = 0, 192 # Adjust these values to set a different origin + +# Initialize annotation and rectangle variables +arrow = None +rect = None +scatter = None + +# Connect the click event +cid = fig.canvas.mpl_connect("button_press_event", onclick) + +# Add a button to highlight non-zero pixels +ax_button = plt.axes([0.8, 0.05, 0.1, 0.075]) +btn = Button(ax_button, "Toggle Non-Zero") +btn.on_clicked(highlight_non_zero) + +# plt.tight_layout() + +plt.show() diff --git a/docs/lock-in.png b/docs/lock-in.png new file mode 100644 index 0000000..786fbec Binary files /dev/null and b/docs/lock-in.png differ diff --git a/example_simple.py b/example_simple.py index 5f3777e..fcde291 100755 --- a/example_simple.py +++ b/example_simple.py @@ -1,11 +1,10 @@ -#!/usr/bin/python3 -import time -import numpy as np -import ht301_hacklib +#!/usr/bin/env python3 +import time +import irpythermal # create camera object and do a initial calibration -cap = ht301_hacklib.HT301() +cap = irpythermal.HT301() # read frame (raw ADC data) ret, frame = cap.read() @@ -14,26 +13,49 @@ info, temperature_lookup_table = cap.info() point = (10, 20) -print('temperature at point:', point, 'is:', temperature_lookup_table[frame[point]], '°C', '[raw ADC value:', frame[point], ']') -#print('additional info:', info) - -time.sleep(5) # not needed -print('click, click') +print( + "temperature at point:", + point, + "is:", + temperature_lookup_table[frame[point]], + "°C", + "[raw ADC value:", + frame[point], + "]", +) +# print('additional info:', info) + +time.sleep(5) # not needed +print("click, click") cap.calibrate() -time.sleep(5) # not needed +time.sleep(5) # not needed # read next frame ret, frame = cap.read() info, temperature_lookup_table = cap.info() -print('temperature at point:', point, 'is:', temperature_lookup_table[frame[point]], '°C', '[raw ADC value:', frame[point], ']') -#print('additional info:', info) - -print('temperature at any point:', temperature_lookup_table[frame]) -print('frame shape:', frame.shape, 'data type:', frame.dtype) -#result: frame shape: (288, 384) data type: uint16 -print('T lut shape:', temperature_lookup_table.shape, 'data type:', temperature_lookup_table.dtype) -#result: T lut shape: (16384,) data type: float64 +print( + "temperature at point:", + point, + "is:", + temperature_lookup_table[frame[point]], + "°C", + "[raw ADC value:", + frame[point], + "]", +) +# print('additional info:', info) + +print("temperature at any point:", temperature_lookup_table[frame]) +print("frame shape:", frame.shape, "data type:", frame.dtype) +# result: frame shape: (288, 384) data type: uint16 +print( + "T lut shape:", + temperature_lookup_table.shape, + "data type:", + temperature_lookup_table.dtype, +) +# result: T lut shape: (16384,) data type: float64 # release camera object cap.release() diff --git a/ht301_hacklib.py b/ht301_hacklib.py deleted file mode 100755 index 0b3094b..0000000 --- a/ht301_hacklib.py +++ /dev/null @@ -1,392 +0,0 @@ -#!/usr/bin/python3 -import math -from sys import platform -from typing import Tuple - -import cv2 -import numpy as np - -SET_CORRECTION = 0 * 4 -SET_REFLECTION = 1 * 4 -SET_AMB = 2 * 4 -SET_HUMIDITY = 3 * 4 -SET_EMISSIVITY = 4 * 4 -SET_DISTANCE = 5 * 4 - -ROWS_SPECIAL_DATA = 4 - -def read_u16(arr_u16, offset): - ''' arr: np.uint16 ''' - return arr_u16[offset] - -def read_f32(arr_u16, offset, step=2): - ''' arr: np.uint16 ''' - return arr_u16[offset:offset+step].view(np.float32)[0] - -def read_u8(arr_u16, offset, step): - return arr_u16[offset:offset + step].view(np.uint8) - -class Camera: - """Class for reading data from the XTherm/HT301/InfiRay thermal cameras""" - supported_resolutions = {(240, 180), (256, 192), (384, 288), (640, 512)} - ZEROC = 273.15 - distance_multiplier = 1.0 - offset_temp_shutter = 0.0 - offset_temp_fpa = 0.0 - - range = 120 - cal_00_offset = 390.0 - cal_00_fpamul = 7.05 - - correction_coefficient_m = 1 - correction_coefficient_b = 0 - - height:int - width:int - frame_width:int - frame_height:int - frame:np.ndarray - frame_raw:np.ndarray - meta:np.ndarray - device_strings:Tuple[str, str, str, str, str, str] - userArea:int - amountPixels:int - fourLinePara:int - cap:cv2.VideoCapture - frame_raw_u16:np.ndarray - def __init__(self, video_dev:cv2.VideoCapture|None=None) -> None: - if video_dev is None: - video_dev = self.find_device() - if not video_dev: - raise Exception("No video device found!") - self.cap = video_dev - self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))-ROWS_SPECIAL_DATA - self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) - - - self.fourLinePara = self.width * self.height - self.init_parameters() - self.userArea = self.amountPixels + 127 - - - # Decide whether or not convert data to RGB - self.cap.set(cv2.CAP_PROP_CONVERT_RGB, 0) - - # using Raw mode 16 bit data - self.cap.set(cv2.CAP_PROP_ZOOM, 0x8004) - - self.calibrate() - - - def find_device(cls) -> cv2.VideoCapture: - """Find a supported thermal camera - - Optionally narrow down the resultion of the camera too look for.""" - for i in range(10): - try: - if platform.startswith('linux'): - cap = cv2.VideoCapture(i, cv2.CAP_V4L2) - else: - cap = cv2.VideoCapture(i) - - cap_width = cap.get(cv2.CAP_PROP_FRAME_WIDTH) - cap_height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) - print(f"Found a camera {i} with resolution {int(cap_width)}x{int(cap_height)}") - - if (cap_width, cap_height-ROWS_SPECIAL_DATA) in cls.supported_resolutions: - return cap - except: pass - raise ValueError(f"Cannot find camera with a width of one of {cls.supported_resolutions} that also matches: {width=} and {height=}") - - def info(self) -> Tuple[dict, np.ndarray]: - shutTemper = read_u16(self.frame_raw_u16, self.fourLinePara + self.amountPixels + 1) - floatShutTemper = shutTemper / 10.0 - self.ZEROC - - coreTemper = read_u16(self.frame_raw_u16, self.fourLinePara + self.amountPixels + 2) - floatCoreTemper = coreTemper / 10.0 - self.ZEROC - - cal_00 = float(read_u16(self.frame_raw_u16, self.fourLinePara + self.amountPixels)) - self.cal_01 = read_f32(self.frame_raw_u16, self.fourLinePara + self.amountPixels + 3) - cal_02 = read_f32(self.frame_raw_u16, self.fourLinePara + self.amountPixels + 5) - cal_03 = read_f32(self.frame_raw_u16, self.fourLinePara + self.amountPixels + 7) - cal_04 = read_f32(self.frame_raw_u16, self.fourLinePara + self.amountPixels + 9) - cal_05 = read_f32(self.frame_raw_u16, self.fourLinePara + self.amountPixels + 11) - - cameraSoftVersion: np.ndarray = read_u8(self.frame_raw_u16, self.fourLinePara + self.amountPixels + 24, step=8) - cameraSoftVersion = cameraSoftVersion.tobytes().decode("ascii").rstrip("\x00") - - sn: np.ndarray = read_u8(self.frame_raw_u16, self.fourLinePara + self.amountPixels + 32, step=3) - sn = sn.tobytes().decode("ascii").rstrip("\x00") - - correction = read_f32(self.frame_raw_u16, self.fourLinePara + self.userArea) - Refltmp = read_f32(self.frame_raw_u16, self.fourLinePara + self.userArea + 2) - Airtmp = read_f32(self.frame_raw_u16, self.fourLinePara + self.userArea + 4) - humi = read_f32(self.frame_raw_u16, self.fourLinePara + self.userArea + 6) - emiss = read_f32(self.frame_raw_u16, self.fourLinePara + self.userArea + 8) - distance = read_u16(self.frame_raw_u16, self.fourLinePara + self.userArea + 10) - - fpa_avg = read_u16(self.frame_raw_u16, self.fourLinePara) - fpaTmp = read_u16(self.frame_raw_u16, self.fourLinePara + 1) - maxx1 = read_u16(self.frame_raw_u16, self.fourLinePara + 2) - maxy1 = read_u16(self.frame_raw_u16, self.fourLinePara + 3) - self.max_raw = read_u16(self.frame_raw_u16, self.fourLinePara + 4) - minx1 = read_u16(self.frame_raw_u16, self.fourLinePara + 5) - miny1 = read_u16(self.frame_raw_u16, self.fourLinePara + 6) - self.min_raw = read_u16(self.frame_raw_u16, self.fourLinePara + 7) - self.avg_raw = read_u16(self.frame_raw_u16, self.fourLinePara + 8) - - fpatmp_ = 20.0 - (float(fpaTmp) - self.fpa_off) / self.fpa_div - - center_raw = read_u16(self.frame_raw_u16, self.fourLinePara + 12) - user_raw00 = read_u16(self.frame_raw_u16, self.fourLinePara + 13) - user_raw01 = read_u16(self.frame_raw_u16, self.fourLinePara + 14) - user_raw02 = read_u16(self.frame_raw_u16, self.fourLinePara + 15) - - distance_adjusted = (20.0 if distance >= 20.0 else distance) * self.distance_multiplier - atm = self.atmt(humi, Airtmp, distance_adjusted) - self.numerator_sub = (1.0 - emiss) * atm * math.pow(Refltmp + self.ZEROC, 4) + (1.0 - atm) * math.pow(Airtmp + self.ZEROC, 4) - self.denominator = emiss * atm - - ts = floatShutTemper + self.offset_temp_shutter - tfpa = fpatmp_ + self.offset_temp_fpa - - self.cal_a = cal_02 / (self.cal_01 + self.cal_01) - self.cal_b = cal_02 * cal_02 / (self.cal_01 * self.cal_01 * 4.0) - self.cal_c = self.cal_01 * math.pow(ts, 2) + ts * cal_02 - self.cal_d = cal_03 * math.pow(tfpa, 2) + cal_04 * tfpa + cal_05 - - cal_00_corr = 0 - - if self.range == 120: - cal_00_corr = int(self.cal_00_offset - tfpa * self.cal_00_fpamul) - - table_offset = cal_00 - (cal_00_corr if cal_00_corr > 0 else 0) - - temperatureTable = self.get_temp_table(correction, Airtmp, table_offset, distance_adjusted) - - ''' build infomation ''' - info = { - "temp_shutter": floatShutTemper, - "temp_core": floatCoreTemper, - "cameraSoftVersion": cameraSoftVersion, - "sn": sn, - "correction": correction, - "temp_reflected": Refltmp, - "temp_air": Airtmp, - "humidity": humi, - "emissivity": emiss, - "distance": distance, - "fpa_average": fpa_avg, - "temp_fpa": fpatmp_, - "temp_max_x": maxx1, - "temp_max_y": maxy1, - "temp_max_raw": self.max_raw, - "temp_max": temperatureTable[self.max_raw], - "temp_min_x": minx1, - "temp_min_y": miny1, - "temp_min_raw": self.min_raw, - "temp_min": temperatureTable[self.min_raw], - "temp_average_raw": self.avg_raw, - "temp_average": temperatureTable[self.avg_raw], - "temp_center_raw": center_raw, - "temp_center": temperatureTable[center_raw], - "temp_user_00": temperatureTable[user_raw00], - "temp_user_01": temperatureTable[user_raw01], - "temp_user_02": temperatureTable[user_raw02], - "Tmin_point": (minx1, miny1), - "Tmax_point": (maxx1, maxy1), - "Tcenter_point": (self.width // 2, self.height // 2), - "Tmin_C": temperatureTable[self.min_raw], - "Tmax_C": temperatureTable[self.max_raw], - "Tcenter_C": temperatureTable[center_raw], - } - - return info, temperatureTable - - # read raw data from cam, seperate visible frame from metadata - def read(self) -> Tuple[bool, np.ndarray]: - ret, frame_raw = self.cap.read() - self.frame_raw_u16: np.ndarray = frame_raw.view(np.uint16).ravel() - frame_visible = self.frame_raw_u16[:self.fourLinePara].copy().reshape(self.height, self.width) - return ret, frame_visible - - def set_correction(self, correction: float) -> None: - self.sendFloatCommand(position=SET_CORRECTION, value=correction) - - def set_reflection(self, reflection: float) -> None: - self.sendFloatCommand(position=SET_REFLECTION, value=reflection) - - def set_amb(self, amb: float) -> None: - self.sendFloatCommand(position=SET_AMB, value=amb) - - def set_humidity(self, humidity: float) -> None: - self.sendFloatCommand(position=SET_HUMIDITY, value=humidity) - - def set_emissivity(self, emiss: float) -> None: - self.sendFloatCommand(position=SET_EMISSIVITY, value=emiss) - - def set_distance(self, distance: int) -> None: - self.sendUshortCommand(position=SET_DISTANCE, value=distance) - - ''' Control methods''' - # send command and a float value to camera - def sendFloatCommand(self, position: int, value: float) -> None: - # Split float to 4 bytes - b0, b1, b2, b3 = np.array([value], dtype=np.float32).view(np.uint8) - - positionAndValue0 = (position << 8) | (0x000000ff & b0) - if not self.cap.set(cv2.CAP_PROP_ZOOM, positionAndValue0): - print("Control fail {}".format(positionAndValue0)) - - positionAndValue1 = ((position + 1) << 8) | (0x000000ff & b1) - if not self.cap.set(cv2.CAP_PROP_ZOOM, positionAndValue1): - print("Control fail {}".format(positionAndValue1)) - - positionAndValue2 = ((position + 2) << 8) | (0x000000ff & b2) - if not self.cap.set(cv2.CAP_PROP_ZOOM, positionAndValue2): - print("Control fail {}".format(positionAndValue2)) - - positionAndValue3 = ((position + 3) << 8) | (0x000000ff & b3) - if not self.cap.set(cv2.CAP_PROP_ZOOM, positionAndValue3): - print("Control fail {}".format(positionAndValue3)) - - # send command and 16 bit value to camera - def sendUshortCommand(self, position: int, value: int) -> None: - value0, value1 = np.array([value], dtype=np.uint16).view(np.uint8) - positionAndValue0 = (position << 8) | (0x000000ff & value0) - if not self.cap.set(cv2.CAP_PROP_ZOOM, positionAndValue0): - print("Control fail {}".format(positionAndValue0)) - - positionAndValue1 = ((position + 1) << 8) | (0x000000ff & value1) - if not self.cap.set(cv2.CAP_PROP_ZOOM, positionAndValue1): - print("Control fail {}".format(positionAndValue1)) - - # send command and byte value to camera - def sendByteCommand(self, position: int, value: int) -> None: - value0 = np.array([value], dtype=np.uint8)[0] - psitionAndValue0 = (position << 8) | (0x000000ff & value0) - self.cap.set(cv2.CAP_PROP_ZOOM, psitionAndValue0) - - # save set parameters - def save_parameters(self) -> None: - self.cap.set(cv2.CAP_PROP_ZOOM, 0x80ff) - - # set custom point to measure temperature - def set_point(self, x: int, y: int, index: int) -> None: - match index: - case 0: - x1 = 0xf000 + x - y1 = 0xf200 + y - case 1: - x1 = 0xf400 + x - y1 = 0xf600 + y - case 2: - x1 = 0xf800 + x - y1 = 0xfa00 + y - case _: - raise ValueError("Invalid index: {}.\nCan only set 3 custom points to measure temperature at indexes: {}, {}, {}".format(index, 0, 1, 2)) - - self.cap.set(cv2.CAP_PROP_ZOOM, x1) - self.cap.set(cv2.CAP_PROP_ZOOM, y1) - - def calibrate(self) -> None: - '''camera calibration''' - self.cap.set(cv2.CAP_PROP_ZOOM, 0x8000) - - def release(self) -> None: - ''' Release cap opencv ''' - self.cap.release() - - def init_parameters(self) -> None: - ''' Initalize parameters based on thermal camera resolution ''' - match self.width: - case 640: - self.fpa_off = 6867 - self.fpa_div = 33.8 - self.amountPixels = self.width * 3 - case 384: - self.fpa_off = 7800 - self.fpa_div = 36.0 - self.amountPixels = self.width * 3 - case 256: - self.fpa_off = 8617 - self.fpa_div = 37.682 - self.amountPixels = self.width - self.cal_00_offset = 170.0 - self.cal_00_fpamul = 0.0 - case 240: - self.fpa_off = 7800 - self.fpa_div = 36.0 - self.amountPixels = self.width - case _: raise ValueError("{} does not match supported device".format(self.width)) - - - - ''' Temperature calculation ''' - # Water vapor coefficient from humidity and ambient temperature - def wvc(self, h: float, t_atm: float): - h1 = 1.5587 - h2 = 0.06939 - h3 = -2.7816e-4 - h4 = 6.8455e-7 - return h * math.exp(h1 + h2 * t_atm + h3 * math.pow(t_atm, 2) + h4 * math.pow(t_atm, 3)) - - # Transmittance of the atmosphere from humitity, ambient temperature and distance. - def atmt(self, h: float, t_atm: float, d: float): - k_atm = 1.9 - nsqd = -math.sqrt(d) - sqw = math.sqrt(self.wvc(h, t_atm)) - - '''Athmospheric attenuation without water vapor''' - a1 = 0.006569 - a2 = 0.01262 - - '''Attenuation for water vapor.''' - b1 = -0.002276 - b2 = -0.00667 - return k_atm * math.exp(nsqd * (a1 + b1 * sqw)) + (1.0 - k_atm) * math.exp(nsqd * (a2 + b2 * sqw)) - - # calculate temperature table - # for each 16 bit value from frame data will return correspond temperture value - def get_temp_table(self, correction, Airtmp, table_offset, distance_adjusted): - ''' x: uint16 ''' - n = np.sqrt(np.abs(((np.arange(16384, dtype=np.float32) - table_offset) * self.cal_d + self.cal_c) / self.cal_01 + self.cal_b)) - n[np.isnan(n)] = 0.0 - wtot = np.power(n - self.cal_a + self.ZEROC, 4) - ttot = np.power((wtot - self.numerator_sub) / self.denominator, 0.25) - self.ZEROC - temperatureTable = ttot + (distance_adjusted * 0.85 - 1.125) * (ttot - Airtmp) / 100.0 + correction - return self.correction_coefficient_m * temperatureTable + self.correction_coefficient_b - - def temperature_range_normal(self): - """Switch camera to the normal temperature range (-20°C to 120°C)""" - self.cap.set(cv2.CAP_PROP_ZOOM, 0x8020) - self.correction_coefficient_m = 1 - self.correction_coefficient_b = 0 - - def temperature_range_high(self): - """Switch camera to the high temperature range (-20°C to 450°C)""" - self.cap.set(cv2.CAP_PROP_ZOOM, 0x8021) - self.correction_coefficient_m = 1.17 - self.correction_coefficient_b = -40.9 - - -class MockVidoCapture: - def set(self, propId, value): - setattr(self, str(propId), value) - def get(self, propId): - return getattr(self, str(propId)) - - -class CameraEmulator(Camera): - - def __init__(self, filename): - frame_raw_u16 = np.load(filename, allow_pickle=True) - self.cap = MockVidoCapture() - self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, frame_raw_u16.shape[0]) - self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, frame_raw_u16.shape[1]) - self.frame_raw_u16 = frame_raw_u16.ravel() - super().__init__(video_dev=self.cap) - def read(self) -> Tuple[bool, np.ndarray]: - ret = True - frame_visible = self.frame_raw_u16[:self.fourLinePara].copy().reshape(self.height, self.width) - return ret, frame_visible diff --git a/irpythermal.py b/irpythermal.py new file mode 100755 index 0000000..272a163 --- /dev/null +++ b/irpythermal.py @@ -0,0 +1,739 @@ +#!/usr/bin/env python3 + +import math +import time +from sys import platform +from time import sleep +from typing import Tuple + +import os + +import cv2 +import numpy as np + +SET_CORRECTION = 0 * 4 +SET_REFLECTION = 1 * 4 +SET_AMB = 2 * 4 +SET_HUMIDITY = 3 * 4 +SET_EMISSIVITY = 4 * 4 +SET_DISTANCE = 5 * 4 + +ROWS_SPECIAL_DATA = 4 + + +def read_u16(arr_u16, offset): + """arr: np.uint16""" + return arr_u16[offset] + + +def read_f32(arr_u16, offset, step=2): + """arr: np.uint16""" + return arr_u16[offset : offset + step].view(np.float32)[0] + + +def read_u8(arr_u16, offset, step): + return arr_u16[offset : offset + step].view(np.uint8) + + +class Camera: + """Class for reading data from the XTherm/HT301/InfiRay thermal cameras""" + + supported_resolutions = {(240, 180), (256, 192), (384, 288), (640, 512)} + ZEROC = 273.15 + distance_multiplier = 1.0 + offset_temp_shutter = 0.0 + offset_temp_fpa = 0.0 + + range = 120 + cal_00_offset = 390.0 + cal_00_fpamul = 7.05 + + correction_coefficient_m = 1 + correction_coefficient_b = 0 + + height: int + width: int + frame_width: int + frame_height: int + frame: np.ndarray + frame_raw: np.ndarray + meta: np.ndarray + device_strings: Tuple[str, str, str, str, str, str] + userArea: int + amountPixels: int + fourLinePara: int + cap: cv2.VideoCapture + frame_raw_u16: np.ndarray + + camera_raw = False + reference_frame = None + offset_mean = 0.0 + dead_pixels_mask = None + calibration_path = None + + def __init__( + self, + video_dev: cv2.VideoCapture | None = None, + camera_raw=False, + fixed_offset: float = 0.0, + ) -> None: + if video_dev is None: + video_dev = self.find_device() + if not video_dev: + raise Exception("No video device found!") + self.cap = video_dev + self.camera_raw = camera_raw + self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - ROWS_SPECIAL_DATA + self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + self.frame_rate = self.cap.get(cv2.CAP_PROP_FPS) + + self.userOffset = fixed_offset + + self.fourLinePara = self.width * self.height + self.init_parameters() + self.userArea = self.amountPixels + 127 + + # Decide whether or not convert data to RGB + self.cap.set(cv2.CAP_PROP_CONVERT_RGB, 0) + + # using Raw mode 16 bit data + self.cap.set(cv2.CAP_PROP_ZOOM, 0x8004) + + # Wait for the camera to apply the temperature range change + self.wait_for_range_application() + + # Calibrate the camera + self.calibrate() + + def get_resolution(self) -> Tuple[int, int]: + return self.width, self.height + + def find_device(cls) -> cv2.VideoCapture: + """Find a supported thermal camera + + Optionally narrow down the resultion of the camera too look for.""" + for i in range(10): + try: + if platform.startswith("linux"): + cap = cv2.VideoCapture(i, cv2.CAP_V4L2) + else: + cap = cv2.VideoCapture(i) + + cap_width = cap.get(cv2.CAP_PROP_FRAME_WIDTH) + cap_height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) + print( + f"Found a camera {i} with resolution {int(cap_width)}x{int(cap_height)}" + ) + + if ( + cap_width, + cap_height - ROWS_SPECIAL_DATA, + ) in cls.supported_resolutions: + return cap + cap.release() + except Exception: + pass + raise ValueError( + f"Cannot find camera with a width of one of {cls.supported_resolutions} that also matches: {cap_width=} and {cap_height=}" + ) + + def bin_to_twos_complement(self, binary: str) -> int: + if binary[0] == "1": + return int(binary, 2) - 2 ** len(binary) + return int(binary, 2) + + def info(self) -> Tuple[dict, np.ndarray]: + shutTemper = read_u16( + self.frame_raw_u16, self.fourLinePara + self.amountPixels + 1 + ) # 257 + if self.camera_raw: + if shutTemper < 2049: + floatShutTemper = float(shutTemper) + corrFactor = 0.625 + else: + floatShutTemper = float(0xFFF - shutTemper) + corrFactor = -0.625 + shutterFix = ( + self.bin_to_twos_complement( + ( + bin( + read_u16( + self.frame_raw_u16, + self.fourLinePara + self.amountPixels * 2 + 47, + ) + )[2:].zfill(16) + )[:8] + ) + / 10.0 + ) # 559 + floatShutTemper = (floatShutTemper * corrFactor + 2731.5) / 10.0 + -273.15 + floatShutTemper = floatShutTemper + shutterFix + # TODO fix this readout for the T2S+ v2 + # The temperature is indeed being red out, but the sensor is located in some weird place, + # it gets hot super fast and causes the entire image to drift, I couldn't figure out how to deal with that + # hard coding ~18C (room temp) works pretty good tho... + # floatShutTemper = 18.0 + else: + floatShutTemper = shutTemper / 10.0 - self.ZEROC + + coreTemper = read_u16( + self.frame_raw_u16, self.fourLinePara + self.amountPixels + 2 + ) # 258 + if self.camera_raw: + # TODO fix this readout for the T2S+ v2 + # I don't even think the v2 has a separate core and shutter temperature registers... + # floatCoreTemper = 18.0 + floatCoreTemper = floatShutTemper - shutterFix + # floatShutTemper = 18.0 + else: + floatCoreTemper = coreTemper / 10.0 - self.ZEROC + + # print(f"Shutter temperature: {floatShutTemper}°C, Core temperature: {coreTemper / 10.0 - self.ZEROC}°C") + + # TODO check all of these + cal_00 = float( + read_u16(self.frame_raw_u16, self.fourLinePara + self.amountPixels) + ) # 256 + self.cal_01 = read_f32( + self.frame_raw_u16, self.fourLinePara + self.amountPixels + 3 + ) # 259 + cal_02 = read_f32( + self.frame_raw_u16, self.fourLinePara + self.amountPixels + 5 + ) # 261 + cal_03 = read_f32( + self.frame_raw_u16, self.fourLinePara + self.amountPixels + 7 + ) # 263 + cal_04 = read_f32( + self.frame_raw_u16, self.fourLinePara + self.amountPixels + 9 + ) # 265 + cal_05 = read_f32( + self.frame_raw_u16, self.fourLinePara + self.amountPixels + 11 + ) # 267 + + cameraSoftVersion: np.ndarray = read_u8( + self.frame_raw_u16, self.fourLinePara + self.amountPixels + 24, step=8 + ) # 280-287? + cameraSoftVersion = cameraSoftVersion.tobytes().decode("ascii").rstrip("\x00") + + # this one is reading out incorrectly? + sn: np.ndarray = read_u8( + self.frame_raw_u16, self.fourLinePara + self.amountPixels + 32, step=3 + ) # 288-290? + sn = sn.tobytes().decode("ascii").rstrip("\x00") + + correction = read_f32( + self.frame_raw_u16, self.fourLinePara + self.userArea + ) # 383 + Refltmp = read_f32( + self.frame_raw_u16, self.fourLinePara + self.userArea + 2 + ) # 385 + Airtmp = read_f32( + self.frame_raw_u16, self.fourLinePara + self.userArea + 4 + ) # 387 + humi = read_f32( + self.frame_raw_u16, self.fourLinePara + self.userArea + 6 + ) # 389 + emiss = read_f32( + self.frame_raw_u16, self.fourLinePara + self.userArea + 8 + ) # 391 + distance = read_u16( + self.frame_raw_u16, self.fourLinePara + self.userArea + 10 + ) # 393 + + fpa_avg = read_u16(self.frame_raw_u16, self.fourLinePara) # 0 + fpaTmp = read_u16(self.frame_raw_u16, self.fourLinePara + 1) # 1 + maxx1 = read_u16(self.frame_raw_u16, self.fourLinePara + 2) # 2 + maxy1 = read_u16(self.frame_raw_u16, self.fourLinePara + 3) # 3 + self.max_raw = read_u16(self.frame_raw_u16, self.fourLinePara + 4) # 4 + minx1 = read_u16(self.frame_raw_u16, self.fourLinePara + 5) # 5 + miny1 = read_u16(self.frame_raw_u16, self.fourLinePara + 6) # 6 + self.min_raw = read_u16(self.frame_raw_u16, self.fourLinePara + 7) # 7 + self.avg_raw = read_u16(self.frame_raw_u16, self.fourLinePara + 8) # 8 + + fpatmp_ = 20.0 - (float(fpaTmp) - self.fpa_off) / self.fpa_div + + center_raw = read_u16(self.frame_raw_u16, self.fourLinePara + 12) # 12 + user_raw00 = read_u16(self.frame_raw_u16, self.fourLinePara + 13) # 13 + user_raw01 = read_u16(self.frame_raw_u16, self.fourLinePara + 14) # 14 + user_raw02 = read_u16(self.frame_raw_u16, self.fourLinePara + 15) # 15 + + distance_adjusted = ( + 20.0 if distance >= 20.0 else distance + ) * self.distance_multiplier + atm = self.atmt(humi, Airtmp, distance_adjusted) + self.numerator_sub = (1.0 - emiss) * atm * math.pow(Refltmp + self.ZEROC, 4) + ( + 1.0 - atm + ) * math.pow(Airtmp + self.ZEROC, 4) + self.denominator = emiss * atm + + ts = floatShutTemper + self.offset_temp_shutter + tfpa = fpatmp_ + self.offset_temp_fpa + + self.cal_a = cal_02 / (self.cal_01 + self.cal_01) + self.cal_b = cal_02 * cal_02 / (self.cal_01 * self.cal_01 * 4.0) + self.cal_c = self.cal_01 * math.pow(ts, 2) + ts * cal_02 + self.cal_d = cal_03 * math.pow(tfpa, 2) + cal_04 * tfpa + cal_05 + + cal_00_corr = 0 + + if self.range == 120: + cal_00_corr = int(self.cal_00_offset - tfpa * self.cal_00_fpamul) + + table_offset = cal_00 - (cal_00_corr if cal_00_corr > 0 else 0) + + temperatureTable = self.get_temp_table( + correction, Airtmp, table_offset, distance_adjusted + ) + + temperatureTable = temperatureTable + self.userOffset + + """ build infomation """ + info = { + "temp_shutter": floatShutTemper, + "temp_core": floatCoreTemper, + "cameraSoftVersion": cameraSoftVersion, + "sn": sn, + "correction": correction, + "temp_reflected": Refltmp, + "temp_air": Airtmp, + "humidity": humi, + "emissivity": emiss, + "distance": distance, + "fpa_average": fpa_avg, + "temp_fpa": fpatmp_, + "temp_max_x": maxx1, + "temp_max_y": maxy1, + "temp_max_raw": self.max_raw, + "temp_max": temperatureTable[self.max_raw], + "temp_min_x": minx1, + "temp_min_y": miny1, + "temp_min_raw": self.min_raw, + "temp_min": temperatureTable[self.min_raw], + "temp_average_raw": self.avg_raw, + "temp_average": temperatureTable[self.avg_raw], + "temp_center_raw": center_raw, + "temp_center": temperatureTable[center_raw], + "temp_user_00": temperatureTable[user_raw00], + "temp_user_01": temperatureTable[user_raw01], + "temp_user_02": temperatureTable[user_raw02], + "Tmin_point": (minx1, miny1), + "Tmax_point": (maxx1, maxy1), + "Tcenter_point": (self.width // 2, self.height // 2), + "Tmin_C": temperatureTable[self.min_raw], + "Tmax_C": temperatureTable[self.max_raw], + "Tcenter_C": temperatureTable[center_raw], + } + + return info, temperatureTable + + # read raw data from cam, seperate visible frame from metadata + def read(self, raw=False, max_retries=5, retry_delay=0.5) -> Tuple[bool, np.ndarray]: + """Returns a raw frame from the camera, retrying if the frame is incomplete.""" + for attempt in range(max_retries): + ret, frame_raw = self.cap.read() + if not ret or frame_raw is None: + time.sleep(retry_delay) + continue + + frame_data = frame_raw.view(np.uint16).ravel() + expected_size = self.height * self.width + if frame_data.size < expected_size: + # Incomplete frame, wait and retry + print( + f"[warn] Incomplete frame (got {frame_data.size}, expected {expected_size}), retrying {attempt+1}/{max_retries}" + ) + time.sleep(retry_delay) + continue + + try: + frame_visible = frame_data[: self.fourLinePara].copy().reshape( + self.height, self.width + ) + break + except ValueError: + print( + f"[warn] Reshape failed (array size {frame_data.size}), retrying {attempt+1}/{max_retries}" + ) + time.sleep(retry_delay) + continue + else: + # If we exit the loop without success + raise RuntimeError("Failed to read a complete frame from camera") + + self.frame_raw_u16: np.ndarray = frame_raw.view(np.uint16).ravel() + + if raw: + return ret, frame_visible + if self.reference_frame is not None: + frame_float = frame_visible.astype(np.float32) + + corrected_frame = frame_float - self.reference_frame + self.offset_mean + + corrected_frame = np.clip(corrected_frame, 0, 65535) + + if self.dead_pixels_mask is not None: + inpaint_radius = 3 + corrected_frame = cv2.inpaint( + corrected_frame, + self.dead_pixels_mask, + inpaint_radius, + cv2.INPAINT_TELEA, + ) + + frame_visible = corrected_frame.astype(np.uint16) + + return ret, frame_visible + + def get_frame(self) -> np.ndarray: + """Returns a frame with temperature values""" + ret, frame = self.read() + info, lut = self.info() + lut_frame = lut[frame] + return lut_frame + + def convert_to_frame(self, frame_raw: np.ndarray, lut: np.ndarray) -> np.ndarray: + """Converts a raw frame to a frame with temperature values""" + return lut[frame_raw] + + def set_correction(self, correction: float) -> None: + self.sendFloatCommand(position=SET_CORRECTION, value=correction) + + def set_reflection(self, reflection: float) -> None: + self.sendFloatCommand(position=SET_REFLECTION, value=reflection) + + def set_amb(self, amb: float) -> None: + self.sendFloatCommand(position=SET_AMB, value=amb) + + def set_humidity(self, humidity: float) -> None: + self.sendFloatCommand(position=SET_HUMIDITY, value=humidity) + + def set_emissivity(self, emiss: float) -> None: + self.sendFloatCommand(position=SET_EMISSIVITY, value=emiss) + + def set_distance(self, distance: int) -> None: + self.sendUshortCommand(position=SET_DISTANCE, value=distance) + + """ Control methods""" + + # send command and a float value to camera + def sendFloatCommand(self, position: int, value: float) -> None: + # Split float to 4 bytes + b0, b1, b2, b3 = np.array([value], dtype=np.float32).view(np.uint8) + + positionAndValue0 = (position << 8) | (0x000000FF & b0) + if not self.cap.set(cv2.CAP_PROP_ZOOM, positionAndValue0): + print("Control fail {}".format(positionAndValue0)) + + positionAndValue1 = ((position + 1) << 8) | (0x000000FF & b1) + if not self.cap.set(cv2.CAP_PROP_ZOOM, positionAndValue1): + print("Control fail {}".format(positionAndValue1)) + + positionAndValue2 = ((position + 2) << 8) | (0x000000FF & b2) + if not self.cap.set(cv2.CAP_PROP_ZOOM, positionAndValue2): + print("Control fail {}".format(positionAndValue2)) + + positionAndValue3 = ((position + 3) << 8) | (0x000000FF & b3) + if not self.cap.set(cv2.CAP_PROP_ZOOM, positionAndValue3): + print("Control fail {}".format(positionAndValue3)) + + # send command and 16 bit value to camera + def sendUshortCommand(self, position: int, value: int) -> None: + value0, value1 = np.array([value], dtype=np.uint16).view(np.uint8) + positionAndValue0 = (position << 8) | (0x000000FF & value0) + if not self.cap.set(cv2.CAP_PROP_ZOOM, positionAndValue0): + print("Control fail {}".format(positionAndValue0)) + + positionAndValue1 = ((position + 1) << 8) | (0x000000FF & value1) + if not self.cap.set(cv2.CAP_PROP_ZOOM, positionAndValue1): + print("Control fail {}".format(positionAndValue1)) + + # send command and byte value to camera + def sendByteCommand(self, position: int, value: int) -> None: + value0 = np.array([value], dtype=np.uint8)[0] + psitionAndValue0 = (position << 8) | (0x000000FF & value0) + self.cap.set(cv2.CAP_PROP_ZOOM, psitionAndValue0) + + # save set parameters + def save_parameters(self) -> None: + self.cap.set(cv2.CAP_PROP_ZOOM, 0x80FF) + + # set custom point to measure temperature + def set_point(self, x: int, y: int, index: int) -> None: + match index: + case 0: + x1 = 0xF000 + x + y1 = 0xF200 + y + case 1: + x1 = 0xF400 + x + y1 = 0xF600 + y + case 2: + x1 = 0xF800 + x + y1 = 0xFA00 + y + case _: + raise ValueError( + "Invalid index: {}.\nCan only set 3 custom points to measure temperature at indexes: {}, {}, {}".format( + index, 0, 1, 2 + ) + ) + + self.cap.set(cv2.CAP_PROP_ZOOM, x1) + self.cap.set(cv2.CAP_PROP_ZOOM, y1) + + def calibrate_raw(self, quiet=False) -> None: + """Camera calibration for cameras that return raw data only""" + self.reference_frame = None + self.offset_mean = 0.0 + self.dead_pixels_mask = None + # uniformity correction + sleep(0.5) + self.cap.set(cv2.CAP_PROP_ZOOM, 0x8000) # close shutter + sleep(0.3) # wait for the shutter to close + self.flush_buffer() + # by issuing this command faster than once per second, we can keep the shutter closed + self.cap.set(cv2.CAP_PROP_ZOOM, 0x8000) + ret, frame_visible = self.read(raw=True) + + if ret: + self.reference_frame = frame_visible.astype(np.float32) + self.offset_mean = np.mean(self.reference_frame) + else: + raise RuntimeError("Failed to capture reference frame") + + # dead/hot pixel correction using symmetric threshold + frame_visible_float = frame_visible.astype(np.float32) + min_val = np.min(frame_visible_float) + max_val = np.max(frame_visible_float) + val_range = max_val - min_val + if not quiet: + print(f"Min: {min_val}, Max: {max_val}, Avg: {np.mean(frame_visible_float)}") + threshold_margin = val_range * 0.05 + + low_threshold = min_val + threshold_margin + high_threshold = max_val - threshold_margin + + # detect cold/dead pixels AND hot/stuck pixels + cold_mask = cv2.inRange( + frame_visible_float, 0, float(low_threshold) + ).astype(np.uint8) + hot_mask = cv2.inRange( + frame_visible_float, float(high_threshold), float(max_val + 1) + ).astype(np.uint8) + combined_mask = cold_mask | hot_mask + + if np.count_nonzero(combined_mask) > 0: + self.dead_pixels_mask = combined_mask + + if not quiet: + n_cold = np.count_nonzero(cold_mask) + n_hot = np.count_nonzero(hot_mask) + print(f"Found {n_cold} dead pixels, {n_hot} hot pixels") + + if self.calibration_path: + self.save_calibration(self.calibration_path) + + def calibrate(self, quiet=False) -> None: + """camera calibration""" + if self.camera_raw: + self.calibrate_raw(quiet=quiet) + else: + self.cap.set(cv2.CAP_PROP_ZOOM, 0x8000) + + def release(self) -> None: + """Release cap opencv""" + self.cap.release() + + def init_parameters(self) -> None: + """Initalize parameters based on thermal camera resolution""" + match self.width: + case 640: + self.fpa_off = 6867 + self.fpa_div = 33.8 + self.amountPixels = self.width * 3 + case 384: + self.fpa_off = 7800 + self.fpa_div = 36.0 + self.amountPixels = self.width * 3 + case 256: + self.fpa_off = 8617 + self.fpa_div = 37.682 + self.amountPixels = self.width + self.cal_00_offset = 170.0 + self.cal_00_fpamul = 0.0 + case 240: + self.fpa_off = 7800 + self.fpa_div = 36.0 + self.amountPixels = self.width + case _: + raise ValueError( + "{} does not match supported device".format(self.width) + ) + + """ Temperature calculation """ + + # Water vapor coefficient from humidity and ambient temperature + def wvc(self, h: float, t_atm: float): + h1 = 1.5587 + h2 = 0.06939 + h3 = -2.7816e-4 + h4 = 6.8455e-7 + return h * math.exp( + h1 + h2 * t_atm + h3 * math.pow(t_atm, 2) + h4 * math.pow(t_atm, 3) + ) + + # Transmittance of the atmosphere from humitity, ambient temperature and distance. + def atmt(self, h: float, t_atm: float, d: float): + k_atm = 1.9 + nsqd = -math.sqrt(d) + sqw = math.sqrt(self.wvc(h, t_atm)) + + """Athmospheric attenuation without water vapor""" + a1 = 0.006569 + a2 = 0.01262 + + """Attenuation for water vapor.""" + b1 = -0.002276 + b2 = -0.00667 + return k_atm * math.exp(nsqd * (a1 + b1 * sqw)) + (1.0 - k_atm) * math.exp( + nsqd * (a2 + b2 * sqw) + ) + + # calculate temperature table + # for each 16 bit value from frame data will return correspond temperture value + def get_temp_table(self, correction, Airtmp, table_offset, distance_adjusted): + """x: uint16""" + n = np.sqrt( + np.abs( + ( + (np.arange(16384, dtype=np.float32) - table_offset) * self.cal_d + + self.cal_c + ) + / self.cal_01 + + self.cal_b + ) + ) + n[np.isnan(n)] = 0.0 + wtot = np.power(n - self.cal_a + self.ZEROC, 4) + ttot = ( + np.power((wtot - self.numerator_sub) / self.denominator, 0.25) - self.ZEROC + ) + # TODO, ttot is being directly read out according to the decomp, how is it calculated here exactly? (fVar7) offset ~269 + temperatureTable = ( + ttot + + (distance_adjusted * 0.85 - 1.125) * (ttot - Airtmp) / 100.0 + + correction + ) + return ( + self.correction_coefficient_m * temperatureTable + + self.correction_coefficient_b + ) + + def temperature_range_normal(self): + """Switch camera to the normal temperature range (-20°C to 120°C)""" + self.cap.set(cv2.CAP_PROP_ZOOM, 0x8020) + self.correction_coefficient_m = 1 + self.correction_coefficient_b = 0 + + def temperature_range_high(self): + """Switch camera to the high temperature range (-20°C to 450°C)""" + self.cap.set(cv2.CAP_PROP_ZOOM, 0x8021) + if self.camera_raw: + # TODO verify these + self.correction_coefficient_m = 0.1 + self.correction_coefficient_b = 0 + return + self.correction_coefficient_m = 1.17 + self.correction_coefficient_b = -40.9 + + def wait_for_range_application(self, timeout=20): + """Wait for the camera to apply the temperature range change""" + print("Waiting for camera to stabilize...") + start_time = time.time() + done = False + while time.time() - start_time < timeout: + ret, frame_visible = self.read() + if ret and np.std(frame_visible) > 0: + done = True + break + time.sleep(0.1) + + if self.camera_raw: + # Now we keep the shutter closed and wait for the camera to stabilize, + # we do this by running the calibration, waiting a bit and checking the average + # of all the pixels, when the change gets below a certain threshold we can consider + # the camera to be stable. + # Throughout this routine we keep the shutter closed. + lowest = 1000 + margin = 0.1 + min_val = 0.01 + while time.time() - start_time < timeout: + self.cap.set(cv2.CAP_PROP_ZOOM, 0x8000) + self.calibrate(quiet=True) + ret, frame_visible = self.read() + if ret: + # calculate how uniform the frame is + std = np.std(frame_visible) + self.cap.set(cv2.CAP_PROP_ZOOM, 0x8000) + sleep(0.1) + if std > min_val and lowest - std < margin: + print(f"Camera is stable with std: {std}") + return True + + if std < lowest and std > min_val: + lowest = std + + elif done: + print("Camera is stable") + return True + + return False + + def flush_buffer(self, num_reads=16): + for i in range(num_reads): + ret, frame_visible = self.read(raw=True) + + def save_calibration(self, path) -> None: + """Save calibration data (reference frame + dead pixel mask) to disk.""" + np.save(f"{path}_reference.npy", self.reference_frame) + if self.dead_pixels_mask is not None: + np.save(f"{path}_deadpixels.npy", self.dead_pixels_mask) + print(f"Calibration saved to {path}_*.npy") + + def load_calibration(self, path) -> bool: + """Load calibration data from disk. Returns True if successful.""" + ref_file = f"{path}_reference.npy" + if not os.path.exists(ref_file): + return False + self.reference_frame = np.load(ref_file) + self.offset_mean = float(np.mean(self.reference_frame)) + dp_file = f"{path}_deadpixels.npy" + if os.path.exists(dp_file): + self.dead_pixels_mask = np.load(dp_file) + print(f"Calibration loaded from {path}_*.npy") + return True + + +class MockVidoCapture: + def set(self, propId, value): + setattr(self, str(propId), value) + + def get(self, propId): + return getattr(self, str(propId)) + + +class CameraEmulator(Camera): + def __init__(self, filename): + frame_raw_u16 = np.load(filename, allow_pickle=True) + self.cap = MockVidoCapture() + self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, frame_raw_u16.shape[0]) + self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, frame_raw_u16.shape[1]) + self.frame_raw_u16 = frame_raw_u16.ravel() + super().__init__(video_dev=self.cap) + + def read(self) -> Tuple[bool, np.ndarray]: + ret = True + frame_visible = ( + self.frame_raw_u16[: self.fourLinePara] + .copy() + .reshape(self.height, self.width) + ) + return ret, frame_visible diff --git a/opencv.py b/opencv.py index bcf9ae6..b820153 100755 --- a/opencv.py +++ b/opencv.py @@ -1,16 +1,18 @@ +#!/usr/bin/env python3 + +import pickle +import time -#!/usr/bin/python3 -import numpy as np import cv2 -import ht301_hacklib +import numpy as np + +import irpythermal import utils -import time -from skimage.exposure import rescale_intensity, equalize_hist -import pickle + draw_temp = True # cap = ht301_hacklib.HT301() -camera = ht301_hacklib.Camera() +camera = irpythermal.Camera() window_name = str(type(camera).__name__) cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) @@ -97,10 +99,9 @@ def get_fps(self): info, lut = camera.info() frame = frame.astype(np.float32) - # Sketchy auto-exposure - frame = rescale_intensity( - equalize_hist(frame), in_range="image", out_range=(0, 255) - ).astype(np.uint8) + # Auto-exposure: normalize + histogram equalization + frame = cv2.normalize(frame, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) + frame = cv2.equalizeHist(frame) frame = cv2.applyColorMap(frame, cv2.COLORMAP_INFERNO) @@ -173,7 +174,7 @@ def get_fps(self): # some delay is needed before calibration for _ in range(50): camera.read() - camera.calibrate() + camera.calibrate() if key == ord("s"): cv2.imwrite(time.strftime("%Y-%m-%d_%H-%M-%S") + ".png", frame) if key == ord("o"): @@ -183,7 +184,7 @@ def get_fps(self): if key == ord("a"): # save to disk ret, frame = camera.cap.read() - data = (frame) + data = frame name = time.strftime("%Y-%m-%d_%H-%M-%S") + ".pkl" with open(name, "wb") as f: pickle.dump(data, f) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..4a2d62b --- /dev/null +++ b/poetry.lock @@ -0,0 +1,624 @@ +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. + +[[package]] +name = "contourpy" +version = "1.3.1" +description = "Python library for calculating contours of 2D quadrilateral grids" +optional = false +python-versions = ">=3.10" +files = [ + {file = "contourpy-1.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a045f341a77b77e1c5de31e74e966537bba9f3c4099b35bf4c2e3939dd54cdab"}, + {file = "contourpy-1.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:500360b77259914f7805af7462e41f9cb7ca92ad38e9f94d6c8641b089338124"}, + {file = "contourpy-1.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2f926efda994cdf3c8d3fdb40b9962f86edbc4457e739277b961eced3d0b4c1"}, + {file = "contourpy-1.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adce39d67c0edf383647a3a007de0a45fd1b08dedaa5318404f1a73059c2512b"}, + {file = "contourpy-1.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abbb49fb7dac584e5abc6636b7b2a7227111c4f771005853e7d25176daaf8453"}, + {file = "contourpy-1.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0cffcbede75c059f535725c1680dfb17b6ba8753f0c74b14e6a9c68c29d7ea3"}, + {file = "contourpy-1.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab29962927945d89d9b293eabd0d59aea28d887d4f3be6c22deaefbb938a7277"}, + {file = "contourpy-1.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:974d8145f8ca354498005b5b981165b74a195abfae9a8129df3e56771961d595"}, + {file = "contourpy-1.3.1-cp310-cp310-win32.whl", hash = "sha256:ac4578ac281983f63b400f7fe6c101bedc10651650eef012be1ccffcbacf3697"}, + {file = "contourpy-1.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:174e758c66bbc1c8576992cec9599ce8b6672b741b5d336b5c74e35ac382b18e"}, + {file = "contourpy-1.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3e8b974d8db2c5610fb4e76307e265de0edb655ae8169e8b21f41807ccbeec4b"}, + {file = "contourpy-1.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20914c8c973f41456337652a6eeca26d2148aa96dd7ac323b74516988bea89fc"}, + {file = "contourpy-1.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19d40d37c1c3a4961b4619dd9d77b12124a453cc3d02bb31a07d58ef684d3d86"}, + {file = "contourpy-1.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:113231fe3825ebf6f15eaa8bc1f5b0ddc19d42b733345eae0934cb291beb88b6"}, + {file = "contourpy-1.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dbbc03a40f916a8420e420d63e96a1258d3d1b58cbdfd8d1f07b49fcbd38e85"}, + {file = "contourpy-1.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a04ecd68acbd77fa2d39723ceca4c3197cb2969633836ced1bea14e219d077c"}, + {file = "contourpy-1.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c414fc1ed8ee1dbd5da626cf3710c6013d3d27456651d156711fa24f24bd1291"}, + {file = "contourpy-1.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:31c1b55c1f34f80557d3830d3dd93ba722ce7e33a0b472cba0ec3b6535684d8f"}, + {file = "contourpy-1.3.1-cp311-cp311-win32.whl", hash = "sha256:f611e628ef06670df83fce17805c344710ca5cde01edfdc72751311da8585375"}, + {file = "contourpy-1.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:b2bdca22a27e35f16794cf585832e542123296b4687f9fd96822db6bae17bfc9"}, + {file = "contourpy-1.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ffa84be8e0bd33410b17189f7164c3589c229ce5db85798076a3fa136d0e509"}, + {file = "contourpy-1.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805617228ba7e2cbbfb6c503858e626ab528ac2a32a04a2fe88ffaf6b02c32bc"}, + {file = "contourpy-1.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade08d343436a94e633db932e7e8407fe7de8083967962b46bdfc1b0ced39454"}, + {file = "contourpy-1.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47734d7073fb4590b4a40122b35917cd77be5722d80683b249dac1de266aac80"}, + {file = "contourpy-1.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ba94a401342fc0f8b948e57d977557fbf4d515f03c67682dd5c6191cb2d16ec"}, + {file = "contourpy-1.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa874e87e4a647fd2e4f514d5e91c7d493697127beb95e77d2f7561f6905bd9"}, + {file = "contourpy-1.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1bf98051f1045b15c87868dbaea84f92408337d4f81d0e449ee41920ea121d3b"}, + {file = "contourpy-1.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:61332c87493b00091423e747ea78200659dc09bdf7fd69edd5e98cef5d3e9a8d"}, + {file = "contourpy-1.3.1-cp312-cp312-win32.whl", hash = "sha256:e914a8cb05ce5c809dd0fe350cfbb4e881bde5e2a38dc04e3afe1b3e58bd158e"}, + {file = "contourpy-1.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:08d9d449a61cf53033612cb368f3a1b26cd7835d9b8cd326647efe43bca7568d"}, + {file = "contourpy-1.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a761d9ccfc5e2ecd1bf05534eda382aa14c3e4f9205ba5b1684ecfe400716ef2"}, + {file = "contourpy-1.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:523a8ee12edfa36f6d2a49407f705a6ef4c5098de4f498619787e272de93f2d5"}, + {file = "contourpy-1.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece6df05e2c41bd46776fbc712e0996f7c94e0d0543af1656956d150c4ca7c81"}, + {file = "contourpy-1.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:573abb30e0e05bf31ed067d2f82500ecfdaec15627a59d63ea2d95714790f5c2"}, + {file = "contourpy-1.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fa36448e6a3a1a9a2ba23c02012c43ed88905ec80163f2ffe2421c7192a5d7"}, + {file = "contourpy-1.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ea9924d28fc5586bf0b42d15f590b10c224117e74409dd7a0be3b62b74a501c"}, + {file = "contourpy-1.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b75aa69cb4d6f137b36f7eb2ace9280cfb60c55dc5f61c731fdf6f037f958a3"}, + {file = "contourpy-1.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:041b640d4ec01922083645a94bb3b2e777e6b626788f4095cf21abbe266413c1"}, + {file = "contourpy-1.3.1-cp313-cp313-win32.whl", hash = "sha256:36987a15e8ace5f58d4d5da9dca82d498c2bbb28dff6e5d04fbfcc35a9cb3a82"}, + {file = "contourpy-1.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:a7895f46d47671fa7ceec40f31fae721da51ad34bdca0bee83e38870b1f47ffd"}, + {file = "contourpy-1.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9ddeb796389dadcd884c7eb07bd14ef12408aaae358f0e2ae24114d797eede30"}, + {file = "contourpy-1.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19c1555a6801c2f084c7ddc1c6e11f02eb6a6016ca1318dd5452ba3f613a1751"}, + {file = "contourpy-1.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:841ad858cff65c2c04bf93875e384ccb82b654574a6d7f30453a04f04af71342"}, + {file = "contourpy-1.3.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4318af1c925fb9a4fb190559ef3eec206845f63e80fb603d47f2d6d67683901c"}, + {file = "contourpy-1.3.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:14c102b0eab282427b662cb590f2e9340a9d91a1c297f48729431f2dcd16e14f"}, + {file = "contourpy-1.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05e806338bfeaa006acbdeba0ad681a10be63b26e1b17317bfac3c5d98f36cda"}, + {file = "contourpy-1.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4d76d5993a34ef3df5181ba3c92fabb93f1eaa5729504fb03423fcd9f3177242"}, + {file = "contourpy-1.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:89785bb2a1980c1bd87f0cb1517a71cde374776a5f150936b82580ae6ead44a1"}, + {file = "contourpy-1.3.1-cp313-cp313t-win32.whl", hash = "sha256:8eb96e79b9f3dcadbad2a3891672f81cdcab7f95b27f28f1c67d75f045b6b4f1"}, + {file = "contourpy-1.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:287ccc248c9e0d0566934e7d606201abd74761b5703d804ff3df8935f523d546"}, + {file = "contourpy-1.3.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b457d6430833cee8e4b8e9b6f07aa1c161e5e0d52e118dc102c8f9bd7dd060d6"}, + {file = "contourpy-1.3.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb76c1a154b83991a3cbbf0dfeb26ec2833ad56f95540b442c73950af2013750"}, + {file = "contourpy-1.3.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:44a29502ca9c7b5ba389e620d44f2fbe792b1fb5734e8b931ad307071ec58c53"}, + {file = "contourpy-1.3.1.tar.gz", hash = "sha256:dfd97abd83335045a913e3bcc4a09c0ceadbe66580cf573fe961f4a825efa699"}, +] + +[package.dependencies] +numpy = ">=1.23" + +[package.extras] +bokeh = ["bokeh", "selenium"] +docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"] +mypy = ["contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.11.1)", "types-Pillow"] +test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] +test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"] + +[[package]] +name = "cycler" +version = "0.12.1" +description = "Composable style cycles" +optional = false +python-versions = ">=3.8" +files = [ + {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, + {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, +] + +[package.extras] +docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] +tests = ["pytest", "pytest-cov", "pytest-xdist"] + +[[package]] +name = "fonttools" +version = "4.55.0" +description = "Tools to manipulate font files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fonttools-4.55.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:51c029d4c0608a21a3d3d169dfc3fb776fde38f00b35ca11fdab63ba10a16f61"}, + {file = "fonttools-4.55.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bca35b4e411362feab28e576ea10f11268b1aeed883b9f22ed05675b1e06ac69"}, + {file = "fonttools-4.55.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ce4ba6981e10f7e0ccff6348e9775ce25ffadbee70c9fd1a3737e3e9f5fa74f"}, + {file = "fonttools-4.55.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31d00f9852a6051dac23294a4cf2df80ced85d1d173a61ba90a3d8f5abc63c60"}, + {file = "fonttools-4.55.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e198e494ca6e11f254bac37a680473a311a88cd40e58f9cc4dc4911dfb686ec6"}, + {file = "fonttools-4.55.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7208856f61770895e79732e1dcbe49d77bd5783adf73ae35f87fcc267df9db81"}, + {file = "fonttools-4.55.0-cp310-cp310-win32.whl", hash = "sha256:e7e6a352ff9e46e8ef8a3b1fe2c4478f8a553e1b5a479f2e899f9dc5f2055880"}, + {file = "fonttools-4.55.0-cp310-cp310-win_amd64.whl", hash = "sha256:636caaeefe586d7c84b5ee0734c1a5ab2dae619dc21c5cf336f304ddb8f6001b"}, + {file = "fonttools-4.55.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fa34aa175c91477485c44ddfbb51827d470011e558dfd5c7309eb31bef19ec51"}, + {file = "fonttools-4.55.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:37dbb3fdc2ef7302d3199fb12468481cbebaee849e4b04bc55b77c24e3c49189"}, + {file = "fonttools-4.55.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5263d8e7ef3c0ae87fbce7f3ec2f546dc898d44a337e95695af2cd5ea21a967"}, + {file = "fonttools-4.55.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f307f6b5bf9e86891213b293e538d292cd1677e06d9faaa4bf9c086ad5f132f6"}, + {file = "fonttools-4.55.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f0a4b52238e7b54f998d6a56b46a2c56b59c74d4f8a6747fb9d4042190f37cd3"}, + {file = "fonttools-4.55.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3e569711464f777a5d4ef522e781dc33f8095ab5efd7548958b36079a9f2f88c"}, + {file = "fonttools-4.55.0-cp311-cp311-win32.whl", hash = "sha256:2b3ab90ec0f7b76c983950ac601b58949f47aca14c3f21eed858b38d7ec42b05"}, + {file = "fonttools-4.55.0-cp311-cp311-win_amd64.whl", hash = "sha256:aa046f6a63bb2ad521004b2769095d4c9480c02c1efa7d7796b37826508980b6"}, + {file = "fonttools-4.55.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:838d2d8870f84fc785528a692e724f2379d5abd3fc9dad4d32f91cf99b41e4a7"}, + {file = "fonttools-4.55.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f46b863d74bab7bb0d395f3b68d3f52a03444964e67ce5c43ce43a75efce9246"}, + {file = "fonttools-4.55.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33b52a9cfe4e658e21b1f669f7309b4067910321757fec53802ca8f6eae96a5a"}, + {file = "fonttools-4.55.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:732a9a63d6ea4a81b1b25a1f2e5e143761b40c2e1b79bb2b68e4893f45139a40"}, + {file = "fonttools-4.55.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7dd91ac3fcb4c491bb4763b820bcab6c41c784111c24172616f02f4bc227c17d"}, + {file = "fonttools-4.55.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1f0e115281a32ff532118aa851ef497a1b7cda617f4621c1cdf81ace3e36fb0c"}, + {file = "fonttools-4.55.0-cp312-cp312-win32.whl", hash = "sha256:6c99b5205844f48a05cb58d4a8110a44d3038c67ed1d79eb733c4953c628b0f6"}, + {file = "fonttools-4.55.0-cp312-cp312-win_amd64.whl", hash = "sha256:f8c8c76037d05652510ae45be1cd8fb5dd2fd9afec92a25374ac82255993d57c"}, + {file = "fonttools-4.55.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8118dc571921dc9e4b288d9cb423ceaf886d195a2e5329cc427df82bba872cd9"}, + {file = "fonttools-4.55.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01124f2ca6c29fad4132d930da69158d3f49b2350e4a779e1efbe0e82bd63f6c"}, + {file = "fonttools-4.55.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81ffd58d2691f11f7c8438796e9f21c374828805d33e83ff4b76e4635633674c"}, + {file = "fonttools-4.55.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5435e5f1eb893c35c2bc2b9cd3c9596b0fcb0a59e7a14121562986dd4c47b8dd"}, + {file = "fonttools-4.55.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d12081729280c39d001edd0f4f06d696014c26e6e9a0a55488fabc37c28945e4"}, + {file = "fonttools-4.55.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7ad1f1b98ab6cb927ab924a38a8649f1ffd7525c75fe5b594f5dab17af70e18"}, + {file = "fonttools-4.55.0-cp313-cp313-win32.whl", hash = "sha256:abe62987c37630dca69a104266277216de1023cf570c1643bb3a19a9509e7a1b"}, + {file = "fonttools-4.55.0-cp313-cp313-win_amd64.whl", hash = "sha256:2863555ba90b573e4201feaf87a7e71ca3b97c05aa4d63548a4b69ea16c9e998"}, + {file = "fonttools-4.55.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:00f7cf55ad58a57ba421b6a40945b85ac7cc73094fb4949c41171d3619a3a47e"}, + {file = "fonttools-4.55.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f27526042efd6f67bfb0cc2f1610fa20364396f8b1fc5edb9f45bb815fb090b2"}, + {file = "fonttools-4.55.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e67974326af6a8879dc2a4ec63ab2910a1c1a9680ccd63e4a690950fceddbe"}, + {file = "fonttools-4.55.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61dc0a13451143c5e987dec5254d9d428f3c2789a549a7cf4f815b63b310c1cc"}, + {file = "fonttools-4.55.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b2e526b325a903868c62155a6a7e24df53f6ce4c5c3160214d8fe1be2c41b478"}, + {file = "fonttools-4.55.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b7ef9068a1297714e6fefe5932c33b058aa1d45a2b8be32a4c6dee602ae22b5c"}, + {file = "fonttools-4.55.0-cp38-cp38-win32.whl", hash = "sha256:55718e8071be35dff098976bc249fc243b58efa263768c611be17fe55975d40a"}, + {file = "fonttools-4.55.0-cp38-cp38-win_amd64.whl", hash = "sha256:553bd4f8cc327f310c20158e345e8174c8eed49937fb047a8bda51daf2c353c8"}, + {file = "fonttools-4.55.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f901cef813f7c318b77d1c5c14cf7403bae5cb977cede023e22ba4316f0a8f6"}, + {file = "fonttools-4.55.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8c9679fc0dd7e8a5351d321d8d29a498255e69387590a86b596a45659a39eb0d"}, + {file = "fonttools-4.55.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd2820a8b632f3307ebb0bf57948511c2208e34a4939cf978333bc0a3f11f838"}, + {file = "fonttools-4.55.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23bbbb49bec613a32ed1b43df0f2b172313cee690c2509f1af8fdedcf0a17438"}, + {file = "fonttools-4.55.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a656652e1f5d55b9728937a7e7d509b73d23109cddd4e89ee4f49bde03b736c6"}, + {file = "fonttools-4.55.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f50a1f455902208486fbca47ce33054208a4e437b38da49d6721ce2fef732fcf"}, + {file = "fonttools-4.55.0-cp39-cp39-win32.whl", hash = "sha256:161d1ac54c73d82a3cded44202d0218ab007fde8cf194a23d3dd83f7177a2f03"}, + {file = "fonttools-4.55.0-cp39-cp39-win_amd64.whl", hash = "sha256:ca7fd6987c68414fece41c96836e945e1f320cda56fc96ffdc16e54a44ec57a2"}, + {file = "fonttools-4.55.0-py3-none-any.whl", hash = "sha256:12db5888cd4dd3fcc9f0ee60c6edd3c7e1fd44b7dd0f31381ea03df68f8a153f"}, + {file = "fonttools-4.55.0.tar.gz", hash = "sha256:7636acc6ab733572d5e7eec922b254ead611f1cdad17be3f0be7418e8bfaca71"}, +] + +[package.extras] +all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "fs (>=2.2.0,<3)", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "pycairo", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0)", "xattr", "zopfli (>=0.1.4)"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["munkres", "pycairo", "scipy"] +lxml = ["lxml (>=4.0)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.23.0)"] +symfont = ["sympy"] +type1 = ["xattr"] +ufo = ["fs (>=2.2.0,<3)"] +unicode = ["unicodedata2 (>=15.1.0)"] +woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] + +[[package]] +name = "kiwisolver" +version = "1.4.7" +description = "A fast implementation of the Cassowary constraint solver" +optional = false +python-versions = ">=3.8" +files = [ + {file = "kiwisolver-1.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8a9c83f75223d5e48b0bc9cb1bf2776cf01563e00ade8775ffe13b0b6e1af3a6"}, + {file = "kiwisolver-1.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:58370b1ffbd35407444d57057b57da5d6549d2d854fa30249771775c63b5fe17"}, + {file = "kiwisolver-1.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa0abdf853e09aff551db11fce173e2177d00786c688203f52c87ad7fcd91ef9"}, + {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8d53103597a252fb3ab8b5845af04c7a26d5e7ea8122303dd7a021176a87e8b9"}, + {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:88f17c5ffa8e9462fb79f62746428dd57b46eb931698e42e990ad63103f35e6c"}, + {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a9ca9c710d598fd75ee5de59d5bda2684d9db36a9f50b6125eaea3969c2599"}, + {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f4d742cb7af1c28303a51b7a27aaee540e71bb8e24f68c736f6f2ffc82f2bf05"}, + {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e28c7fea2196bf4c2f8d46a0415c77a1c480cc0724722f23d7410ffe9842c407"}, + {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e968b84db54f9d42046cf154e02911e39c0435c9801681e3fc9ce8a3c4130278"}, + {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0c18ec74c0472de033e1bebb2911c3c310eef5649133dd0bedf2a169a1b269e5"}, + {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8f0ea6da6d393d8b2e187e6a5e3fb81f5862010a40c3945e2c6d12ae45cfb2ad"}, + {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:f106407dda69ae456dd1227966bf445b157ccc80ba0dff3802bb63f30b74e895"}, + {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:84ec80df401cfee1457063732d90022f93951944b5b58975d34ab56bb150dfb3"}, + {file = "kiwisolver-1.4.7-cp310-cp310-win32.whl", hash = "sha256:71bb308552200fb2c195e35ef05de12f0c878c07fc91c270eb3d6e41698c3bcc"}, + {file = "kiwisolver-1.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:44756f9fd339de0fb6ee4f8c1696cfd19b2422e0d70b4cefc1cc7f1f64045a8c"}, + {file = "kiwisolver-1.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:78a42513018c41c2ffd262eb676442315cbfe3c44eed82385c2ed043bc63210a"}, + {file = "kiwisolver-1.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d2b0e12a42fb4e72d509fc994713d099cbb15ebf1103545e8a45f14da2dfca54"}, + {file = "kiwisolver-1.4.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2a8781ac3edc42ea4b90bc23e7d37b665d89423818e26eb6df90698aa2287c95"}, + {file = "kiwisolver-1.4.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46707a10836894b559e04b0fd143e343945c97fd170d69a2d26d640b4e297935"}, + {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef97b8df011141c9b0f6caf23b29379f87dd13183c978a30a3c546d2c47314cb"}, + {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab58c12a2cd0fc769089e6d38466c46d7f76aced0a1f54c77652446733d2d02"}, + {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:803b8e1459341c1bb56d1c5c010406d5edec8a0713a0945851290a7930679b51"}, + {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9a9e8a507420fe35992ee9ecb302dab68550dedc0da9e2880dd88071c5fb052"}, + {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18077b53dc3bb490e330669a99920c5e6a496889ae8c63b58fbc57c3d7f33a18"}, + {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6af936f79086a89b3680a280c47ea90b4df7047b5bdf3aa5c524bbedddb9e545"}, + {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3abc5b19d24af4b77d1598a585b8a719beb8569a71568b66f4ebe1fb0449460b"}, + {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:933d4de052939d90afbe6e9d5273ae05fb836cc86c15b686edd4b3560cc0ee36"}, + {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:65e720d2ab2b53f1f72fb5da5fb477455905ce2c88aaa671ff0a447c2c80e8e3"}, + {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3bf1ed55088f214ba6427484c59553123fdd9b218a42bbc8c6496d6754b1e523"}, + {file = "kiwisolver-1.4.7-cp311-cp311-win32.whl", hash = "sha256:4c00336b9dd5ad96d0a558fd18a8b6f711b7449acce4c157e7343ba92dd0cf3d"}, + {file = "kiwisolver-1.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:929e294c1ac1e9f615c62a4e4313ca1823ba37326c164ec720a803287c4c499b"}, + {file = "kiwisolver-1.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:e33e8fbd440c917106b237ef1a2f1449dfbb9b6f6e1ce17c94cd6a1e0d438376"}, + {file = "kiwisolver-1.4.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:5360cc32706dab3931f738d3079652d20982511f7c0ac5711483e6eab08efff2"}, + {file = "kiwisolver-1.4.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942216596dc64ddb25adb215c3c783215b23626f8d84e8eff8d6d45c3f29f75a"}, + {file = "kiwisolver-1.4.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:48b571ecd8bae15702e4f22d3ff6a0f13e54d3d00cd25216d5e7f658242065ee"}, + {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad42ba922c67c5f219097b28fae965e10045ddf145d2928bfac2eb2e17673640"}, + {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:612a10bdae23404a72941a0fc8fa2660c6ea1217c4ce0dbcab8a8f6543ea9e7f"}, + {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e838bba3a3bac0fe06d849d29772eb1afb9745a59710762e4ba3f4cb8424483"}, + {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22f499f6157236c19f4bbbd472fa55b063db77a16cd74d49afe28992dff8c258"}, + {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693902d433cf585133699972b6d7c42a8b9f8f826ebcaf0132ff55200afc599e"}, + {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e77f2126c3e0b0d055f44513ed349038ac180371ed9b52fe96a32aa071a5107"}, + {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:657a05857bda581c3656bfc3b20e353c232e9193eb167766ad2dc58b56504948"}, + {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4bfa75a048c056a411f9705856abfc872558e33c055d80af6a380e3658766038"}, + {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:34ea1de54beef1c104422d210c47c7d2a4999bdecf42c7b5718fbe59a4cac383"}, + {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90da3b5f694b85231cf93586dad5e90e2d71b9428f9aad96952c99055582f520"}, + {file = "kiwisolver-1.4.7-cp312-cp312-win32.whl", hash = "sha256:18e0cca3e008e17fe9b164b55735a325140a5a35faad8de92dd80265cd5eb80b"}, + {file = "kiwisolver-1.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:58cb20602b18f86f83a5c87d3ee1c766a79c0d452f8def86d925e6c60fbf7bfb"}, + {file = "kiwisolver-1.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:f5a8b53bdc0b3961f8b6125e198617c40aeed638b387913bf1ce78afb1b0be2a"}, + {file = "kiwisolver-1.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2e6039dcbe79a8e0f044f1c39db1986a1b8071051efba3ee4d74f5b365f5226e"}, + {file = "kiwisolver-1.4.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a1ecf0ac1c518487d9d23b1cd7139a6a65bc460cd101ab01f1be82ecf09794b6"}, + {file = "kiwisolver-1.4.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ab9ccab2b5bd5702ab0803676a580fffa2aa178c2badc5557a84cc943fcf750"}, + {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f816dd2277f8d63d79f9c8473a79fe54047bc0467754962840782c575522224d"}, + {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf8bcc23ceb5a1b624572a1623b9f79d2c3b337c8c455405ef231933a10da379"}, + {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dea0bf229319828467d7fca8c7c189780aa9ff679c94539eed7532ebe33ed37c"}, + {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c06a4c7cf15ec739ce0e5971b26c93638730090add60e183530d70848ebdd34"}, + {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:913983ad2deb14e66d83c28b632fd35ba2b825031f2fa4ca29675e665dfecbe1"}, + {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5337ec7809bcd0f424c6b705ecf97941c46279cf5ed92311782c7c9c2026f07f"}, + {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c26ed10c4f6fa6ddb329a5120ba3b6db349ca192ae211e882970bfc9d91420b"}, + {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c619b101e6de2222c1fcb0531e1b17bbffbe54294bfba43ea0d411d428618c27"}, + {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:073a36c8273647592ea332e816e75ef8da5c303236ec0167196793eb1e34657a"}, + {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ce6b2b0231bda412463e152fc18335ba32faf4e8c23a754ad50ffa70e4091ee"}, + {file = "kiwisolver-1.4.7-cp313-cp313-win32.whl", hash = "sha256:f4c9aee212bc89d4e13f58be11a56cc8036cabad119259d12ace14b34476fd07"}, + {file = "kiwisolver-1.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:8a3ec5aa8e38fc4c8af308917ce12c536f1c88452ce554027e55b22cbbfbff76"}, + {file = "kiwisolver-1.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:76c8094ac20ec259471ac53e774623eb62e6e1f56cd8690c67ce6ce4fcb05650"}, + {file = "kiwisolver-1.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5d5abf8f8ec1f4e22882273c423e16cae834c36856cac348cfbfa68e01c40f3a"}, + {file = "kiwisolver-1.4.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:aeb3531b196ef6f11776c21674dba836aeea9d5bd1cf630f869e3d90b16cfade"}, + {file = "kiwisolver-1.4.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b7d755065e4e866a8086c9bdada157133ff466476a2ad7861828e17b6026e22c"}, + {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08471d4d86cbaec61f86b217dd938a83d85e03785f51121e791a6e6689a3be95"}, + {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bbfcb7165ce3d54a3dfbe731e470f65739c4c1f85bb1018ee912bae139e263b"}, + {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d34eb8494bea691a1a450141ebb5385e4b69d38bb8403b5146ad279f4b30fa3"}, + {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9242795d174daa40105c1d86aba618e8eab7bf96ba8c3ee614da8302a9f95503"}, + {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a0f64a48bb81af7450e641e3fe0b0394d7381e342805479178b3d335d60ca7cf"}, + {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8e045731a5416357638d1700927529e2b8ab304811671f665b225f8bf8d8f933"}, + {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4322872d5772cae7369f8351da1edf255a604ea7087fe295411397d0cfd9655e"}, + {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:e1631290ee9271dffe3062d2634c3ecac02c83890ada077d225e081aca8aab89"}, + {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:edcfc407e4eb17e037bca59be0e85a2031a2ac87e4fed26d3e9df88b4165f92d"}, + {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4d05d81ecb47d11e7f8932bd8b61b720bf0b41199358f3f5e36d38e28f0532c5"}, + {file = "kiwisolver-1.4.7-cp38-cp38-win32.whl", hash = "sha256:b38ac83d5f04b15e515fd86f312479d950d05ce2368d5413d46c088dda7de90a"}, + {file = "kiwisolver-1.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:d83db7cde68459fc803052a55ace60bea2bae361fc3b7a6d5da07e11954e4b09"}, + {file = "kiwisolver-1.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f9362ecfca44c863569d3d3c033dbe8ba452ff8eed6f6b5806382741a1334bd"}, + {file = "kiwisolver-1.4.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e8df2eb9b2bac43ef8b082e06f750350fbbaf2887534a5be97f6cf07b19d9583"}, + {file = "kiwisolver-1.4.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f32d6edbc638cde7652bd690c3e728b25332acbadd7cad670cc4a02558d9c417"}, + {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e2e6c39bd7b9372b0be21456caab138e8e69cc0fc1190a9dfa92bd45a1e6e904"}, + {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dda56c24d869b1193fcc763f1284b9126550eaf84b88bbc7256e15028f19188a"}, + {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79849239c39b5e1fd906556c474d9b0439ea6792b637511f3fe3a41158d89ca8"}, + {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e3bc157fed2a4c02ec468de4ecd12a6e22818d4f09cde2c31ee3226ffbefab2"}, + {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3da53da805b71e41053dc670f9a820d1157aae77b6b944e08024d17bcd51ef88"}, + {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8705f17dfeb43139a692298cb6637ee2e59c0194538153e83e9ee0c75c2eddde"}, + {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:82a5c2f4b87c26bb1a0ef3d16b5c4753434633b83d365cc0ddf2770c93829e3c"}, + {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce8be0466f4c0d585cdb6c1e2ed07232221df101a4c6f28821d2aa754ca2d9e2"}, + {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:409afdfe1e2e90e6ee7fc896f3df9a7fec8e793e58bfa0d052c8a82f99c37abb"}, + {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5b9c3f4ee0b9a439d2415012bd1b1cc2df59e4d6a9939f4d669241d30b414327"}, + {file = "kiwisolver-1.4.7-cp39-cp39-win32.whl", hash = "sha256:a79ae34384df2b615eefca647a2873842ac3b596418032bef9a7283675962644"}, + {file = "kiwisolver-1.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:cf0438b42121a66a3a667de17e779330fc0f20b0d97d59d2f2121e182b0505e4"}, + {file = "kiwisolver-1.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:764202cc7e70f767dab49e8df52c7455e8de0df5d858fa801a11aa0d882ccf3f"}, + {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:94252291e3fe68001b1dd747b4c0b3be12582839b95ad4d1b641924d68fd4643"}, + {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b7dfa3b546da08a9f622bb6becdb14b3e24aaa30adba66749d38f3cc7ea9706"}, + {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd3de6481f4ed8b734da5df134cd5a6a64fe32124fe83dde1e5b5f29fe30b1e6"}, + {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a91b5f9f1205845d488c928e8570dcb62b893372f63b8b6e98b863ebd2368ff2"}, + {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fa14dbd66b8b8f470d5fc79c089a66185619d31645f9b0773b88b19f7223c4"}, + {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:eb542fe7933aa09d8d8f9d9097ef37532a7df6497819d16efe4359890a2f417a"}, + {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bfa1acfa0c54932d5607e19a2c24646fb4c1ae2694437789129cf099789a3b00"}, + {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:eee3ea935c3d227d49b4eb85660ff631556841f6e567f0f7bda972df6c2c9935"}, + {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f3160309af4396e0ed04db259c3ccbfdc3621b5559b5453075e5de555e1f3a1b"}, + {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a17f6a29cf8935e587cc8a4dbfc8368c55edc645283db0ce9801016f83526c2d"}, + {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10849fb2c1ecbfae45a693c070e0320a91b35dd4bcf58172c023b994283a124d"}, + {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:ac542bf38a8a4be2dc6b15248d36315ccc65f0743f7b1a76688ffb6b5129a5c2"}, + {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8b01aac285f91ca889c800042c35ad3b239e704b150cfd3382adfc9dcc780e39"}, + {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:48be928f59a1f5c8207154f935334d374e79f2b5d212826307d072595ad76a2e"}, + {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f37cfe618a117e50d8c240555331160d73d0411422b59b5ee217843d7b693608"}, + {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:599b5c873c63a1f6ed7eead644a8a380cfbdf5db91dcb6f85707aaab213b1674"}, + {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:801fa7802e5cfabe3ab0c81a34c323a319b097dfb5004be950482d882f3d7225"}, + {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0c6c43471bc764fad4bc99c5c2d6d16a676b1abf844ca7c8702bdae92df01ee0"}, + {file = "kiwisolver-1.4.7.tar.gz", hash = "sha256:9893ff81bd7107f7b685d3017cc6583daadb4fc26e4a888350df530e41980a60"}, +] + +[[package]] +name = "matplotlib" +version = "3.9.2" +description = "Python plotting package" +optional = false +python-versions = ">=3.9" +files = [ + {file = "matplotlib-3.9.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9d78bbc0cbc891ad55b4f39a48c22182e9bdaea7fc0e5dbd364f49f729ca1bbb"}, + {file = "matplotlib-3.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c375cc72229614632c87355366bdf2570c2dac01ac66b8ad048d2dabadf2d0d4"}, + {file = "matplotlib-3.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d94ff717eb2bd0b58fe66380bd8b14ac35f48a98e7c6765117fe67fb7684e64"}, + {file = "matplotlib-3.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab68d50c06938ef28681073327795c5db99bb4666214d2d5f880ed11aeaded66"}, + {file = "matplotlib-3.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:65aacf95b62272d568044531e41de26285d54aec8cb859031f511f84bd8b495a"}, + {file = "matplotlib-3.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:3fd595f34aa8a55b7fc8bf9ebea8aa665a84c82d275190a61118d33fbc82ccae"}, + {file = "matplotlib-3.9.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8dd059447824eec055e829258ab092b56bb0579fc3164fa09c64f3acd478772"}, + {file = "matplotlib-3.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c797dac8bb9c7a3fd3382b16fe8f215b4cf0f22adccea36f1545a6d7be310b41"}, + {file = "matplotlib-3.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d719465db13267bcef19ea8954a971db03b9f48b4647e3860e4bc8e6ed86610f"}, + {file = "matplotlib-3.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8912ef7c2362f7193b5819d17dae8629b34a95c58603d781329712ada83f9447"}, + {file = "matplotlib-3.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7741f26a58a240f43bee74965c4882b6c93df3e7eb3de160126d8c8f53a6ae6e"}, + {file = "matplotlib-3.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:ae82a14dab96fbfad7965403c643cafe6515e386de723e498cf3eeb1e0b70cc7"}, + {file = "matplotlib-3.9.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ac43031375a65c3196bee99f6001e7fa5bdfb00ddf43379d3c0609bdca042df9"}, + {file = "matplotlib-3.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be0fc24a5e4531ae4d8e858a1a548c1fe33b176bb13eff7f9d0d38ce5112a27d"}, + {file = "matplotlib-3.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf81de2926c2db243c9b2cbc3917619a0fc85796c6ba4e58f541df814bbf83c7"}, + {file = "matplotlib-3.9.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6ee45bc4245533111ced13f1f2cace1e7f89d1c793390392a80c139d6cf0e6c"}, + {file = "matplotlib-3.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:306c8dfc73239f0e72ac50e5a9cf19cc4e8e331dd0c54f5e69ca8758550f1e1e"}, + {file = "matplotlib-3.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:5413401594cfaff0052f9d8b1aafc6d305b4bd7c4331dccd18f561ff7e1d3bd3"}, + {file = "matplotlib-3.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:18128cc08f0d3cfff10b76baa2f296fc28c4607368a8402de61bb3f2eb33c7d9"}, + {file = "matplotlib-3.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4876d7d40219e8ae8bb70f9263bcbe5714415acfdf781086601211335e24f8aa"}, + {file = "matplotlib-3.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d9f07a80deab4bb0b82858a9e9ad53d1382fd122be8cde11080f4e7dfedb38b"}, + {file = "matplotlib-3.9.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7c0410f181a531ec4e93bbc27692f2c71a15c2da16766f5ba9761e7ae518413"}, + {file = "matplotlib-3.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:909645cce2dc28b735674ce0931a4ac94e12f5b13f6bb0b5a5e65e7cea2c192b"}, + {file = "matplotlib-3.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:f32c7410c7f246838a77d6d1eff0c0f87f3cb0e7c4247aebea71a6d5a68cab49"}, + {file = "matplotlib-3.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:37e51dd1c2db16ede9cfd7b5cabdfc818b2c6397c83f8b10e0e797501c963a03"}, + {file = "matplotlib-3.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b82c5045cebcecd8496a4d694d43f9cc84aeeb49fe2133e036b207abe73f4d30"}, + {file = "matplotlib-3.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f053c40f94bc51bc03832a41b4f153d83f2062d88c72b5e79997072594e97e51"}, + {file = "matplotlib-3.9.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbe196377a8248972f5cede786d4c5508ed5f5ca4a1e09b44bda889958b33f8c"}, + {file = "matplotlib-3.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5816b1e1fe8c192cbc013f8f3e3368ac56fbecf02fb41b8f8559303f24c5015e"}, + {file = "matplotlib-3.9.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:cef2a73d06601437be399908cf13aee74e86932a5ccc6ccdf173408ebc5f6bb2"}, + {file = "matplotlib-3.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0830e188029c14e891fadd99702fd90d317df294c3298aad682739c5533721a"}, + {file = "matplotlib-3.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ba9c1299c920964e8d3857ba27173b4dbb51ca4bab47ffc2c2ba0eb5e2cbc5"}, + {file = "matplotlib-3.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cd93b91ab47a3616b4d3c42b52f8363b88ca021e340804c6ab2536344fad9ca"}, + {file = "matplotlib-3.9.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6d1ce5ed2aefcdce11904fc5bbea7d9c21fff3d5f543841edf3dea84451a09ea"}, + {file = "matplotlib-3.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:b2696efdc08648536efd4e1601b5fd491fd47f4db97a5fbfd175549a7365c1b2"}, + {file = "matplotlib-3.9.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d52a3b618cb1cbb769ce2ee1dcdb333c3ab6e823944e9a2d36e37253815f9556"}, + {file = "matplotlib-3.9.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:039082812cacd6c6bec8e17a9c1e6baca230d4116d522e81e1f63a74d01d2e21"}, + {file = "matplotlib-3.9.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6758baae2ed64f2331d4fd19be38b7b4eae3ecec210049a26b6a4f3ae1c85dcc"}, + {file = "matplotlib-3.9.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:050598c2b29e0b9832cde72bcf97627bf00262adbc4a54e2b856426bb2ef0697"}, + {file = "matplotlib-3.9.2.tar.gz", hash = "sha256:96ab43906269ca64a6366934106fa01534454a69e471b7bf3d79083981aaab92"}, +] + +[package.dependencies] +contourpy = ">=1.0.1" +cycler = ">=0.10" +fonttools = ">=4.22.0" +kiwisolver = ">=1.3.1" +numpy = ">=1.23" +packaging = ">=20.0" +pillow = ">=8" +pyparsing = ">=2.3.1" +python-dateutil = ">=2.7" + +[package.extras] +dev = ["meson-python (>=0.13.1)", "numpy (>=1.25)", "pybind11 (>=2.6)", "setuptools (>=64)", "setuptools_scm (>=7)"] + +[[package]] +name = "numpy" +version = "2.1.3" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "numpy-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c894b4305373b9c5576d7a12b473702afdf48ce5369c074ba304cc5ad8730dff"}, + {file = "numpy-2.1.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b47fbb433d3260adcd51eb54f92a2ffbc90a4595f8970ee00e064c644ac788f5"}, + {file = "numpy-2.1.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:825656d0743699c529c5943554d223c021ff0494ff1442152ce887ef4f7561a1"}, + {file = "numpy-2.1.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:6a4825252fcc430a182ac4dee5a505053d262c807f8a924603d411f6718b88fd"}, + {file = "numpy-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e711e02f49e176a01d0349d82cb5f05ba4db7d5e7e0defd026328e5cfb3226d3"}, + {file = "numpy-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78574ac2d1a4a02421f25da9559850d59457bac82f2b8d7a44fe83a64f770098"}, + {file = "numpy-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c7662f0e3673fe4e832fe07b65c50342ea27d989f92c80355658c7f888fcc83c"}, + {file = "numpy-2.1.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fa2d1337dc61c8dc417fbccf20f6d1e139896a30721b7f1e832b2bb6ef4eb6c4"}, + {file = "numpy-2.1.3-cp310-cp310-win32.whl", hash = "sha256:72dcc4a35a8515d83e76b58fdf8113a5c969ccd505c8a946759b24e3182d1f23"}, + {file = "numpy-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:ecc76a9ba2911d8d37ac01de72834d8849e55473457558e12995f4cd53e778e0"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4d1167c53b93f1f5d8a139a742b3c6f4d429b54e74e6b57d0eff40045187b15d"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c80e4a09b3d95b4e1cac08643f1152fa71a0a821a2d4277334c88d54b2219a41"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:576a1c1d25e9e02ed7fa5477f30a127fe56debd53b8d2c89d5578f9857d03ca9"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:973faafebaae4c0aaa1a1ca1ce02434554d67e628b8d805e61f874b84e136b09"}, + {file = "numpy-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:762479be47a4863e261a840e8e01608d124ee1361e48b96916f38b119cfda04a"}, + {file = "numpy-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc6f24b3d1ecc1eebfbf5d6051faa49af40b03be1aaa781ebdadcbc090b4539b"}, + {file = "numpy-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:17ee83a1f4fef3c94d16dc1802b998668b5419362c8a4f4e8a491de1b41cc3ee"}, + {file = "numpy-2.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15cb89f39fa6d0bdfb600ea24b250e5f1a3df23f901f51c8debaa6a5d122b2f0"}, + {file = "numpy-2.1.3-cp311-cp311-win32.whl", hash = "sha256:d9beb777a78c331580705326d2367488d5bc473b49a9bc3036c154832520aca9"}, + {file = "numpy-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:d89dd2b6da69c4fff5e39c28a382199ddedc3a5be5390115608345dec660b9e2"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f55ba01150f52b1027829b50d70ef1dafd9821ea82905b63936668403c3b471e"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13138eadd4f4da03074851a698ffa7e405f41a0845a6b1ad135b81596e4e9958"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a6b46587b14b888e95e4a24d7b13ae91fa22386c199ee7b418f449032b2fa3b8"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:0fa14563cc46422e99daef53d725d0c326e99e468a9320a240affffe87852564"}, + {file = "numpy-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8637dcd2caa676e475503d1f8fdb327bc495554e10838019651b76d17b98e512"}, + {file = "numpy-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2312b2aa89e1f43ecea6da6ea9a810d06aae08321609d8dc0d0eda6d946a541b"}, + {file = "numpy-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a38c19106902bb19351b83802531fea19dee18e5b37b36454f27f11ff956f7fc"}, + {file = "numpy-2.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02135ade8b8a84011cbb67dc44e07c58f28575cf9ecf8ab304e51c05528c19f0"}, + {file = "numpy-2.1.3-cp312-cp312-win32.whl", hash = "sha256:e6988e90fcf617da2b5c78902fe8e668361b43b4fe26dbf2d7b0f8034d4cafb9"}, + {file = "numpy-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:0d30c543f02e84e92c4b1f415b7c6b5326cbe45ee7882b6b77db7195fb971e3a"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96fe52fcdb9345b7cd82ecd34547fca4321f7656d500eca497eb7ea5a926692f"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f653490b33e9c3a4c1c01d41bc2aef08f9475af51146e4a7710c450cf9761598"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dc258a761a16daa791081d026f0ed4399b582712e6fc887a95af09df10c5ca57"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:016d0f6f5e77b0f0d45d77387ffa4bb89816b57c835580c3ce8e099ef830befe"}, + {file = "numpy-2.1.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c181ba05ce8299c7aa3125c27b9c2167bca4a4445b7ce73d5febc411ca692e43"}, + {file = "numpy-2.1.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5641516794ca9e5f8a4d17bb45446998c6554704d888f86df9b200e66bdcce56"}, + {file = "numpy-2.1.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ea4dedd6e394a9c180b33c2c872b92f7ce0f8e7ad93e9585312b0c5a04777a4a"}, + {file = "numpy-2.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0df3635b9c8ef48bd3be5f862cf71b0a4716fa0e702155c45067c6b711ddcef"}, + {file = "numpy-2.1.3-cp313-cp313-win32.whl", hash = "sha256:50ca6aba6e163363f132b5c101ba078b8cbd3fa92c7865fd7d4d62d9779ac29f"}, + {file = "numpy-2.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:747641635d3d44bcb380d950679462fae44f54b131be347d5ec2bce47d3df9ed"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:996bb9399059c5b82f76b53ff8bb686069c05acc94656bb259b1d63d04a9506f"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:45966d859916ad02b779706bb43b954281db43e185015df6eb3323120188f9e4"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:baed7e8d7481bfe0874b566850cb0b85243e982388b7b23348c6db2ee2b2ae8e"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f7f672a3388133335589cfca93ed468509cb7b93ba3105fce780d04a6576a0"}, + {file = "numpy-2.1.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7aac50327da5d208db2eec22eb11e491e3fe13d22653dce51b0f4109101b408"}, + {file = "numpy-2.1.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4394bc0dbd074b7f9b52024832d16e019decebf86caf909d94f6b3f77a8ee3b6"}, + {file = "numpy-2.1.3-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:50d18c4358a0a8a53f12a8ba9d772ab2d460321e6a93d6064fc22443d189853f"}, + {file = "numpy-2.1.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:14e253bd43fc6b37af4921b10f6add6925878a42a0c5fe83daee390bca80bc17"}, + {file = "numpy-2.1.3-cp313-cp313t-win32.whl", hash = "sha256:08788d27a5fd867a663f6fc753fd7c3ad7e92747efc73c53bca2f19f8bc06f48"}, + {file = "numpy-2.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2564fbdf2b99b3f815f2107c1bbc93e2de8ee655a69c261363a1172a79a257d4"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4f2015dfe437dfebbfce7c85c7b53d81ba49e71ba7eadbf1df40c915af75979f"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:3522b0dfe983a575e6a9ab3a4a4dfe156c3e428468ff08ce582b9bb6bd1d71d4"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c006b607a865b07cd981ccb218a04fc86b600411d83d6fc261357f1c0966755d"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e14e26956e6f1696070788252dcdff11b4aca4c3e8bd166e0df1bb8f315a67cb"}, + {file = "numpy-2.1.3.tar.gz", hash = "sha256:aa08e04e08aaf974d4458def539dece0d28146d866a39da5639596f4921fd761"}, +] + +[[package]] +name = "opencv-python" +version = "4.10.0.84" +description = "Wrapper package for OpenCV python bindings." +optional = false +python-versions = ">=3.6" +files = [ + {file = "opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc182f8f4cda51b45f01c64e4cbedfc2f00aff799debebc305d8d0210c43f251"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:2db02bb7e50b703f0a2d50c50ced72e95c574e1e5a0bb35a8a86d0b35c98c236"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:32dbbd94c26f611dc5cc6979e6b7aa1f55a64d6b463cc1dcd3c95505a63e48fe"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, + {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, + {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pillow" +version = "11.0.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947"}, + {file = "pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba"}, + {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086"}, + {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9"}, + {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488"}, + {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f"}, + {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb"}, + {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97"}, + {file = "pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50"}, + {file = "pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c"}, + {file = "pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1"}, + {file = "pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc"}, + {file = "pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a"}, + {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3"}, + {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5"}, + {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b"}, + {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa"}, + {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306"}, + {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9"}, + {file = "pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5"}, + {file = "pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291"}, + {file = "pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9"}, + {file = "pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923"}, + {file = "pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903"}, + {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4"}, + {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f"}, + {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9"}, + {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7"}, + {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6"}, + {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc"}, + {file = "pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6"}, + {file = "pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47"}, + {file = "pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25"}, + {file = "pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699"}, + {file = "pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38"}, + {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2"}, + {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2"}, + {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527"}, + {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa"}, + {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f"}, + {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb"}, + {file = "pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798"}, + {file = "pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de"}, + {file = "pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84"}, + {file = "pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b"}, + {file = "pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003"}, + {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2"}, + {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a"}, + {file = "pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8"}, + {file = "pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8"}, + {file = "pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904"}, + {file = "pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3"}, + {file = "pillow-11.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2e46773dc9f35a1dd28bd6981332fd7f27bec001a918a72a79b4133cf5291dba"}, + {file = "pillow-11.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2679d2258b7f1192b378e2893a8a0a0ca472234d4c2c0e6bdd3380e8dfa21b6a"}, + {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda2616eb2313cbb3eebbe51f19362eb434b18e3bb599466a1ffa76a033fb916"}, + {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ec184af98a121fb2da42642dea8a29ec80fc3efbaefb86d8fdd2606619045d"}, + {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8594f42df584e5b4bb9281799698403f7af489fba84c34d53d1c4bfb71b7c4e7"}, + {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:c12b5ae868897c7338519c03049a806af85b9b8c237b7d675b8c5e089e4a618e"}, + {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:70fbbdacd1d271b77b7721fe3cdd2d537bbbd75d29e6300c672ec6bb38d9672f"}, + {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5178952973e588b3f1360868847334e9e3bf49d19e169bbbdfaf8398002419ae"}, + {file = "pillow-11.0.0-cp39-cp39-win32.whl", hash = "sha256:8c676b587da5673d3c75bd67dd2a8cdfeb282ca38a30f37950511766b26858c4"}, + {file = "pillow-11.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:94f3e1780abb45062287b4614a5bc0874519c86a777d4a7ad34978e86428b8dd"}, + {file = "pillow-11.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:290f2cc809f9da7d6d622550bbf4c1e57518212da51b6a30fe8e0a270a5b78bd"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316"}, + {file = "pillow-11.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bd2d3bdb846d757055910f0a59792d33b555800813c3b39ada1829c372ccb06"}, + {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375b8dd15a1f5d2feafff536d47e22f69625c1aa92f12b339ec0b2ca40263273"}, + {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:daffdf51ee5db69a82dd127eabecce20729e21f7a3680cf7cbb23f0829189790"}, + {file = "pillow-11.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7326a1787e3c7b0429659e0a944725e1b03eeaa10edd945a86dead1913383944"}, + {file = "pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] +typing = ["typing-extensions"] +xmp = ["defusedxml"] + +[[package]] +name = "pyparsing" +version = "3.2.0" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pyparsing-3.2.0-py3-none-any.whl", hash = "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84"}, + {file = "pyparsing-3.2.0.tar.gz", hash = "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pyserial" +version = "3.5" +description = "Python Serial Port Extension" +optional = false +python-versions = "*" +files = [ + {file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"}, + {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"}, +] + +[package.extras] +cp2110 = ["hidapi"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "ruff" +version = "0.7.4" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478"}, + {file = "ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63"}, + {file = "ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc"}, + {file = "ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172"}, + {file = "ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a"}, + {file = "ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd"}, + {file = "ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a"}, + {file = "ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac"}, + {file = "ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6"}, + {file = "ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f"}, + {file = "ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.10.4" +content-hash = "0d9d5e276743933f60aa452b48009a492b31d7a40de088594e52c5ae1c774d6a" diff --git a/pyplot.py b/pyplot.py index fb3df48..231dade 100755 --- a/pyplot.py +++ b/pyplot.py @@ -1,143 +1,470 @@ -#!/usr/bin/python3 -import numpy as np -import matplotlib +#!/usr/bin/env python3 + +import argparse +import csv +import math +import sys +import threading +import time +from datetime import datetime + +import cv2 import matplotlib.pyplot as plt -import matplotlib.animation as animation -import matplotlib.patches as patches +import numpy as np +import serial +from matplotlib import animation, patches from matplotlib.backend_bases import MouseButton from mpl_toolkits.axes_grid1 import make_axes_locatable -import ht301_hacklib + +import irpythermal import utils -import time -import sys -fps = 40 -exposure = {'auto': True, - 'auto_type': 'ends', # 'center' or 'ends' - 'T_min': 0., - 'T_max': 50., - 'T_margin': 2.0, -} -draw_temp = True - -# choose the camera class -camera:ht301_hacklib.Camera -if sys.argv[-1].endswith('.npy'): - camera = ht301_hacklib.CameraEmulator(sys.argv[-1]) -else: - camera = ht301_hacklib.Camera() - - - -#see https://matplotlib.org/tutorials/colors/colormaps.html -cmaps_idx = 1 -cmaps = ['inferno', 'coolwarm', 'cividis', 'jet', 'nipy_spectral', 'binary', 'gray', 'tab10'] - -matplotlib.rcParams['toolbar'] = 'None' - -# temporary fake frame -lut_frame = frame = np.full((camera.height, camera.width), 25.) -info = {} -lut = None # will be defined later - -fig = plt.figure() - -try: - fig.canvas.set_window_title("Thermal Camera") -except: - # does not work on windows - pass - -ax = plt.gca() -im = ax.imshow(lut_frame, cmap=cmaps[cmaps_idx]) -divider = make_axes_locatable(ax) -cax = divider.append_axes("right", size="5%", pad=0.05) -cbar = plt.colorbar(im, cax=cax) - -annotations = utils.Annotations(ax, patches) -temp_annotations = { - 'std': { - 'Tmin': 'lightblue', - 'Tmax': 'red', - 'Tcenter': 'yellow' - }, - 'user': {} -} - -# Add the patch to the Axes -roi = ((0,0),(0,0)) - - -paused = False -update_colormap = True -diff = { 'enabled': False, - 'annotation_enabled': False, - 'frame': np.zeros(frame.shape) -} +# TODO(frans): We should get rid of those +# ruff: noqa: PTH123 # use Path.open +# ruff: noqa: ANN201,ANN001 # type annotations +# ruff: noqa: DTZ005 # timezone missing +# ruff: noqa: RUF005 # tuple / list concatenation +# ruff: noqa: D100,D101,D103 # documentation missing +# ruff: noqa: PLR0915 # too many statements +# ruff: noqa: C901 # function too complex +# ruff: noqa: PLR0912 # too many branches +# ruff: noqa: FIX002 # fix instead of TODO +# ruff: noqa: ERA001 # commented out code +# ruff: noqa: SIM105,S110,E722 # exceptions + +# see https://matplotlib.org/tutorials/colors/colormaps.html +CMAP_NAMES = [ + "inferno", + "plasma", + "coolwarm", + "cividis", + "jet", + "nipy_spectral", + "binary", + "gray", + "tab10", +] + + +def parse_arguments() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description="Thermal Camera Viewer") + parser.add_argument( + "-r", "--rawcam", action="store_true", help="use the raw camera" + ) + parser.add_argument( + "-d", "--device", type=str, help="use the camera at camera_path" + ) + parser.add_argument( + "-o", + "--offset", + type=float, + help="set a fixed offset for the temperature data", + ) + + # lock in thermometry options (all of these are requred) + parser.add_argument( + "-l", + "--lockin", + type=float, + help=( + "enable lock-in thermometry with the given frequency (in Hz)," + " ideally several times smaller than the camera fps" + ), + ) + parser.add_argument( + "-p", + "--port", + type=str, + help=( + "set the serial port for the power supply control (will send 1 to turn on" + " the load, 0 to turn it off new line terminated) at 115200 baud" + ), + ) + parser.add_argument( + "-i", + "--integration", + type=float, + help="set the integration time for the lock-in thermometry (in seconds)", + ) + parser.add_argument( + "-n", + "--negate", + action='store_true', + help="negate the signal send to the switch device, (useful for NO switches, or N-FETs)", + ) + parser.add_argument( + "file", nargs="?", type=str, help="use the emulator with the data in file.npy" + ) + return parser.parse_args() + + +args = parse_arguments() + + +class AppState: + def __init__(self, args: argparse.Namespace) -> None: + """Set up a default application state.""" + self.fps = 40 + self.exposure = { + "auto": True, + "auto_type": "ends", # 'center' or 'ends' + "T_min": 0.0, + "T_max": 50.0, + "T_margin": 2.0, + } + self.draw_temp = True + + # Choose the camera class + self.camera: irpythermal.Camera + self.lockin = False + + if args.file and args.file.endswith(".npy"): + self.camera = irpythermal.CameraEmulator(args.file) + else: + camera_kwargs = {} + if args.rawcam: + camera_kwargs["camera_raw"] = True + if args.device: + camera_path = args.device + cv2_cam = cv2.VideoCapture(camera_path) + camera_kwargs["video_dev"] = cv2_cam + if args.offset: + camera_kwargs["fixed_offset"] = args.offset + + if args.lockin: + self.lockin = True + self.draw_temp = False + self.negate = False + # check if all lock-in thermometry options are provided + if not args.port or not args.integration: + print( + "Error: lock-in thermometry also requires --port and" + " --integration options" + ) + sys.exit(1) + + if args.negate: + self.negate = True + + self.fequency = args.lockin + self.port = args.port + self.integration = args.integration + + self.camera = irpythermal.Camera(**camera_kwargs) + + self.cmaps_idx = 1 + + # matplotlib.rcParams['toolbar'] = 'None' + + # temporary fake frame + self.frame = np.full((self.camera.height, self.camera.width), 25.0) + self.quad_frame = np.full((self.camera.height, self.camera.width), 0.0) + self.in_phase_frame = np.full((self.camera.height, self.camera.width), 0.0) + self.lut = None # will be defined later + self.start_skips = 2 + self.is_capturing = False + self.lock = threading.Lock() + self.lock_in_thread = None + + if self.lockin: + self.fig, self.axes = plt.subplots(nrows=2, ncols=2, layout="tight") + axes = self.axes + self.ax = axes[0][0] + self.im = axes[0][0].imshow(self.frame, cmap=CMAP_NAMES[self.cmaps_idx]) + self.im_in_phase = axes[0][1].imshow(self.frame, cmap=CMAP_NAMES[self.cmaps_idx]) + self.im_quadrature = axes[1][1].imshow( + self.frame, cmap=CMAP_NAMES[self.cmaps_idx] + ) + axes[0][0].set_title("Live") + axes[0][1].set_title("In-phase") + axes[1][1].set_title("Quadrature") + divider = make_axes_locatable(axes[0][0]) + divider_in_phase = make_axes_locatable(axes[0][1]) + divider_quadrature = make_axes_locatable(axes[1][1]) + cax = divider.append_axes("right", size="5%", pad=0.05) + cax_in_phase = divider_in_phase.append_axes("right", size="5%", pad=0.05) + cax_quadrature = divider_quadrature.append_axes( + "right", size="5%", pad=0.05 + ) + plt.colorbar(self.im, cax=cax) + self.cbar_in_phase = plt.colorbar(self.im_in_phase, cax=cax_in_phase) + self.cbar_quadrature = plt.colorbar(self.im_quadrature, cax=cax_quadrature) + + axes[1][0].axis("off") + status_text = """ + Frame: -, + Time: -/-, + Load: -, + Frequency: -Hz, + Integration Time: -s + Serial Port: - + """ + self.status_text_obj = axes[1][0].text( + 0.05, + 0.95, + status_text, + verticalalignment="top", + horizontalalignment="left", + transform=axes[1][0].transAxes, + fontsize=12, + color="black", + ) + self.status_text = """ + Frame: -, + Time: -/-, + Load: -, + Frequency: -Hz, + Integration Time: -s + Serial Port: - + """ -import csv -from datetime import datetime #to easily get miliseconds -csv_filename = None -def log_annotations_to_csv(annotation_frame): - anns_data = [] - for type in ['std', 'user']: - for ann_name in temp_annotations[type]: - pos = annotations.get_pos(ann_name) - val = round(annotations.get_val(ann_name, annotation_frame), 2) - anns_data += [pos[0], pos[1], val] # store each position and value - if csv_filename is not None: - with open(csv_filename, 'a', newline='') as f: - writer = csv.writer(f) - writer.writerow([datetime.now()] + anns_data) + else: + self.fig = plt.figure() + self.ax = plt.gca() + self.im = self.ax.imshow(self.frame, cmap=CMAP_NAMES[self.cmaps_idx]) + divider = make_axes_locatable(self.ax) + cax = divider.append_axes("right", size="5%", pad=0.05) + plt.colorbar(self.im, cax=cax) -import csv -from datetime import datetime #to easily get miliseconds -csv_filename = None -def log_annotations_to_csv(annotation_frame): + try: + self.fig.canvas.set_window_title("Thermal Camera") + except: + # does not work on windows + pass + + self.annotations = utils.Annotations(self.ax, patches) + self.temp_annotations = { + "std": {"Tmin": "lightblue", "Tmax": "red", "Tcenter": "yellow"}, + "user": {}, + } + + # Add the patch to the Axes + self.roi = ((0, 0), (0, 0)) + + self.paused = False + self.update_colormap = True + self.diff = { + "enabled": False, + "annotation_enabled": False, + "frame": np.zeros(self.frame.shape), + } + + self.csv_filename = None + self.mouse_action_pos = (0, 0) + self.mouse_action = None + + +app_state = AppState(args) + + +def log_annotations_to_csv(annotation_frame) -> None: anns_data = [] - for type in ['std', 'user']: - for ann_name in temp_annotations[type]: - pos = annotations.get_pos(ann_name) - val = round(annotations.get_val(ann_name, annotation_frame), 2) + for ann_type in ["std", "user"]: + for ann_name in app_state.temp_annotations[ann_type]: + pos = app_state.annotations.get_pos(ann_name) + val = round(app_state.annotations.get_val(ann_name, annotation_frame), 2) anns_data += [pos[0], pos[1], val] # store each position and value - if csv_filename is not None: - with open(csv_filename, 'a', newline='') as f: + if app_state.csv_filename is not None: + with open(app_state.csv_filename, "a", newline="") as f: writer = csv.writer(f) writer.writerow([datetime.now()] + anns_data) -def animate_func(i): - global lut, frame, info, paused, update_colormap, exposure, im, diff, lut_frame - ret, frame = camera.read() - if not paused: - info, lut = camera.info() - lut_frame = lut[frame] +def get_lockin_frame(freq, port, integration, invert = False): + """ + Perform all of the lock-in thermometry operations. + + Returns the in-phase and quadrature frames after the integration time is up, while + controlling the load via serial communication based on the period of the signal. + """ + try: + ser = serial.Serial(port, 115200) # Open the serial port + except serial.SerialException as exc: + print(f"Error: could not open serial port {port} ({exc})") + sys.exit(1) + + start_time = time.time() + in_phase_sum = np.zeros((app_state.camera.height, app_state.camera.width)) + quadrature_sum = np.zeros((app_state.camera.height, app_state.camera.width)) + total_frames = 0 + + # Calculate the period of the signal + period = 1.0 / freq + half_period = period / 2.0 # Toggle every half period + load_on = True # Track whether the load is on or off + last_toggle_time = start_time # Track the last time we toggled the load + + while (time.time() - start_time) < integration: + current_time = time.time() - start_time # Time since the loop started + ret, raw_frame = app_state.camera.read() + info, lut = app_state.camera.info() + + app_state.frame = app_state.camera.convert_to_frame(raw_frame, lut) + + if not ret: + print("Error: could not read frame from camera") + sys.exit(1) + + total_frames += 1 + + # if(total_frames % 10 == 0): + # print(f'Frame: {total_frames}, Time: {current_time:.2f}/{integration:.2f}') + if True: + app_state.status_text = f""" +Frame: {total_frames}, +Time: {current_time:.2f}/{integration:.2f}, +Load: {load_on}, +Frequency: {freq:.2f}Hz, +Integration Time: {integration:.2f}s +Serial Port: {port} +""" + + # Check if a half period has passed (i.e., time to toggle the load) + if current_time - (last_toggle_time - start_time) >= half_period: + # Toggle the load state + if load_on: + if invert: + ser.write(b"1\n") # Send 1 to turn the load on + else: + ser.write(b"0\n") # Send 0 to turn the load off + load_on = False + else: + if invert: + ser.write(b"0\n") # Send 0 to turn the load off + else: + ser.write(b"1\n") # Send 1 to turn the load on + load_on = True + + # print(f'Load state: {load_on}, Time: {current_time}') # debugging + + # Update the last toggle time + last_toggle_time += half_period - if diff['enabled']: show_frame = lut_frame - diff['frame'] - else: show_frame = lut_frame - if diff['annotation_enabled']: - annotation_frame = lut_frame - diff['frame'] - else: annotation_frame = lut_frame + # Calculate the phase angle based on the current time and frequency + phase = 2 * math.pi * freq * current_time - im.set_array(show_frame) + # Calculate sine and cosine factors + sin_weight = 2 * math.sin(phase) + cos_weight = -2 * math.cos(phase) - annotations.update(temp_annotations, annotation_frame, draw_temp) + # print(f"time: {current_time}, phase: {phase}" + # f", sin: {sin_weight}, cos: {cos_weight} , load: {load_on}") - if exposure['auto']: - update_colormap = utils.autoExposure(update_colormap, exposure, show_frame) + # Multiply the frame by sin and cos to get in-phase and quadrature components + in_phase = raw_frame * sin_weight + quadrature = raw_frame * cos_weight + # Accumulate the sums + in_phase_sum += in_phase + quadrature_sum += quadrature + + if not app_state.is_capturing: + break + + ser.write(b"0\n") + ser.close() + + # After integration time, normalize by the total frames (optional) + in_phase_sum /= total_frames + quadrature_sum /= total_frames + + return in_phase_sum, quadrature_sum + + +def capture_lock_in(): + while app_state.is_capturing: + in_phase, quad = get_lockin_frame( + app_state.fequency, app_state.port, app_state.integration, app_state.negate + ) + if in_phase is not None and quad is not None: + with app_state.lock: + app_state.in_phase_frame = in_phase + app_state.quad_frame = quad + + +def start_capture(): + app_state.is_capturing = True + thread = threading.Thread(target=capture_lock_in) + thread.start() + return thread + + +def stop_capture(thread): + app_state.is_capturing = False + if thread is not None: + thread.join() + + +def animate_func(_frame: int) -> None: + if app_state.lockin and app_state.start_skips > 0: + app_state.frame = app_state.camera.get_frame() + app_state.start_skips -= 1 + elif app_state.lockin: + if not app_state.is_capturing: + app_state.lock_in_thread = start_capture() + # in_phase_frame, quad_frame = get_lockin_frame(fequency, port, integration) + else: + app_state.frame = app_state.camera.get_frame() + + if not app_state.paused: + if app_state.diff["enabled"]: + show_frame = app_state.frame - app_state.diff["frame"] + else: + show_frame = app_state.frame + + if app_state.diff["annotation_enabled"]: + annotation_frame = app_state.frame - app_state.diff["frame"] + else: + annotation_frame = app_state.frame + + app_state.im.set_array(show_frame) + if app_state.lockin: + app_state.im_in_phase.set_array(app_state.in_phase_frame) + app_state.im_quadrature.set_array(app_state.quad_frame) + + app_state.annotations.update( + app_state.temp_annotations, annotation_frame, app_state.draw_temp + ) + + if app_state.exposure["auto"]: + app_state.update_colormap = utils.autoExposure( + app_state.update_colormap, app_state.exposure, show_frame + ) + + # TODO: deal with saving the lock in stuff to a file log_annotations_to_csv(annotation_frame) - if update_colormap: - im.set_clim(exposure['T_min'], exposure['T_max']) - fig.canvas.resize_event() #force update all, even with blit=True - update_colormap = False + if app_state.update_colormap: + app_state.im.set_clim( + app_state.exposure["T_min"], app_state.exposure["T_max"] + ) + app_state.fig.canvas.draw_idle() # force update all, even with blit=True + app_state.update_colormap = False return [] - return [im] + annotations.get() + if app_state.lockin: + # adjust the color limits for the in-phase and quadrature frames + app_state.im_in_phase.set_clim( + np.min(app_state.in_phase_frame), np.max(app_state.in_phase_frame) + ) + app_state.im_quadrature.set_clim( + np.min(app_state.quad_frame), np.max(app_state.quad_frame) + ) + new_status_text = getattr(app_state, "status_text", "") + app_state.status_text_obj.set_text(new_status_text) + return [ + app_state.im, + app_state.im_in_phase, + app_state.im_quadrature, + app_state.status_text_obj, + ] + app_state.annotations.get() + + return [app_state.im] + app_state.annotations.get() + def print_help(): - print('''keys: + print("""keys: 'h' - help 'q' - quit ' ' - pause, resume @@ -152,118 +479,192 @@ def print_help(): 'v' - record annotations data to file date.csv ',', '.' - change color map 'a', 'z' - auto exposure on/off, auto exposure type - 'k', 'l' - set the thermal range to normal/high (supported by T2S+/T2L) + 'k', 'l' - set the thermal range to normal/high (supported by T2S+/T2L) left, right, up, down - set exposure limits mouse: left button - add Region Of Interest (ROI) right button - add user temperature annotation -''') +""") + FILE_NAME_FORMAT = "%Y-%m-%d_%H-%M-%S" -#keyboard + +# keyboard def press(event): - global paused, exposure, update_colormap, cmaps_idx, draw_temp, temp_extra_annotations, csv_filename - global lut_frame, lut, frame, diff, annotations, roi - if event.key == 'h': print_help() - if event.key == ' ': paused ^= True; print('paused:', paused) - if event.key == 'd': diff['frame'] = lut_frame; diff['annotation_enabled'] = diff['enabled'] = True; print('set diff') - if event.key == 'x': diff['enabled'] ^= True; print('enable diff:', diff['enabled']) - if event.key == 'c': diff['annotation_enabled'] ^= True; print('enable annotation diff:', diff['annotation_enabled']) - if event.key == 't': draw_temp ^= True; print('draw temp:', draw_temp) - if event.key == 'e': - print('removing user annotations: ', len(temp_annotations['user'])) - annotations.remove(temp_annotations['user']) - if event.key == 'u': print('calibrate'); camera.calibrate() - if event.key == 'a': exposure['auto'] ^= True; print('auto exposure:', exposure['auto'], ', type:', exposure['auto_type']) - if event.key == 'z': - types = ['center', 'ends'] - exposure['auto_type'] = types[types.index(exposure['auto_type'])-1] - print('auto exposure:', exposure['auto'], ', type:', exposure['auto_type']) - if event.key == 'w': - filename = time.strftime(FILE_NAME_FORMAT) + '.png' + if event.key == "h": + print_help() + if event.key == " ": + app_state.paused ^= True + print("paused:", app_state.paused) + if event.key == "d": + app_state.diff["frame"] = app_state.frame + app_state.diff["annotation_enabled"] = app_state.diff["enabled"] = True + print("set diff") + if event.key == "x": + app_state.diff["enabled"] ^= True + print("enable diff:", app_state.diff["enabled"]) + if event.key == "c": + app_state.diff["annotation_enabled"] ^= True + print("enable annotation diff:", app_state.diff["annotation_enabled"]) + if event.key == "t": + app_state.draw_temp ^= True + print("draw temp:", app_state.draw_temp) + if event.key == "e": + print("removing user annotations: ", len(app_state.temp_annotations["user"])) + app_state.annotations.remove(app_state.temp_annotations["user"]) + if event.key == "u": + print("calibrate") + app_state.camera.calibrate() + if event.key == "a": + app_state.exposure["auto"] ^= True + print( + "auto exposure:", + app_state.exposure["auto"], + ", type:", + app_state.exposure["auto_type"], + ) + if event.key == "z": + types = ["center", "ends"] + app_state.exposure["auto_type"] = types[ + types.index(app_state.exposure["auto_type"]) - 1 + ] + print( + "auto exposure:", + app_state.exposure["auto"], + ", type:", + app_state.exposure["auto_type"], + ) + if event.key == "w": + filename = time.strftime(FILE_NAME_FORMAT) + ".png" plt.savefig(filename) - print('saved to:', filename) - if event.key == 'r': - filename = time.strftime(FILE_NAME_FORMAT) + '.npy' - np.save(filename, camera.frame_raw_u16.reshape(camera.height+4, camera.width)) - print('saved to:', filename) - if event.key == 'v': - if csv_filename is None: - csv_filename = time.strftime(FILE_NAME_FORMAT) + '.csv' - with open(csv_filename, 'w', newline='') as f: + print("saved to:", filename) + if event.key == "r": + filename = time.strftime(FILE_NAME_FORMAT) + ".npy" + np.save( + filename, + app_state.camera.frame_raw_u16.reshape( + app_state.camera.height + 4, app_state.camera.width + ), + ) + print("saved to:", filename) + if event.key == "v": + if app_state.csv_filename is None: + csv_filename = time.strftime(FILE_NAME_FORMAT) + ".csv" + with open(csv_filename, "w", newline="") as f: header = ["time"] - header += [f'{a} {x}' for a in temp_annotations['std'].keys() for x in ['x', 'y', 'val']] #t, tmin x, tmin y, tmin val, etc - header += [f'Point{i} {x}' for i, key in enumerate(temp_annotations['user'].keys()) for x in ['x', 'y', 'val']] + header += [ + f"{a} {x}" + for a in app_state.temp_annotations["std"] + for x in ["x", "y", "val"] + ] # t, tmin x, tmin y, tmin val, etc + header += [ + f"Point{i} {x}" + for i, key in enumerate(app_state.temp_annotations["user"].keys()) + for x in ["x", "y", "val"] + ] csv.writer(f).writerow(header) - print('Annotation recording started in:', csv_filename) + print("Annotation recording started in:", csv_filename) else: - print('Annotation recording saved in:', csv_filename) + print("Annotation recording saved in:", csv_filename) csv_filename = None - - if event.key in [',', '.']: - if event.key == '.': cmaps_idx= (cmaps_idx + 1) % len(cmaps) - else: cmaps_idx= (cmaps_idx - 1) % len(cmaps) - print('color map:', cmaps[cmaps_idx]) - im.set_cmap(cmaps[cmaps_idx]) - update_colormap = True - if event.key in ['k', 'l']: - if event.key == 'k': - camera.temperature_range_normal() + + if event.key in [",", "."]: + if event.key == ".": + app_state.cmaps_idx = (app_state.cmaps_idx + 1) % len(CMAP_NAMES) + else: + app_state.cmaps_idx = (app_state.cmaps_idx - 1) % len(CMAP_NAMES) + print("color map:", CMAP_NAMES[app_state.cmaps_idx]) + app_state.im.set_cmap(CMAP_NAMES[app_state.cmaps_idx]) + app_state.update_colormap = True + if event.key in ["k", "l"]: + if event.key == "k": + app_state.camera.temperature_range_normal() else: - camera.temperature_range_high() - camera.calibrate() - if event.key in ['left', 'right', 'up', 'down']: - exposure['auto'] = False - T_cent = int((exposure['T_min'] + exposure['T_max'])/2) - d = int(exposure['T_max'] - T_cent) - if event.key == 'up': T_cent += exposure['T_margin']/2 - if event.key == 'down': T_cent -= exposure['T_margin']/2 - if event.key == 'left': d -= exposure['T_margin']/2 - if event.key == 'right': d += exposure['T_margin']/2 - d = max(d, exposure['T_margin']) - exposure['T_min'] = T_cent - d - exposure['T_max'] = T_cent + d - print('auto exposure off, T_min:', exposure['T_min'], 'T_cent:', T_cent, 'T_max:', exposure['T_max']) - update_colormap = True - -mouse_action_pos = (0,0) -mouse_action = None + app_state.camera.temperature_range_high() + # this takes care of calibration as well + app_state.camera.wait_for_range_application() + app_state.update_colormap = True + if event.key in ["left", "right", "up", "down"]: + app_state.exposure["auto"] = False + t_cent = int((app_state.exposure["T_min"] + app_state.exposure["T_max"]) / 2) + d = int(app_state.exposure["T_max"] - t_cent) + if event.key == "up": + t_cent += app_state.exposure["T_margin"] / 2 + if event.key == "down": + t_cent -= app_state.exposure["T_margin"] / 2 + if event.key == "left": + d -= app_state.exposure["T_margin"] / 2 + if event.key == "right": + d += app_state.exposure["T_margin"] / 2 + d = max(d, app_state.exposure["T_margin"]) + app_state.exposure["T_min"] = t_cent - d + app_state.exposure["T_max"] = t_cent + d + print( + "auto exposure off, T_min:", + app_state.exposure["T_min"], + "T_cent:", + t_cent, + "T_max:", + app_state.exposure["T_max"], + ) + app_state.update_colormap = True + + def onclick(event): - global mouse_action, mouse_action_pos - if event.inaxes == ax: + if event.inaxes == app_state.ax: pos = (int(event.xdata), int(event.ydata)) if event.button == MouseButton.RIGHT: - print('add user temperature annotation at pos:', pos) - temp_annotations['user'][pos] = 'white' + print("add user temperature annotation at pos:", pos) + app_state.temp_annotations["user"][pos] = "white" if event.button == MouseButton.LEFT: - if utils.inRoi(annotations.roi, pos, frame.shape): - mouse_action = 'move_roi' - mouse_action_pos = (annotations.roi[0][0] - pos[0], annotations.roi[0][1] - pos[1]) + if utils.inRoi(app_state.annotations.roi, pos, app_state.frame.shape): + app_state.mouse_action = "move_roi" + app_state.mouse_action_pos = ( + app_state.annotations.roi[0][0] - pos[0], + app_state.annotations.roi[0][1] - pos[1], + ) else: - mouse_action = 'create_roi' - mouse_action_pos = pos - annotations.set_roi((pos, (0,0))) + app_state.mouse_action = "create_roi" + app_state.mouse_action_pos = pos + app_state.annotations.set_roi((pos, (0, 0))) + def onmotion(event): - global mouse_action, mouse_action_pos, roi - if event.inaxes == ax and event.button == MouseButton.LEFT: + if event.inaxes == app_state.ax and event.button == MouseButton.LEFT: pos = (int(event.xdata), int(event.ydata)) - if mouse_action == 'create_roi': - w,h = pos[0] - mouse_action_pos[0], pos[1] - mouse_action_pos[1] - roi = (mouse_action_pos, (w,h)) - annotations.set_roi(roi) - if mouse_action == 'move_roi': - roi = ((pos[0] + mouse_action_pos[0], pos[1] + mouse_action_pos[1]), annotations.roi[1]) - annotations.set_roi(roi) - - -anim = animation.FuncAnimation(fig, animate_func, interval = 1000 / fps, blit=True) -fig.canvas.mpl_connect('button_press_event', onclick) -fig.canvas.mpl_connect('motion_notify_event', onmotion) -fig.canvas.mpl_connect('key_press_event', press) - -print_help() -plt.show() -camera.release() + if app_state.mouse_action == "create_roi": + w, h = ( + pos[0] - app_state.mouse_action_pos[0], + pos[1] - app_state.mouse_action_pos[1], + ) + app_state.roi = (app_state.mouse_action_pos, (w, h)) + app_state.annotations.set_roi(app_state.roi) + if app_state.mouse_action == "move_roi": + app_state.roi = ( + ( + pos[0] + app_state.mouse_action_pos[0], + pos[1] + app_state.mouse_action_pos[1], + ), + app_state.annotations.roi[1], + ) + app_state.annotations.set_roi(app_state.roi) + + +def main() -> None: + _keep_me_anim = animation.FuncAnimation( + app_state.fig, animate_func, interval=1000 / app_state.fps, blit=True, cache_frame_data=False + ) + app_state.fig.canvas.mpl_connect("button_press_event", onclick) + app_state.fig.canvas.mpl_connect("motion_notify_event", onmotion) + app_state.fig.canvas.mpl_connect("key_press_event", press) + + print_help() + plt.show() + stop_capture(app_state.lock_in_thread) + app_state.camera.release() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..42b8689 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "ir-py-thermal" +version = "0.1.0" +description = "ir-py-thermal" +authors = [{name = "Dima", email = "me@dmytroengineering.com"}] +readme = "README.md" +requires-python = ">=3.10.4" +dependencies = [ + "matplotlib>=3.9.2", + "pyserial>=3.5", + "scikit-image>=0.25.2", + "imageio-ffmpeg>=0.6.0", + "imageio>=2.37.2", + "pyusb>=1.3.1", + "av>=16.0.1", + "opencv-python>=4.12.0.88", +] + +[project.urls] +repository = "https://github.com/diminDDL/IR-Py-Thermal" + +[dependency-groups] +dev = [ + "ruff>=0.7.3", +] + +[tool.uv] +package = false + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "ISC001", + "COM812", # trailing comma missing + "D203", # one-blank-line-before-class + "D212", # multi-line-summary-first-line + "T201", # print-found + "TD002", # issue author missing + "TD003", # issue link missing + ] diff --git a/utils.py b/utils.py index 02d2ede..ee67aea 100755 --- a/utils.py +++ b/utils.py @@ -1,110 +1,131 @@ -import numpy as np -import cv2 - +#!/usr/bin/env python3 +import cv2 +import numpy as np -def drawTemperature(img, point, T, color = (0,0,0)): +def drawTemperature(img, point, T, color=(0, 0, 0)): d1, d2 = 2, 5 dsize = 1 font = cv2.FONT_HERSHEY_PLAIN - (x, y) = point - t = '%.2fC' % T - cv2.line(img,(x+d1, y),(x+d2,y),color, dsize) - cv2.line(img,(x-d1, y),(x-d2,y),color, dsize) - cv2.line(img,(x, y+d1),(x,y+d2),color, dsize) - cv2.line(img,(x, y-d1),(x,y-d2),color, dsize) + (x, y) = int(point[0]), int(point[1]) + t = "%.2fC" % T + cv2.line(img, (x + d1, y), (x + d2, y), color, dsize) + cv2.line(img, (x - d1, y), (x - d2, y), color, dsize) + cv2.line(img, (x, y + d1), (x, y + d2), color, dsize) + cv2.line(img, (x, y - d1), (x, y - d2), color, dsize) text_size = cv2.getTextSize(t, font, 1, dsize)[0] - tx, ty = x+d1, y+d1+text_size[1] - if tx + text_size[0] > img.shape[1]: tx = x-d1-text_size[0] - if ty > img.shape[0]: ty = y-d1 + tx, ty = int(x + d1), int(y + d1 + text_size[1]) + if tx + text_size[0] > img.shape[1]: + tx = int(x - d1 - text_size[0]) + if ty > img.shape[0]: + ty = int(y - d1) + + cv2.putText(img, t, (tx, ty), font, 1, color, dsize, cv2.LINE_8) - cv2.putText(img, t, (tx,ty), font, 1, color, dsize, cv2.LINE_8) def autoExposure(update, exposure, frame): # Sketchy auto-exposure lmin, lmax = frame.min(), frame.max() - T_min, T_max, T_margin = exposure['T_min'], exposure['T_max'], exposure['T_margin'] - if exposure['auto_type'] == 'center': - T_cent = int((T_min+T_max)/2) - d = int(max(T_cent-lmin, lmax-T_cent, 0) + T_margin) - if lmin < T_min or T_max < lmax or (T_min + 2 * T_margin < lmin and T_max - 2 * T_margin > lmax): -# print('d:',d, 'lmin:', lmin, 'lmax:', lmax) + T_min, T_max, T_margin = exposure["T_min"], exposure["T_max"], exposure["T_margin"] + if exposure["auto_type"] == "center": + T_cent = int((T_min + T_max) / 2) + d = int(max(T_cent - lmin, lmax - T_cent, 0) + T_margin) + if ( + lmin < T_min + or T_max < lmax + or (T_min + 2 * T_margin < lmin and T_max - 2 * T_margin > lmax) + ): + # print('d:',d, 'lmin:', lmin, 'lmax:', lmax) update = True T_min, T_max = T_cent - d, T_cent + d -# print('T_min:', T_min, 'T_cent:', T_cent, 'T_max:', T_max) - if exposure['auto_type'] == 'ends': - if T_min > lmin: update, T_min = True, lmin-T_margin - if T_min + 2 * T_margin < lmin: update, T_min = True, lmin-T_margin - if T_max < lmax: update, T_max = True, lmax+T_margin - if T_max - 2 * T_margin > lmax: update, T_max = True, lmax+T_margin - - exposure['T_min'] = T_min - exposure['T_max'] = T_max + # print('T_min:', T_min, 'T_cent:', T_cent, 'T_max:', T_max) + if exposure["auto_type"] == "ends": + if T_min > lmin: + update, T_min = True, lmin - T_margin + if T_min + 2 * T_margin < lmin: + update, T_min = True, lmin - T_margin + if T_max < lmax: + update, T_max = True, lmax + T_margin + if T_max - 2 * T_margin > lmax: + update, T_max = True, lmax + T_margin + + exposure["T_min"] = T_min + exposure["T_max"] = T_max return update + def correctRoi(roi, shape): - ((x,y),(w,h)) = roi -# if w == 0 and h == 0: -# ((x,y),(w,h)) = ((0,0), shape) - x1,x2 = max(0, min(x,x+w)), max(0, x,x+w) - y1,y2 = max(0, min(y,y+h)), max(0, y,y+h) -# if x1 == x2: x2 += 1 -# if y1 == y2: y2 += 1 - return ((x1,y1),(x2,y2)) + ((x, y), (w, h)) = roi + # if w == 0 and h == 0: + # ((x,y),(w,h)) = ((0,0), shape) + x1, x2 = max(0, min(x, x + w)), max(0, x, x + w) + y1, y2 = max(0, min(y, y + h)), max(0, y, y + h) + # if x1 == x2: x2 += 1 + # if y1 == y2: y2 += 1 + return ((x1, y1), (x2, y2)) def inRoi(roi, point, shape): result = False - ((x1,y1),(x2,y2)) = correctRoi(roi, shape) + ((x1, y1), (x2, y2)) = correctRoi(roi, shape) if x1 < point[0] and point[0] < x2: if y1 < point[1] and point[1] < y2: result = True return result -def subdict(d, l): - return dict((k,d[k]) for k in l if k in d) - - - - class Annotations: def __init__(self, ax, patches): self.ax = ax - self.astyle = dict(text='', xy=(0, 0), xytext=(0, 0), textcoords='offset pixels', arrowprops=dict(facecolor='black', arrowstyle="->")) + self.astyle = dict( + text="", + xy=(0, 0), + xytext=(0, 0), + textcoords="offset pixels", + arrowprops=dict(facecolor="black", arrowstyle="->"), + ) self.anns = {} - self.roi_patch = ax.add_patch(patches.Rectangle((0, 0), 0, 0, linewidth=1, edgecolor='black', facecolor='none')) - self.set_roi(((0,0),(0,0))) + self.roi_patch = ax.add_patch( + patches.Rectangle( + (0, 0), 0, 0, linewidth=1, edgecolor="black", facecolor="none" + ) + ) + self.set_roi(((0, 0), (0, 0))) def set_roi(self, roi): self.roi = roi - ((x,y), (w,h)) = roi - self.roi_patch.xy = (x,y) + ((x, y), (w, h)) = roi + self.roi_patch.xy = (x, y) self.roi_patch.set_width(w) self.roi_patch.set_height(h) - self.roi_patch.set_visible(w!=0 and h!=0) + self.roi_patch.set_visible(w != 0 and h != 0) def get_ann(self, name, color): if name not in self.anns: - self.anns[name] = self.ax.annotate(**self.astyle, bbox=dict(boxstyle='square', fc=color, alpha=0.3, lw=0)) + self.anns[name] = self.ax.annotate( + **self.astyle, bbox=dict(boxstyle="square", fc=color, alpha=0.3, lw=0) + ) return self.anns[name] def get_pos(self, name): pos = self.get_ann(name, "").xy return pos - + def get_val(self, name, annotation_frame): annotation_pos = self.get_pos(name) val = annotation_frame[annotation_pos[::-1]] return val def update(self, temp_annotations, annotation_frame, draw_temp): - l = temp_annotations['std'].items() | temp_annotations['user'].items() - for name, color in l: + for name, color in ( + temp_annotations["std"].items() | temp_annotations["user"].items() + ): pos = self._get_pos(name, annotation_frame, self.roi) - self._ann_set_temp(self.get_ann(name, color), pos, annotation_frame, draw_temp) + self._ann_set_temp( + self.get_ann(name, color), pos, annotation_frame, draw_temp + ) def get(self): return list(self.anns.values()) + [self.roi_patch] @@ -117,31 +138,32 @@ def remove(self, d): d.clear() def _ann_set_temp(self, ann, pos, annotation_frame, draw_temp): - (x,y) = pos - ann.xy = pos + (x, y) = pos + ann.xy = pos value = annotation_frame[pos[1], pos[0]] - ann.set_text('%.2f$^\circ$C' % value) + ann.set_text("%.2f$^\circ$C" % value) ann.set_visible(draw_temp) - tx,ty = 20, 15 - if x > annotation_frame.shape[1]-50: tx = -80 - if y < 30: ty = -15 + tx, ty = 20, 15 + if x > annotation_frame.shape[1] - 50: + tx = -80 + if y < 30: + ty = -15 ann.xyann = (tx, ty) - def _get_pos(self, name, annotation_frame, roi): - ((x1,y1),(x2,y2)) = correctRoi(roi, annotation_frame.shape) - roi_frame = annotation_frame[y1:y2,x1:x2] + ((x1, y1), (x2, y2)) = correctRoi(roi, annotation_frame.shape) + roi_frame = annotation_frame[y1:y2, x1:x2] if roi_frame.size <= 0: - x1,y1 = 0, 0 + x1, y1 = 0, 0 roi_frame = annotation_frame - if name == 'Tmin': + if name == "Tmin": pos = np.unravel_index(roi_frame.argmin(), roi_frame.shape) - pos = (pos[1]+x1, pos[0]+y1) - elif name == 'Tmax': + pos = (pos[1] + x1, pos[0] + y1) + elif name == "Tmax": pos = np.unravel_index(roi_frame.argmax(), roi_frame.shape) - pos = (pos[1]+x1, pos[0]+y1) - elif name == 'Tcenter': - pos = (annotation_frame.shape[1]//2, annotation_frame.shape[0]//2) + pos = (pos[1] + x1, pos[0] + y1) + elif name == "Tcenter": + pos = (annotation_frame.shape[1] // 2, annotation_frame.shape[0] // 2) else: pos = name return pos