diff --git a/.github/workflows/qualitycheck.yml b/.github/workflows/qualitycheck.yml new file mode 100644 index 0000000..960aa82 --- /dev/null +++ b/.github/workflows/qualitycheck.yml @@ -0,0 +1,33 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Code quality check + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [3.8] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pylama + - name: PyLama + run: | + pylama . diff --git a/.gitignore b/.gitignore index 60e6ab3..58200d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1 @@ -__pycache__/ -*.SEQ -DJI_0586.jpg -images/ \ No newline at end of file +__pycache__/ diff --git a/CThermal.py b/CThermal.py deleted file mode 100644 index ecc2920..0000000 --- a/CThermal.py +++ /dev/null @@ -1,946 +0,0 @@ -#!/usr/bin/env python3 -import io -import json -import os -import subprocess as sp -import sys -from math import exp, log, sqrt -from pathlib import Path -from threading import Thread - -import cv2 as cv -import numpy as np -from logzero import logger -from matplotlib import pyplot as plt -from PIL import Image -from tqdm import tqdm - - -plt.style.use("ggplot") - -class CFlir: - line_flag = False - contour = [] - drawing = False - scale_moving = False - measurement_moving = False - rect_moving = False - spots_moving = False - xo, yo = 0, 0 - xdisp, ydisp = None, None - measurement_index = None - rect_index = None - spots_index = None - - def __init__(self, image_path, color_map="jet"): - """Base Class for FLIR thermal images""" - self.image_path = image_path - self.cmap = color_map - self.thermal_np, self.raw_thermal_np, self.meta = self.extract_temperatures() - self.thermal_image = CFlir.get_temp_image(self.thermal_np, colormap=self.cmap) - self.global_min_temp = np.min(self.thermal_np) - self.global_max_temp = np.max(self.thermal_np) - self.scale_contours = [] - self.measurement_contours = [] - self.measurement_rects = [] - self.spots = [] - - @staticmethod - def raw2temp( - raw, - E=0.9, - OD=1, - RTemp=20, - ATemp=20, - IRWTemp=20, - IRT=1, - RH=50, - PR1=21106.77, - PB=1501, - PF=1, - PO=-7340, - PR2=0.012545258, - ): - """ convert raw values from the flir sensor to temperatures in °C """ - # this calculation has been ported to python from https://github.com/gtatters/Thermimage/blob/master/R/raw2temp.R - # a detailed explanation of what is going on here can be found there - - # constants - ATA1 = 0.006569 - ATA2 = 0.01262 - ATB1 = -0.002276 - ATB2 = -0.00667 - ATX = 1.9 - # RH=0 - # transmission through window (calibrated) - emiss_wind = 1 - IRT - refl_wind = 0 - # transmission through the air - h2o = (RH / 100) * exp(1.5587 + 0.06939 * (ATemp) - 0.00027816 * (ATemp) ** 2 + 0.00000068455 * (ATemp) ** 3) - tau1 = ATX * exp(-sqrt(OD / 2) * (ATA1 + ATB1 * sqrt(h2o))) + (1 - ATX) * exp( - -sqrt(OD / 2) * (ATA2 + ATB2 * sqrt(h2o)) - ) - tau2 = ATX * exp(-sqrt(OD / 2) * (ATA1 + ATB1 * sqrt(h2o))) + (1 - ATX) * exp( - -sqrt(OD / 2) * (ATA2 + ATB2 * sqrt(h2o)) - ) - # radiance from the environment - raw_refl1 = PR1 / (PR2 * (exp(PB / (RTemp + 273.15)) - PF)) - PO - raw_refl1_attn = (1 - E) / E * raw_refl1 # Reflected component - - raw_atm1 = PR1 / (PR2 * (exp(PB / (ATemp + 273.15)) - PF)) - PO # Emission from atmosphere 1 - raw_atm1_attn = (1 - tau1) / E / tau1 * raw_atm1 # attenuation for atmospheric 1 emission - - raw_wind = PR1 / (PR2 * (exp(PB / (IRWTemp + 273.15)) - PF)) - PO # Emission from window due to its own temp - raw_wind_attn = emiss_wind / E / tau1 / IRT * raw_wind # Componen due to window emissivity - - raw_refl2 = ( - PR1 / (PR2 * (exp(PB / (RTemp + 273.15)) - PF)) - PO - ) # Reflection from window due to external objects - raw_refl2_attn = refl_wind / E / tau1 / IRT * raw_refl2 # component due to window reflectivity - - raw_atm2 = PR1 / (PR2 * (exp(PB / (ATemp + 273.15)) - PF)) - PO # Emission from atmosphere 2 - raw_atm2_attn = (1 - tau2) / E / tau1 / IRT / tau2 * raw_atm2 # attenuation for atmospheric 2 emission - - raw_obj = ( - raw / E / tau1 / IRT / tau2 - - raw_atm1_attn - - raw_atm2_attn - - raw_wind_attn - - raw_refl1_attn - - raw_refl2_attn - ) - val_to_log = PR1 / (PR2 * (raw_obj + PO)) + PF - if any(val_to_log.ravel() < 0): - logger.warning("Image seems to be corrupted") - val_to_log = np.where(val_to_log < 0, sys.float_info.min, val_to_log) - # temperature from radiance - temp_C = PB / np.log(val_to_log) - 273.15 - - return temp_C - - @staticmethod - def parse_temp(temp_str): - # TODO: do this right - # we assume degrees celsius - return float(temp_str.split()[0]) - - @staticmethod - def parse_length(length_str): - # TODO: do this right - # we assume meters - return float(length_str.split()[0]) - - @staticmethod - def parse_percent(percentage_str): - return float(percentage_str.split()[0]) - - def generate_colorbar(self, min_temp=None, max_temp=None, cmap=cv.COLORMAP_JET, height=None): - if min_temp is None: - min_temp = self.global_min_temp - if max_temp is None: - max_temp = self.global_max_temp - cb_gray = np.arange(255, 0, -1, dtype=np.uint8).reshape((255, 1)) - if cmap is not None: - cb_color = cv.applyColorMap(cb_gray, cmap) - else: - cb_color = cv.cvtColor(cb_gray, cv.COLOR_GRAY2BGR) - for i in range(1, 6): - cb_color = np.concatenate((cb_color, cb_color), axis=1) - - if height is None: - append_img = np.zeros((self.thermal_image.shape[0], cb_color.shape[1] + 30, 3), dtype=np.uint8) - else: - append_img = np.zeros((height, cb_color.shape[1] + 30, 3), dtype=np.uint8) - - append_img[ - append_img.shape[0] // 2 - - cb_color.shape[0] // 2 : append_img.shape[0] // 2 - - (cb_color.shape[0] // 2) - + cb_color.shape[0], - 10 : 10 + cb_color.shape[1], - ] = cb_color - cv.putText( - append_img, - str(min_temp), - (5, append_img.shape[0] // 2 - (cb_color.shape[0] // 2) + cb_color.shape[0] + 30), - cv.FONT_HERSHEY_PLAIN, - 1, - (255, 0, 0), - 1, - 8, - ) - cv.putText( - append_img, - str(max_temp), - (5, append_img.shape[0] // 2 - cb_color.shape[0] // 2 - 20), - cv.FONT_HERSHEY_PLAIN, - 1, - (0, 0, 255), - 1, - 8, - ) - return append_img - - def extract_temperatures(self): - """ extracts the thermal image as 2D numpy array with temperatures in degC """ - - # read image metadata needed for conversion of the raw sensor values - # E=1,SD=1,RTemp=20,ATemp=RTemp,IRWTemp=RTemp,IRT=1,RH=50,PR1=21106.77,PB=1501,PF=1,PO=-7340,PR2=0.012545258 - if os.name == "nt": - # Windows - meta_json = sp.Popen( - f'exiftool.exe "{self.image_path}" -Emissivity -ObjectDistance -AtmosphericTemperature -ReflectedApparentTemperature -IRWindowTemperature -IRWindowTransmission -RelativeHumidity -PlanckR1 -PlanckB -PlanckF -PlanckO -PlanckR2 -j', - shell=True, - stdout=sp.PIPE, - ).communicate()[0] - else: - # Linux - meta_json = sp.Popen( - f'exiftool "{self.image_path}" -Emissivity -ObjectDistance -AtmosphericTemperature -ReflectedApparentTemperature -IRWindowTemperature -IRWindowTransmission -RelativeHumidity -PlanckR1 -PlanckB -PlanckF -PlanckO -PlanckR2 -j', - shell=True, - stdout=sp.PIPE, - ).communicate()[0] - - meta = json.loads(meta_json)[0] - - # exifread can't extract the embedded thermal image, use exiftool instead - # sp popen can't handle bytes - try: - thermal_img_bytes = sp.check_output(["exiftool", "-RawThermalImage", "-b", f"{self.image_path}"]) - except: - thermal_img_bytes = sp.check_output(["exiftool.exe", "-RawThermalImage", "-b", f"{self.image_path}"]) - - thermal_img_stream = io.BytesIO(thermal_img_bytes) - - thermal_img = Image.open(thermal_img_stream) - raw_thermal_np = np.array(thermal_img) - - # raw values -> temperature E=meta['Emissivity'] - raw2tempfunc = lambda x: CFlir.raw2temp( - x, - E=meta["Emissivity"], - OD=CFlir.parse_length(meta["ObjectDistance"]), - RTemp=CFlir.parse_temp(meta["ReflectedApparentTemperature"]), - ATemp=CFlir.parse_temp(meta["AtmosphericTemperature"]), - IRWTemp=CFlir.parse_temp(meta["IRWindowTemperature"]), - IRT=meta["IRWindowTransmission"], - RH=CFlir.parse_percent(meta["RelativeHumidity"]), - PR1=meta["PlanckR1"], - PB=meta["PlanckB"], - PF=meta["PlanckF"], - PO=meta["PlanckO"], - PR2=meta["PlanckR2"], - ) - thermal_np = raw2tempfunc(raw_thermal_np) - - return thermal_np, raw_thermal_np, meta - - @staticmethod - def normalize(thermal_np): - num = thermal_np - np.amin(thermal_np) - den = np.amax(thermal_np) - np.amin(thermal_np) - thermal_np = num / den - return thermal_np - - @staticmethod - def get_temp_image(thermal_np, colormap=cv.COLORMAP_JET): - thermal_np_norm = CFlir.normalize(thermal_np) - thermal_image = np.array(thermal_np_norm * 255, dtype=np.uint8) - if colormap != None: - thermal_image = cv.applyColorMap(thermal_image, colormap) - return thermal_image - - @staticmethod - def draw_contour_area(event, x, y, flags, params): - thermal_image = params[0] - contours = params[1] - - is_rect = params[2][0] - point1 = params[2][1] - point2 = params[2][2] - - if event == cv.EVENT_LBUTTONDOWN: - if CFlir.drawing == False: - CFlir.drawing = True - if is_rect: - point1[0] = (x, y) - - elif event == cv.EVENT_MOUSEMOVE: - if CFlir.drawing == True: - if not is_rect: - cv.circle(thermal_image, (x, y), 1, (0, 0, 0), -1) - CFlir.contour.append((x, y)) - else: - point2[0] = (x, y) - - elif event == cv.EVENT_LBUTTONUP: - CFlir.drawing = False - CFlir.contour = np.asarray(CFlir.contour, dtype=np.int32) - if len(CFlir.contour) > 0: - contours.append(CFlir.contour) - CFlir.contour = [] - - @staticmethod - def draw_spots(event, x, y, flags, params): - point = params[0] - flag = params[1] - point.clear() - - if event == cv.EVENT_MOUSEMOVE: - if CFlir.drawing == True: - point.append(x) - point.append(y) - - elif event == cv.EVENT_LBUTTONDOWN: - CFlir.drawing == False - point.append(x) - point.append(y) - flag[0] = False - - def get_spots(self, thermal_image): - CFlir.drawing = True - image_copy = thermal_image.copy() - original_copy = image_copy.copy() - if len(original_copy.shape) < 3: - cmap_copy = cv.applyColorMap(original_copy, cv.COLORMAP_JET) - - point = [] - spot_points = [] - flag = [True] - cv.namedWindow("Image") - cv.setMouseCallback("Image", CFlir.draw_spots, (point, flag)) - while 1: - image_copy = original_copy.copy() - for i in range(0, len(spot_points)): - cv.circle(image_copy, spot_points[i], 5, 0, -1) - try: - cv.circle(cmap_copy, spot_points[i], 5, 0, -1) - except: - cv.circle(original_copy, spot_points[i], 5, 0, -1) - - if len(point) > 0: - cv.circle(image_copy, tuple(point), 5, 0, -1) - - if flag[0] == False: - spot_points.append(tuple(point)) - flag[0] = True - - cv.imshow("Image", image_copy) - k = cv.waitKey(1) & 0xFF - - if k == 13 or k == 141: - break - - CFlir.drawing = False - cv.destroyAllWindows() - # origi_copy = cv.UMat(origi_copy) - if len(original_copy.shape) == 3: - gray = cv.cvtColor(original_copy, cv.COLOR_BGR2GRAY) - else: - gray = cv.cvtColor(cmap_copy, cv.COLOR_BGR2GRAY) - - ret, thresh = cv.threshold(gray, 10, 255, cv.THRESH_BINARY_INV) - contours, hierarchy = cv.findContours(thresh, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE) - self.spots = contours - - def get_spots_values(self, thermal_image, thermal_np, raw_thermal_np, contours): - spots_measurement_values = [] - for i in range(0, len(contours)): - spots_measurement_values.append(CFlir.get_roi(thermal_image, thermal_np, raw_thermal_np, self.spots, i)[1]) - - return spots_measurement_values - - @staticmethod - def draw_line(event, x, y, flags, params): - - lp1, lp2 = params[0], params[1] - thermal_image = params[2] - - if len(lp1) <= 2 and len(lp2) < 2: - if event == cv.EVENT_LBUTTONDOWN: - CFlir.line_flag = not (CFlir.line_flag) - if CFlir.line_flag == True: - lp1.append(x) - lp1.append(y) - - else: - lp2.append(x) - lp2.append(y) - lp1 = tuple(lp1) - lp2 = tuple(lp2) - cv.line(thermal_image, lp1, lp2, (0, 0, 0), 2, 8) - - @staticmethod - def get_line(image): - point1 = [] - point2 = [] - - cv.namedWindow("image") - cv.setMouseCallback("image", CFlir.draw_line, (point1, point2, image)) - - while 1: - cv.imshow("image", image) - k = cv.waitKey(1) & 0xFF - - if k == 13 or k == 141: - break - - cv.destroyWindow("image") - - thresh = 15 - line = [] - p1x, p1y = point1[0], point1[1] - p2x, p2y = point2[0], point2[1] - - if abs((p1x - p2x)) > thresh and abs((p1y - p2y)) > thresh: - # Using y = mx + c - m = (p2y - p1y) / (p2x - p1x) - c = p2y - (m * p2x) - if p1x > p2x: - for x in range(p1x, p2x - 1, -1): - y = int((m * x) + c) - line.append((x, y)) - else: - for x in range(p1x, p2x + 1): - y = int((m * x) + c) - line.append((x, y)) - - elif abs(p1x - p2x) <= thresh: - if p1y > p2y: - for y in range(p1y, p2y - 1, -1): - line.append((p1x, y)) - else: - for y in range(p1y, p2y + 1): - line.append((p1x, y)) - - else: - if p1x > p2x: - for x in range(p1x, p2x - 1, -1): - line.append((x, p1y)) - else: - for x in range(p1x, p2x + 1): - line.append((x, p1y)) - - return line, (p1x, p1y), (p2x, p2y) - - def line_measurement(self, image, thermal_np, cmap=cv.COLORMAP_JET): - logger.info("Please click on the two extreme points of the line") - img = image.copy() - line, point1, point2 = CFlir.get_line(img) - line_temps = np.zeros(len(line)) - - if len(img.shape) == 3: - gray_values = np.arange(256, dtype=np.uint8) - color_values = map(tuple, cv.applyColorMap(gray_values, cmap).reshape(256, 3)) - color_to_gray_map = dict(zip(color_values, gray_values)) - img = np.apply_along_axis(lambda bgr: color_to_gray_map[tuple(bgr)], 2, image) - - for i in range(0, len(line)): - line_temps[i] = thermal_np[line[i][1], line[i][0]] - - cv.line(img, point1, point2, 255, 2, 8) - - plt.subplot(1, 8, (1, 3)) - plt.imshow(img, cmap="jet") - basename = Path(self.image_path).name - plt.title(basename) - - a = plt.subplot(1, 8, (5, 8)) - a.set_xlabel("Distance in pixels") - a.set_ylabel("Temperature in C") - plt.plot(line_temps) - if max(line_temps) - min(line_temps) < 5: - plt.ylim([min(line_temps - 2.5), max(line_temps + 2.5)]) - plt.title("Distance vs Temperature") - plt.show() - - logger.info(f"\nMin line: {np.amin(line_temps)}\nMax line: {np.amax(line_temps)}") - - @staticmethod - def is_in_rect(rectangle, point): - tlx, tly, w, h = rectangle - px, py = point - is_inside = False - if px > tlx and px < tlx + w: - if py > tly and py < tly + h: - is_inside = True - return is_inside - - @staticmethod - def move_contours(event, x, y, flags, params): # scale contour,emissivity contours - - CFlir.xdisp = None - CFlir.ydisp = None - measurement_contours = params[0] - measurement_rects = params[1] - scale_contours = params[2] - spot_contours = params[3] - img = params[4] - vals = params[5] - spot_vals = params[6] - scale_contour = [] - - if len(scale_contours) > 0: - scale_contour = scale_contours[0] - - if CFlir.measurement_moving == True: - measurement_cont = measurement_contours[CFlir.measurement_index] - - if CFlir.rect_moving == True: - measurement_rect = measurement_rects[CFlir.rect_index] - - if CFlir.spots_moving == True: - spot_cont = spot_contours[CFlir.spots_index] - - if event == cv.EVENT_RBUTTONDOWN: - for i in range(0, len(measurement_contours)): - if cv.pointPolygonTest(measurement_contours[i], (x, y), False) == 1: - CFlir.measurement_index = i - CFlir.xo = x - CFlir.yo = y - CFlir.measurement_moving = True - break - - for i in range(0, len(measurement_rects)): - if x >= measurement_rects[i][0] and x <= (measurement_rects[i][0] + measurement_rects[i][2]): - if y >= measurement_rects[i][1] and y <= (measurement_rects[i][1] + measurement_rects[i][3]): - CFlir.rect_index = i - CFlir.xo = x - CFlir.yo = y - CFlir.rect_moving = True - break - - if len(scale_contours) > 0: - if cv.pointPolygonTest(scale_contour, (x, y), False) == 1: - CFlir.xo = x - CFlir.yo = y - CFlir.scale_moving = True - - for i in range(0, len(spot_contours)): - if cv.pointPolygonTest(spot_contours[i], (x, y), False) == 1: - CFlir.spots_index = i - CFlir.xo = x - CFlir.yo = y - CFlir.spots_moving = True - break - - elif event == cv.EVENT_MOUSEMOVE: - if CFlir.measurement_moving == True: - measurement_cont[:, 0] += x - CFlir.xo - measurement_cont[:, 1] += y - CFlir.yo - - if ( - np.max(measurement_cont[:, 0]) >= img.shape[1] - or np.amax(measurement_cont[:, 1]) >= img.shape[0] - or np.amin(measurement_cont[:, 0]) <= 0 - or np.amin(measurement_cont[:, 1]) <= 0 - ): - measurement_cont[:, 0] -= x - CFlir.xo - measurement_cont[:, 1] -= y - CFlir.yo - logger.warning("Could not move to intended location. Check if points are exceeding image boundary") - else: - CFlir.xo = x - CFlir.yo = y - - if CFlir.rect_moving is True: - x_new = measurement_rect[0] + (x - CFlir.xo) - y_new = measurement_rect[1] + (y - CFlir.yo) - - if x_new >= img.shape[1] - measurement_rect[2]: - x_new = img.shape[1] - measurement_rect[2] - 1 - if x_new <= 0: - x_new = 1 - if y_new >= img.shape[0] - measurement_rect[3]: - y_new = img.shape[0] - measurement_rect[3] - 1 - if y_new <= 0: - y_new = 1 - measurement_rects[CFlir.rect_index] = x_new, y_new, measurement_rect[2], measurement_rect[3] - CFlir.xo = x - CFlir.yo = y - - if CFlir.scale_moving == True: - scale_contour[:, 0] += x - CFlir.xo - scale_contour[:, 1] += y - CFlir.yo - - if ( - np.max(scale_contour[:, 0]) >= img.shape[1] - or np.amax(scale_contour[:, 1]) >= img.shape[0] - or np.amin(scale_contour[:, 0]) <= 0 - or np.amin(scale_contour[:, 1]) <= 0 - ): - scale_contour[:, 0] -= x - CFlir.xo - scale_contour[:, 1] -= y - CFlir.yo - logger.warning("Could not move to intended location. Check if points are exceeding image boundary") - else: - CFlir.xo = x - CFlir.yo = y - - if CFlir.spots_moving == True: - spot_cont[:, 0, 0] += x - CFlir.xo - spot_cont[:, 0, 1] += y - CFlir.yo - - if ( - np.max(spot_cont[:, 0, 0]) >= img.shape[1] - or np.amax(spot_cont[:, 0, 1]) >= img.shape[0] - or np.amin(spot_cont[:, 0, 0]) <= 0 - or np.amin(spot_cont[:, 0, 1]) <= 0 - ): - spot_cont[:, 0, 0] -= x - CFlir.xo - spot_cont[:, 0, 1] -= y - CFlir.yo - logger.warning("Could not move to intended location. Check if points are exceeding image boundary") - else: - CFlir.xo = x - CFlir.yo = y - - elif event == cv.EVENT_RBUTTONUP: - CFlir.scale_moving = False - CFlir.measurement_moving = False - CFlir.spots_moving = False - CFlir.rect_moving = False - - elif event == cv.EVENT_LBUTTONDBLCLK: - for i in range(0, len(measurement_contours)): - if cv.pointPolygonTest(measurement_contours[i], (x, y), False) == 1: - logger.info( - f"\nMaximum temp: {np.amax(vals[i])}\ - Minimum temp: {np.amin(vals[i])}\ - Avg: {np.average(vals[i])}" - ) - - for i in range(len(measurement_rects)): - if CFlir.is_in_rect(measurement_rects[i], (x, y)): - logger.info( - f"\nMaximum temp: {np.amax(vals[len(measurement_contours) + i])}\ - Minimum temp: {np.amin(vals[len(measurement_contours) + i])}\ - Avg: {np.average(vals[len(measurement_contours) + i])}\n" - ) # vals stores free hand values first, and then rects; hence the 'len(measurement_contours) + i' - - for i in range(0, len(spot_contours)): - if cv.pointPolygonTest(spot_contours[i], (x, y), False) == 1: - logger.info( - f"\nMaximum temp: {np.amax(spot_vals[i])}\ - Minimum temp: {np.amin(spot_vals[i])}\ - Avg: {np.average(spot_vals[i])}\n" - ) - - elif event == cv.EVENT_MBUTTONDOWN: - CFlir.xdisp = x - CFlir.ydisp = y - - @classmethod - def get_contours(cls, thermal_image, contours, is_rect=False): - temp_image = thermal_image.copy() - point1, point2 = [[]], [[]] - cv.namedWindow("image") - cv.setMouseCallback("image", cls.draw_contour_area, (temp_image, contours, [is_rect, point1, point2])) - - while 1: - cv.imshow("image", temp_image) - if is_rect: - if len(point1[0]) > 0 and len(point2[0]) > 0: - temp_image = cv.rectangle(thermal_image.copy(), point1[0], point2[0], (0, 0, 255)) - k = cv.waitKey(1) & 0xFF - - if k == 13 or k == 141: - redraw = None - if is_rect is True and (len(point1[0]) == 0 or len(point2[0]) == 0): - logger.warning("No rectangle has been drawn. Do you want to continue?") - redraw = input("1-Yes\t0-No,draw rectangle again\n") - - if redraw is not None and redraw == 0: - logger.info("Draw a rectangle") - else: - if is_rect is True and redraw is not None: - logger.warning("Exiting function without drawing a rectangle") - is_rect = False - break - cv.destroyWindow("image") - if is_rect: - area_rect = point1[0][0], point1[0][1], abs(point1[0][0] - point2[0][0]), abs(point1[0][1] - point2[0][1]) - return area_rect - else: - return None - - @staticmethod - def get_roi(thermal_image, thermal_np, raw_thermal_np, Contours, index, area_rect=None): - raw_roi_values = [] - thermal_roi_values = [] - indices = [] - - if area_rect is None: - img2 = np.zeros((thermal_image.shape[0], thermal_image.shape[1], 1), np.uint8) - cv.drawContours(img2, Contours, index, 255, -1) - x, y, w, h = cv.boundingRect(Contours[index]) - - indices = np.arange(w * h) - ind = np.where(img2[:, :, 0] == 255) - indices = indices[np.where(img2[y : y + h, x : x + w, 0].flatten() == 255)] - raw_roi_values = raw_thermal_np[ind] - thermal_roi_values = thermal_np[ind] - - else: - x, y, w, h = area_rect - raw_roi_values = raw_thermal_np[y : y + h, x : x + w] - thermal_roi_values = thermal_np[y : y + h, x : x + w] - - return raw_roi_values, thermal_roi_values, indices - - @staticmethod - def scale_with_roi(thermal_np, thermal_roi_values): - temp_array = thermal_np.copy() - - roi_values = thermal_roi_values.copy() - maximum = np.amax(roi_values) - minimum = np.amin(roi_values) - # opt = int(input(f'Temp difference in selected area: {temp_diff}C. Proceed with scaling? 1-Yes 0-No: ' )) - opt = 1 - if opt == 1: - # print(f'New maximum Temp: {maximum}',f'\nNew minimum Temp: {minimum}\n') - temp_array[temp_array > maximum] = maximum - temp_array[temp_array < minimum] = minimum - else: - logger.warning("Returning unscaled temperature image") - return temp_array - - def get_measurement_contours(self, image, is_rect=False): - CFlir.contour = [] - img = image.copy() - area_rect = CFlir.get_contours(img, self.measurement_contours, is_rect=is_rect) - if area_rect is not None: - self.measurement_rects.append(area_rect) - - def get_measurement_areas_values(self, image, thermal_np, raw_thermal_np, is_rect=False): - measurement_areas_thermal_values = [] - measurement_area_indices = [] - - for i in range(0, len(self.measurement_contours)): - raw_vals, thermal_vals, indices = CFlir.get_roi( - image, thermal_np, raw_thermal_np, self.measurement_contours, i - ) - measurement_areas_thermal_values.append(thermal_vals) - measurement_area_indices.append(indices) - - # measurement_area_indices = None - for i in range(0, len(self.measurement_rects)): - measurement_areas_thermal_values.append( - CFlir.get_roi( - image, - thermal_np, - raw_thermal_np, - self.measurement_contours, - i, - area_rect=self.measurement_rects[i], - )[1] - ) - return measurement_areas_thermal_values, measurement_area_indices - - def get_scaled_image(self, img, thermal_np, raw_thermal_np, cmap=cv.COLORMAP_JET, is_rect=False): - self.scale_contours = [] - CFlir.contour = [] - CFlir.get_contours(img, self.scale_contours) - flag = False - - if len(self.scale_contours) > 0: - - if len(self.scale_contours[0]) > 15: - flag = True - thermal_roi_values = CFlir.get_roi(img, thermal_np, raw_thermal_np, self.scale_contours, 0)[1] - temp_scaled = CFlir.scale_with_roi(thermal_np, thermal_roi_values) - temp_scaled_image = CFlir.get_temp_image(temp_scaled, colormap=cmap) - - if flag == False: - temp_scaled = thermal_np.copy() - temp_scaled_image = CFlir.get_temp_image(temp_scaled, colormap=cmap) - - return temp_scaled, temp_scaled_image - - def default_scaling_image(self, array, cmap=cv.COLORMAP_JET): - thermal_np = array.copy() - mid_thermal_np = thermal_np[10 : thermal_np.shape[0] - 10, (int)(thermal_np.shape[1] / 2)] - maximum = np.amax(mid_thermal_np) - minimum = np.amin(mid_thermal_np) - - thermal_np[thermal_np > maximum + 10] = maximum + 10 - thermal_np[thermal_np < minimum - 5] = minimum - 5 - image = CFlir.get_temp_image(thermal_np, colormap=cmap) - - return image, thermal_np - - def save_thermal_image(self, output_path): - cv.imwrite(output_path, self.thermal_image) - - -def get_thermal_image_from_file(thermal_input, thermal_class=CFlir, colormap=None): - """ - Function to get the image associated with each RJPG file using the FLIR Thermal base class CFlir - Saves the thermal images in the same place as the original RJPG - """ - CThermal = thermal_class - - inputpath = Path(thermal_input) - if Path.is_dir(inputpath): - rjpg_img_paths = list(Path(input_folder).glob("*R.JPG")) - fff_file_paths = list(Path(input_folder).glob("*.fff")) - if len(rjpg_img_paths) > 0: - for rjpg_img in tqdm(rjpg_img_paths, total=len(rjpg_img_paths)): - thermal_obj = CThermal(rjpg_img, color_map=colormap) - path_wo_ext = str(rjpg_img).replace("_R" + rjpg_img.suffix, "") - thermal_obj.save_thermal_image(path_wo_ext + ".jpg") - - elif len(fff_file_paths) > 0: - for fff in tqdm(fff_file_paths, total=len(fff_file_paths)): - save_image_path = str(fff).replace(".fff", ".jpg") - thermal_obj = CThermal(fff, color_map=colormap) - thermal_obj.save_thermal_image(save_image_path) - else: - logger.error("Input folder contains neither fff or RJPG files") - - elif Path.is_file(inputpath): - thermal_obj = CThermal(thermal_input, color_map=colormap) - path_wo_ext = Path.as_posix(inputpath).replace(inputpath.suffix, "") - thermal_obj.save_thermal_image(path_wo_ext + ".jpg") - - else: - logger.error("Path given is neither file nor folder. Please check") - - -class CSeqVideo: - """ - Base class for splitting SEQ files into multiple fff and jpg files - refer: https://exiftool.org/forum/index.php?topic=5279.0 - @purpose: - Read .seq files from Flir IR camera and write each frame to temporary binary file. - - @usage: - seqToBin.py _FILE_NAME_.seq - - @note: - When first using this code for a new camera, it might need find the bits separating - each frame, which is possibly IR camera specific. Please run: - hexdump -n16 -C _FILE_NAME_.seq - - @@Example - >$ hexdump -n16 -C Rec-000667_test.seq - 00000000 46 46 46 00 52 65 73 65 61 72 63 68 49 52 00 00 |FFF.ResearchIR..| - 00000010 - So, for this camera, the separation patten is: - \x46\x46\x46\x00\x52\x65\x73\x65\x61\x72\x63\x68\x49\x52 - which == FFFResearchIR - - P.S. Runs much faster when writing data to an empty folder rather than rewriting existing folder's files - """ - - def __init__(self, input_video): - self.split_thermal(input_video) - - def get_hex_sep_pattern(self, input_video): - """ - Function to get the hex separation pattern from the seq file automatically. - The split, and replace functions might have to be modified. This hasn't been tried with files other than from the Zenmuse XT2 - Information on '\\x': - https://stackoverflow.com/questions/2672326/what-does-a-leading-x-mean-in-a-python-string-xaa - https://www.experts-exchange.com/questions/26938912/Get-rid-of-escape-character.html - Python eval() function: - https://www.geeksforgeeks.org/eval-in-python - """ - pat = sp.check_output(["hexdump", "-n16", "-C", str(input_video)]) - pat = pat.decode("ascii") - # Following lines are to get the marker (pattern) to the appropriate hex form - pat = pat.split("00000000 ")[1] - pat = pat.split(" |")[0] - pat = pat.replace(" ", " ") - pat = pat.replace(" ", "\\x") - pat = f"'{pat}'" - pat = eval(pat) # eval is apparently risky to use. Change later - return pat - - # def split_by_marker(f, marker = pat, block_size = 10240): - def split_by_marker(self, f, marker="", block_size=10240): - current = "" - bolStartPos = True - while True: - block = f.read(block_size) - if not block: # end-of-file - yield marker + current - return - block = block.decode("latin-1") - # exit() - current += block - while True: - markerpos = current.find(marker) - if bolStartPos == True: - current = current[markerpos + len(marker) :] - bolStartPos = False - continue - elif markerpos < 0: - break - else: - yield marker + current[:markerpos] - current = current[markerpos + len(marker) :] - - def split_thermal(self, input_video, output_folder=None, path_to_base_thermal_class_folder="."): - """ - Splits the thermal SEQ file into separate 'fff' frames by its hex separator pattern (TO DO: Find out more about how exactly this is done) - Inputs: 'input_video':thermal SEQ video, 'output_folder': Path to output folder (Creates folder if it doesn't exist) - The Threading makes all the cores run at 100%, but it gives ~x4 speed-up. - """ - - if output_folder == None: - output_folder = Path(input_video).with_suffix("") - - output_folder = Path(output_folder) - output_folder.mkdir(exist_ok=True) - - sys.path.insert(0, path_to_base_thermal_class_folder) - - idx = 0 - inputname = input_video - pat = self.get_hex_sep_pattern(input_video) - for line in tqdm(self.split_by_marker(open(inputname, "rb"), marker=pat)): - outname = output_folder / f"frame_{idx}.fff" - with open(outname, "wb") as output_file: - line = line.encode("latin-1") - output_file.write(line) - Thread( - target=get_thermal_image_from_file, kwargs={"thermal_class": CFlir, "thermal_input": outname} - ).start() - idx = idx + 1 - if idx % 100000 == 0: - print(f"running index : {idx} ") - break - return True - - def split_visual(self, visual_video, fps, fps_ratio, output_folder="visual_frames"): - """ - Splits video into frames based on the actual fps, and time between frames of the thermal sequence. - There is a sync issue where the thermal fps, and visual fps don't have an integer LCM/if LCM is v large. Have to try motion interpolation to fix this - """ - - output_folder = Path(output_folder) - output_folder.mkdir(exist_ok=True) - vid = cv.VideoCapture(visual_video) - skip_frames = round(fps_ratio) - total_frames = vid.get(cv.CAP_PROP_FRAME_COUNT) - current_frame = 0 - thermal_fps = fps * (1 / fps_ratio) - thermal_time = 1 / thermal_fps - logger.info(f"Time between frames for Thermal SEQ: {thermal_time}") - # Uncomment below lines if you need total time of visual video - # vid.set(cv.CAP_PROP_POS_AVI_RATIO,1) - # total_time = vid.get(cv.CAP_PROP_POS_MSEC) - last_save_time = -1 * thermal_time # So that it saves the 0th frame - idx = 0 - while current_frame < total_frames: - current_frame = vid.get(cv.CAP_PROP_POS_FRAMES) - try: - current_time = (1 / fps) * current_frame - except: - current_time = 0 - ret, frame = vid.read() - if ret: - if (current_time - last_save_time) * 1000 >= ((thermal_time * 1000) - 5): - # logger.info(f'Current Time: {current_time} Last save time: {last_save_time}') - cv.imwrite(str(output_folder / f"{idx}.jpg"), frame) - idx += 1 - last_save_time = current_time - return True diff --git a/README.md b/README.md index bdc7ff7..12feb2d 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,54 @@ -# ThermImageAnalysis -* `CThermal.py` contains classes for sensor value-->temperature conversion from FLIR files (SEQ, images), and the analysis tools for the same, and for splitting SEQ videos -* `thermal_analysys.py` contains the main code to be run for the analysis tools on the FLIR files. -* `split_seq.py` splits an SEQ file into multiple FFF files +# Thermal Image Analysis +A tool for analyzing and annotating thermal images. +This repo relies on the [thermal_base python package](https://github.com/detecttechnologies/thermal_base) for image decoding and manipulation backend. -## Usage -* First, install the requirements for using the code in this repo using `pip install -r requirements.txt` -* For Linux users, check if exiftool is installed, by running ```exiftool``` in the command line. -* For Windows users, do the following: - * Download exiftool (for your OS) from [here](https://exiftool.org/) - * Extract 'exiftool(-k).exe' to the folder where you're running the code from - * Rename it to 'exiftool.exe' -* To run thermal analysis on a FLIR image (FFF/jpg/rjpg/...), run the following command in a terminal: - ```bash - python thermal_analysis.py - ``` - The usage instructions and other notes for this are in the [wiki page](https://github.com/detecttechnologies/Thermal_Image_Analysis/wiki/Instructions-Manual-for-the-Thermal-Image-Analysis-software) -* To extract FFF(RJPG header that can be used by `thermal_analysis.py` of this repo) and JPG files from an SEQ video, you can run the following command: - ```bash - python split_seq.py - ``` - +[![Quality check](https://github.com/detecttechnologies/Thermal_Image_Analysis/actions/workflows/qualitycheck.yml/badge.svg)](https://github.com/detecttechnologies/Thermal_Image_Analysis/actions) ## Features -1. **ROI Scaling** - Draw a (freehand) Region of Interest area to scale the rest of the image with. This is useful in cases where the region of your interest is low in contrast compared to the scale of the entire image. This drawn area can be moved around to change the region -2. **Area Measurement** - Draw a rectangle, or freehand area(s), to get the *average, minimum, and maximum* temperatures of that area. These can be moved around as well. +


Main menu

+ +### Spot marking, line measurement and area marking + +- Extract the temperature values at marked spots +- Plot temperature values along the marked line(s) +- Get min, max and average values of marked regions + +Generates a plot for line plots and a table for measurements in the marked regions. + +


Markings on the image.

-3. **Line Tool** - Draw a line to get a plot (temp vs pixel distance) of the temperatures along the points. +

+ + +
Plots and measurements. +

-4. **Spot Measurement** - Draw spots(circular areas with a small radius). Similar to 'Area Measurement' -5. **Change Image Parameters** - Option to change the global parameters: *Object Distance, Relative Humidity, Reflected Apparent Temperature, Atmospheric Temperature, Emissivity* of the image. The default values are obtained from the metadatawiki +### ROI scaling +Scale the entire image based on values in the marked region. Use to enhance low contrast areas. -6. **Change Color Map** - Change the color map representation of the thermal data (Default-Jet). Options available are: *Gray* *(No false colormap)*, *Rainbow*, and *Hot* +


ROI scaling interface.

-7. **Invert Scaling** - Change the way the way the image is scaled between the default scaling, and the raw image. +### Change colormap +Change colormap to one of the following options: -## To-Do +


Change colormap.

-* General Interface changes for easier use -* Draw multiple areas in the same go -* Line visualization while drawing from the 'Draw Line' tool -* Change free hand to polygon +### Emissivity scaling +Change reflected apparent temperature and emissivity of marked region. + +


Emissivity scaling interface.

+ +### Save data +Image can be saved with or without markings, plots and values. Custom savefile(.pkl) saves all data and can be used to revive the previous session. + +## Installation + - Run installation once with `pip install -r requirements.txt` + - Install [exiftool](https://exiftool.org/install.html) + +## Usage + - Run the program with `python main.py` + - Select the original thermal image file or the custom saved `.pkl` file. (Find some samples in `sample_images`) + \ No newline at end of file diff --git a/assets/DTlogo.png b/assets/DTlogo.png new file mode 100644 index 0000000..2419264 Binary files /dev/null and b/assets/DTlogo.png differ diff --git a/assets/cursors/crosshair.png b/assets/cursors/crosshair.png new file mode 100644 index 0000000..8e27493 Binary files /dev/null and b/assets/cursors/crosshair.png differ diff --git a/assets/cursors/pointer.png b/assets/cursors/pointer.png new file mode 100644 index 0000000..6d761ad Binary files /dev/null and b/assets/cursors/pointer.png differ diff --git a/assets/icon_gray.png b/assets/icon_gray.png new file mode 100644 index 0000000..462be6d Binary files /dev/null and b/assets/icon_gray.png differ diff --git a/assets/images/cmap.gif b/assets/images/cmap.gif new file mode 100644 index 0000000..b0bce38 Binary files /dev/null and b/assets/images/cmap.gif differ diff --git a/assets/images/emm.png b/assets/images/emm.png new file mode 100644 index 0000000..5983da7 Binary files /dev/null and b/assets/images/emm.png differ diff --git a/assets/images/graph.png b/assets/images/graph.png new file mode 100644 index 0000000..3577af7 Binary files /dev/null and b/assets/images/graph.png differ diff --git a/assets/images/main.gif b/assets/images/main.gif new file mode 100644 index 0000000..13129d3 Binary files /dev/null and b/assets/images/main.gif differ diff --git a/assets/images/markings.png b/assets/images/markings.png new file mode 100644 index 0000000..26f2ec8 Binary files /dev/null and b/assets/images/markings.png differ diff --git a/assets/images/roi.png b/assets/images/roi.png new file mode 100644 index 0000000..663b4ce Binary files /dev/null and b/assets/images/roi.png differ diff --git a/assets/images/table.png b/assets/images/table.png new file mode 100644 index 0000000..406f751 Binary files /dev/null and b/assets/images/table.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..288ef44 --- /dev/null +++ b/main.py @@ -0,0 +1,669 @@ +"""GUI application for thermal image analysis.""" +import os +import pickle +import sys +from tkinter.messagebox import showwarning + +import numpy as np +import pygame +import pygame_gui as pygui +from scipy.ndimage.interpolation import zoom +from thermal_base import ThermalImage +from thermal_base import utils as ThermalImageHelpers + +from utils import WindowHandler, openImage, saveImage + +pygame.init() +WINDOW_SIZE = (1020, 590) +NEW_FILE = False + + +class Manager(pygui.UIManager): + """Class for manager. + + A manager is a set of menu buttons and descriptions. + A manager is assigned to each page of the application. + """ + + def __init__(self, buttons, textbox=None, fields=None): + """Initilizer for manager.""" + super().__init__(WINDOW_SIZE) + self.buttons = [ + ( + pygui.elements.UIButton( + relative_rect=pygame.Rect(pos, size), text=text, manager=self + ), + func, + ) + for pos, size, text, func in buttons + ] + if textbox: + self.textbox = pygui.elements.ui_text_box.UITextBox( + html_text=textbox[2], + relative_rect=pygame.Rect(textbox[:2]), + manager=self, + ) + if fields: + self.fields = [ + ( + pygui.elements.ui_text_entry_line.UITextEntryLine( + relative_rect=pygame.Rect((pos[0], pos[1] + 40), size), + manager=self, + ), + pygui.elements.ui_text_box.UITextBox( + html_text=text, + relative_rect=pygame.Rect(pos, (-1, -1)), + manager=self, + ), + ) + for pos, size, text in fields + ] + + def process_events(self, event): + """Process button presses.""" + if event.type == pygame.USEREVENT: + if event.user_type == pygui.UI_BUTTON_PRESSED: + for button, func in self.buttons: + if event.ui_element == button: + func() + + super().process_events(event) + + +class Window: + """Class that handles the main window.""" + + fonts = [ + pygame.font.SysFont("monospace", 20), + pygame.font.SysFont("monospace", 24), + pygame.font.SysFont("arial", 18), + ] + + cursors = [ + pygame.image.load("./assets/cursors/pointer.png"), + pygame.image.load("./assets/cursors/crosshair.png"), + ] + logo = pygame.transform.scale(pygame.image.load("./assets/DTlogo.png"), (100, 100)) + clip = lambda x, a, b: a if x < a else b if x > b else x + + @staticmethod + def renderText(surface, text, location): + """Render text at a given location.""" + whitetext = Window.fonts[2].render(text, 1, (255, 255, 255)) + Window.fonts[0].set_bold(True) + blacktext = Window.fonts[2].render(text, 1, (0, 0, 0)) + Window.fonts[0].set_bold(False) + + textrect = whitetext.get_rect() + for i in range(-3, 4): + for j in range(-3, 4): + textrect.center = [a + b for a, b in zip(location, (i, j))] + surface.blit(blacktext, textrect) + textrect.center = location + surface.blit(whitetext, textrect) + + def __init__(self, thermal_image=None, filename=None): + """Initializer for the main window.""" + self.exthandler = WindowHandler() + if thermal_image is not None: + mat = thermal_image.thermal_np.astype(np.float32) + + if mat.shape != (512, 640): + y0, x0 = mat.shape + mat = zoom(mat, [512 / y0, 640 / x0]) + + self.mat = mat + self.mat_orig = mat.copy() + self.mat_emm = mat.copy() + self.raw = thermal_image.raw_sensor_np + self.meta = thermal_image.meta + self.overlays = pygame.Surface((640, 512), pygame.SRCALPHA) + else: + with open(filename, "rb") as f: + data = pickle.load(f) + self.mat = data.mat + self.mat_orig = data.mat_orig + self.mat_emm = data.mat_emm + self.raw = data.raw + self.meta = data.meta + self.overlays = pygame.image.fromstring(data.overlays, (640, 512), "RGBA") + + for entry in data.tableEntries: + self.exthandler.addToTable(entry) + self.exthandler.loadGraph(data.plots) + self.exthandler.addRects(data.rects) + + self.colorMap = "jet" + self.lineNum = 0 + self.boxNum = 0 + self.spotNum = 0 + self.areaMode = "poly" + self.selectionComplete = False + self.work("colorMap", self.colorMap) + + self.mode = "main" + # Dictionary of pages. Each page is a manager. + self.managers = {} + self.managers["main"] = Manager( + buttons=[ + ((15, 15), (215, 45), "Spot marking", lambda: self.changeMode("spot")), + ( + (15, 75), + (215, 45), + "Line measurement", + lambda: self.changeMode("line"), + ), + ((15, 135), (215, 45), "Area marking", lambda: self.changeMode("area")), + ((15, 195), (215, 45), "ROI scaling", lambda: self.changeMode("scale")), + ( + (15, 255), + (215, 45), + "Change colorMap", + lambda: self.changeMode("colorMap"), + ), + ( + (15, 315), + (215, 45), + "Emissivity scaling", + lambda: self.changeMode("emissivity"), + ), + ( + (15, 470), + (215, 45), + "Reset modifications", + lambda: self.work("reset"), + ), + ((15, 530), (100, 45), "Open image", lambda: self.work("open")), + ((130, 530), (100, 45), "Save image", lambda: saveImage(self)), + ] + ) + self.managers["spot"] = Manager( + buttons=[((15, 530), (215, 45), "Back", lambda: self.changeMode("main"))], + textbox=((15, 15), (215, -1), "Click to mark spots"), + ) + self.managers["line"] = Manager( + buttons=[ + ( + (15, 410), + (215, 45), + "Continue", + lambda: self.work("line") if len(self.linePoints) == 2 else None, + ), + ((15, 470), (215, 45), "Reset", lambda: self.changeMode("line")), + ((15, 530), (215, 45), "Back", lambda: self.changeMode("main")), + ], + textbox=( + (15, 15), + (215, -1), + "Click to mark the end points of the line. Click continue to get plot and reset to remove the line", + ), + ) + self.managers["area"] = Manager( + buttons=[ + ((15, 470), (215, 45), "Continue", lambda: self.work("area")), + ((15, 530), (215, 45), "Back", lambda: self.changeMode("main")), + ], + textbox=( + (15, 15), + (215, -1), + "Click and drag to draw selection. Select continue to mark", + ), + ) + self.managers["scale"] = Manager( + buttons=[ + ( + (15, 270), + (215, 45), + "Switch to rect mode", + lambda: self.work("scale", "switchMode"), + ), + ( + (15, 350), + (215, 45), + "Continue", + lambda: self.work("scale", "scale") + if self.selectionComplete + else None, + ), + ( + (15, 410), + (215, 45), + "Reset scaling", + lambda: self.work("scale", "reset"), + ), + ( + (15, 470), + (215, 45), + "Reset selection", + lambda: self.changeMode("scale"), + ), + ((15, 530), (215, 45), "Back", lambda: self.changeMode("main")), + ], + textbox=( + (15, 15), + (215, -1), + "Click to mark vertices. Press Ctrl and click to close the selection", + ), + ) + self.managers["colorMap"] = Manager( + buttons=[ + ((15, 15), (215, 45), "Jet", lambda: self.work("colorMap", "jet")), + ((15, 75), (215, 45), "Hot", lambda: self.work("colorMap", "hot")), + ((15, 135), (215, 45), "Cool", lambda: self.work("colorMap", "cool")), + ((15, 195), (215, 45), "Gray", lambda: self.work("colorMap", "gray")), + ( + (15, 255), + (215, 45), + "Inferno", + lambda: self.work("colorMap", "inferno"), + ), + ( + (15, 315), + (215, 45), + "Copper", + lambda: self.work("colorMap", "copper"), + ), + ( + (15, 375), + (215, 45), + "Winter", + lambda: self.work("colorMap", "winter"), + ), + ((15, 530), (215, 45), "Back", lambda: self.changeMode("main")), + ] + ) + self.managers["emissivity"] = Manager( + buttons=[ + ( + (15, 410), + (215, 45), + "Continue", + lambda: self.work("emissivity", "update") + if self.selectionComplete + else None, + ), + ( + (15, 470), + (215, 45), + "Reset", + lambda: self.work("emissivity", "reset"), + ), + ((15, 530), (215, 45), "Back", lambda: self.changeMode("main")), + ], + textbox=( + (15, 15), + (215, -1), + "Select region, enter values and press continue. Click to mark vertices." + "Press Ctrl and click to close the selection", + ), + fields=[ + ((15, 165), (215, 45), "Emissivity:"), + ((15, 240), (215, 45), "Reflected Temp.:"), + ((15, 315), (215, 45), "Atmospheric Temp.:"), + ], + ) + + self.linePoints = [] + + self.cursor_rect = self.cursors[0].get_rect() + self.background = pygame.Surface(WINDOW_SIZE) + self.background.fill((0, 0, 0)) + + def changeMode(self, mode): + """Change mode.""" + # Mode change - reset handler + if self.mode == "line": + if mode in ("main", "line"): + self.linePoints = [] + + if self.mode in ("scale", "area", "emissivity"): + if mode in ("main", "scale", "area"): + self.selectionComplete = False + self.linePoints = [] + + self.mode = mode + + def work(self, mode, *args): + """Work based on mode.""" + if mode == "reset": + # Resetting overlays and plots + self.overlays = pygame.Surface((WINDOW_SIZE[0] - 245, 512), pygame.SRCALPHA) + self.lineNum = 0 + self.boxNum = 0 + self.spotNum = 0 + self.exthandler.killThreads() + self.exthandler = WindowHandler() + + # Resetting ROI scaling + self.work("scale", "reset") + # Resetting Emissivity changes + self.work("emissivity", "reset") + + if mode == "open": + global NEW_FILE + NEW_FILE = True + + if mode == "line": + self.lineNum += 1 + linePoints = [ + [a - b for a, b in zip(points, (245, 15))] for points in self.linePoints + ] + pygame.draw.line( + self.overlays, (255, 255, 255), linePoints[0], linePoints[1], 3 + ) + + center = ( + (linePoints[0][0] + linePoints[1][0]) / 2, + (linePoints[0][1] + linePoints[1][1]) / 2, + ) + + self.renderText(self.overlays, f"l{self.lineNum}", center) + + self.exthandler.linePlot( + self.mat_emm, + f"l{self.lineNum}", + np.array(linePoints[0][::-1]), + np.array(linePoints[1][::-1]), + ) + self.linePoints = [] + + if mode == "spot": + self.spotNum += 1 + self.renderText( + self.overlays, + f"s{self.spotNum}", + (self.mx - 245 + 15, self.my - 15 - 13), + ) + pygame.draw.line( + self.overlays, + (255, 255, 255), + (self.mx - 245, self.my - 15 - 5), + (self.mx - 245, self.my - 15 + 5), + 3, + ) + pygame.draw.line( + self.overlays, + (255, 255, 255), + (self.mx - 245 - 5, self.my - 15), + (self.mx - 245 + 5, self.my - 15), + 3, + ) + val = self.mat_emm[self.cy - 15, self.cx - 245] + self.exthandler.addToTable([f"s{self.spotNum}", val, val, val]) + + if mode == "area": + if self.selectionComplete: + points = [(a - 245, b - 15) for a, b in self.linePoints] + x_coords, y_coords = zip(*points) + xmin = min(x_coords) + xmax = max(x_coords) + ymin = min(y_coords) + ymax = max(y_coords) + if xmin == xmax or ymin == ymax: + return + self.boxNum += 1 + chunk = self.mat_emm[ymin:ymax, xmin:xmax] + self.exthandler.addToTable( + [f"a{self.boxNum}", np.min(chunk), np.max(chunk), np.mean(chunk)] + ) + self.exthandler.addRects([[xmin, xmax, ymin, ymax]]) + pygame.draw.lines(self.overlays, (255, 255, 255), True, points, 3) + self.renderText( + self.overlays, f"a{self.boxNum}", (xmin + 12, ymin + 10) + ) + + if mode == "colorMap": + self.colorMap = args[0] + + minVal = np.min(self.mat) + delVal = np.max(self.mat) - minVal + self.cbarVals = [minVal + i * delVal / 4 for i in range(5)][::-1] + + cbar = np.row_stack(20 * (np.arange(256),))[:, ::-1].astype(np.float32) + + self.image = ThermalImageHelpers.cmap_matplotlib(self.mat, args[0]) + cbar = ThermalImageHelpers.cmap_matplotlib(cbar, args[0]) + + self.imsurf = pygame.Surface((WINDOW_SIZE[0] - 245, 512)) + self.imsurf.blit( + pygame.surfarray.make_surface( + np.transpose(self.image[..., ::-1], (1, 0, 2)) + ), + (0, 0), + ) + self.imsurf.blit(pygame.surfarray.make_surface(cbar[..., ::-1]), (663, 85)) + for i, val in enumerate(self.cbarVals): + self.imsurf.blit( + self.fonts[0].render(f"{val:.1f}", 1, (255, 255, 255)), + (690, 75 + i * 65), + ) + self.imsurf.blit( + self.fonts[0].render("\N{DEGREE SIGN}" + "C", 1, (255, 255, 255)), + (660, 60), + ) + self.imsurf.blit(self.logo, (658, 405)) + + if mode == "scale": + if args[0] == "reset": + self.mat = self.mat_emm.copy() + self.work("colorMap", self.colorMap) + + if args[0] == "switchMode": + self.managers["scale"].buttons[0][0].set_text( + f"Switch to {self.areaMode} mode" + ) + self.areaMode = "poly" if self.areaMode == "rect" else "rect" + self.changeMode("scale") + + if args[0] == "scale": + + chunk = self.mat_emm[ + ThermalImageHelpers.coordinates_in_poly( + [(x - 245, y - 15) for x, y in self.linePoints], self.raw.shape + ) + ] + + if len(chunk) > 0: + self.mat = np.clip(self.mat_emm, np.min(chunk), np.max(chunk)) + self.work("colorMap", self.colorMap) + + if mode == "emissivity": + if args[0] == "update": + emmissivity = self.managers["emissivity"].fields[0][0].get_text() + ref_temp = self.managers["emissivity"].fields[1][0].get_text() + atm_temp = self.managers["emissivity"].fields[2][0].get_text() + + np_indices = ThermalImageHelpers.coordinates_in_poly( + [(x - 245, y - 15) for x, y in self.linePoints], self.raw.shape + ) + self.mat_emm[ + np_indices + ] = ThermalImageHelpers.change_emissivity_for_roi( + thermal_np=None, + meta=self.meta, + roi_contours=None, + raw_roi_values=self.raw[np_indices], + indices=None, + new_emissivity=emmissivity, + ref_temperature=ref_temp, + atm_temperature=atm_temp, + np_indices=True, + ) + + if args[0] == "reset": + self.mat_emm = self.mat_orig.copy() + + self.work("scale", "reset") + + def process(self, event): + """Process input event.""" + self.mx, self.my = self.cursor_rect.center = pygame.mouse.get_pos() + self.cx = Window.clip(self.mx, 245, 884) + self.cy = Window.clip(self.my, 15, 526) + self.cursor_in = (245 < self.mx < 885) and (15 < self.my < 527) + self.managers[self.mode].process_events(event) + if event.type == pygame.MOUSEBUTTONDOWN: + if self.cursor_in: + if self.mode == "line": + if len(self.linePoints) < 2: + self.linePoints.append((self.mx, self.my)) + if ( + self.mode == "scale" and self.areaMode == "poly" + ) or self.mode == "emissivity": + if self.selectionComplete: + self.linePoints = [] + self.selectionComplete = False + self.linePoints.append((self.mx, self.my)) + if pygame.key.get_mods() & pygame.KMOD_CTRL: + if len(self.linePoints) > 2: + self.selectionComplete = True + if ( + self.mode == "scale" and self.areaMode == "rect" + ) or self.mode == "area": + self.changeMode(self.mode) + self.linePoints.append((self.mx, self.my)) + + if self.mode == "spot": + self.work("spot") + + if event.type == pygame.MOUSEBUTTONUP: + if ( + self.mode == "scale" and self.areaMode == "rect" + ) or self.mode == "area": + if len(self.linePoints) == 1: + self.linePoints.append((self.cx, self.linePoints[0][1])) + self.linePoints.append((self.cx, self.cy)) + self.linePoints.append((self.linePoints[0][0], self.cy)) + self.selectionComplete = True + + def update(self, time_del): + """Update events.""" + self.managers[self.mode].update(time_del) + + def draw(self, surface): + """Draw contents on screen.""" + surface.blit(self.background, (0, 0)) + surface.blit(self.imsurf, (245, 15)) + surface.blit(self.overlays, (245, 15)) + + pygame.draw.rect(surface, (255, 255, 255), (245, 540, 760, 35), 1) + self.managers[self.mode].draw_ui(surface) + surface.blit( + self.fonts[1].render( + f"x:{self.cx - 245:03} y:{self.cy - 15:03} temp:{self.mat_emm[self.cy - 15, self.cx - 245]:.4f}", + 1, + (255, 255, 255), + ), + (253, 544), + ) + + if self.mode == "line": + if len(self.linePoints) == 1: + pygame.draw.line( + surface, (255, 255, 255), self.linePoints[0], (self.cx, self.cy), 3 + ) + if len(self.linePoints) == 2: + pygame.draw.line( + surface, (255, 255, 255), self.linePoints[0], self.linePoints[1], 3 + ) + + if ( + self.mode == "scale" and self.areaMode == "poly" + ) or self.mode == "emissivity": + if len(self.linePoints) > 0: + pygame.draw.lines( + surface, + (255, 255, 255), + self.selectionComplete, + self.linePoints + + ([] if self.selectionComplete else [(self.cx, self.cy)]), + 3, + ) + if (self.mode == "scale" and self.areaMode == "rect") or self.mode == "area": + if not self.selectionComplete: + if len(self.linePoints) > 0: + pygame.draw.lines( + surface, + (255, 255, 255), + True, + self.linePoints + + [ + (self.cx, self.linePoints[0][1]), + (self.cx, self.cy), + (self.linePoints[0][0], self.cy), + ], + 3, + ) + else: + pygame.draw.lines(surface, (255, 255, 255), True, self.linePoints, 3) + + surface.blit(self.cursors[self.cursor_in], self.cursor_rect) + + +if __name__ == "__main__": + pygame.mouse.set_visible(False) + + pygame.display.set_caption("Detect Thermal Image Analysis Tool") + pygame.display.set_icon(pygame.image.load("./assets/icon_gray.png")) + surface = pygame.display.set_mode(WINDOW_SIZE) + surface.blit(Window.fonts[2].render("Loading...", 1, (255, 255, 255)), (460, 275)) + pygame.display.update() + + clock = pygame.time.Clock() + + done = False + NEW_FILE = True + window = None + + while not done: + + if NEW_FILE: + filename = openImage() + + if filename: + surface.fill((0, 0, 0)) + surface.blit( + Window.fonts[2].render("Loading...", 1, (255, 255, 255)), (460, 275) + ) + pygame.display.update() + newwindow = None + try: + if filename.split(".")[-1] == "pkl": + newwindow = Window(filename=filename) + else: + try: + image = ThermalImage(filename, camera_manufacturer="FLIR") + except Exception: + image = ThermalImage(filename, camera_manufacturer="DJI") + newwindow = Window(thermal_image=image) + except Exception as err: + print(f"Exception: {err}") + showwarning(title="Error", message="Invalid file selected.") + if newwindow is not None: + if window is not None: + window.exthandler.killThreads() + window = newwindow + if not window: + sys.exit(0) + NEW_FILE = False + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + done = True + if event.type == pygame.KEYDOWN: + if event.key == ord("s"): + index = 0 + while os.path.isfile(f"{index}.png"): + index += 1 + pygame.image.save(surface, f"{index}.png") + print(f"Saved {index}.png") + + window.process(event) + + window.update(clock.tick(60) / 1000.0) + window.draw(surface) + + pygame.display.update() + + # For the threads to close before end of program + window.exthandler.killThreads() diff --git a/pylama.ini b/pylama.ini new file mode 100644 index 0000000..cbdac93 --- /dev/null +++ b/pylama.ini @@ -0,0 +1,12 @@ +[pylama] +async = 1 +linters = pyflakes,mccabe,pydocstyle,pylint,isort +ignore = W0123,W0603,W0611,W0613,W0621,W0703,W1203,E0110,E0401,E0611,E1101,C0103,C0200,C0415,C901,R0902,R0914,R0903,R1732,W0107,W0201,R0913,D104,D105,D212,D203,D204,D213,D215,D400,D401,D404,D406,D407,D408,D409,D413 +# All warnings after D203 are to adopt the Google docstring style + +[pylama:pylint] +max-line-length = 119 +good-names = w,h,k,v,x,y,a,b,i,j,f +extension-pkg-whitelist = cv2 +max-branches = 40 +max-statements = 120 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 95a71d8..75c72f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,9 @@ -PyInquirer -opencv-contrib-python -pillow -argparse -numpy -logzero -matplotlib -tqdm +Pillow >= 8.1.2 +matplotlib >= 3.4.0 +numpy >= 1.19.5 +pandas >= 1.2.4 +pygame >= 2.0.1 +pygame_gui >= 0.5.7 +scipy >= 1.6.2 +xlsxwriter >= 1.4.4 +git+https://github.com/detecttechnologies/thermal_base.git@main diff --git a/sample_images/DJI_XT-2.jpg b/sample_images/DJI_XT-2.jpg new file mode 100644 index 0000000..e897de0 Binary files /dev/null and b/sample_images/DJI_XT-2.jpg differ diff --git a/sample_images/Flir_B60.jpg b/sample_images/Flir_B60.jpg new file mode 100644 index 0000000..ab0b3a4 Binary files /dev/null and b/sample_images/Flir_B60.jpg differ diff --git a/sample_images/Flir_E40.jpg b/sample_images/Flir_E40.jpg new file mode 100644 index 0000000..a97cf76 Binary files /dev/null and b/sample_images/Flir_E40.jpg differ diff --git a/sample_images/H20T.jpg b/sample_images/H20T.jpg new file mode 100644 index 0000000..3ada7c8 Binary files /dev/null and b/sample_images/H20T.jpg differ diff --git a/sample_images/H20T_2.jpg b/sample_images/H20T_2.jpg new file mode 100644 index 0000000..74b8b4d Binary files /dev/null and b/sample_images/H20T_2.jpg differ diff --git a/sample_images/T640.jpg b/sample_images/T640.jpg new file mode 100644 index 0000000..a2aaeac Binary files /dev/null and b/sample_images/T640.jpg differ diff --git a/sample_images/T640_2.jpg b/sample_images/T640_2.jpg new file mode 100644 index 0000000..a2ef26b Binary files /dev/null and b/sample_images/T640_2.jpg differ diff --git a/split_seq.py b/split_seq.py deleted file mode 100644 index db39e76..0000000 --- a/split_seq.py +++ /dev/null @@ -1,4 +0,0 @@ -from CThermal import CSeqVideo -import sys - -CSeqVideo(sys.argv[1]) \ No newline at end of file diff --git a/thermal_analysis.py b/thermal_analysis.py deleted file mode 100644 index 2337d1d..0000000 --- a/thermal_analysis.py +++ /dev/null @@ -1,341 +0,0 @@ -#!/usr/bin/env python3 -import os -import sys -from decimal import Decimal -from pathlib import Path - -import cv2 as cv -import numpy as np -from logzero import logger, logging -from PyInquirer import prompt - -if __name__ == "__main__": - from CThermal import CFlir -else: - from .CThermal import CFlir - - -def flush_input(): - try: - import msvcrt - - while msvcrt.kbhit(): - msvcrt.getch() - except ImportError: - import sys, termios # for linux/unix - - termios.tcflush(sys.stdin, termios.TCIOFLUSH) - - -if __name__ == "__main__": - input_image_path = sys.argv[1] - - cmap = cv.COLORMAP_JET - therm_obj = CFlir(input_image_path, color_map=cv.COLORMAP_JET) - raw_np = therm_obj.raw_thermal_np - original_array = therm_obj.thermal_np - array = original_array.copy() - corrected_array = original_array.copy() - default_scaled_image, default_scaled_array = therm_obj.default_scaling_image(array, cmap) - image = default_scaled_image.copy() - original_default_scaled_array = default_scaled_array.copy() - default_scale = True - - OD, RH, RAT, AT, E = [None] * 5 - - while True: - os.system("cls" if os.name == "nt" else "clear") - flush_input() - cv.namedWindow("Main Window", 0) # Scalable Window - cv.imshow("Main Window", image) - questions = [ - { - "type": "list", - "name": "main_operation", - "message": "What operation would you like to perform", - "choices": [ - "ROI Temperature Scaling", - "Area Highlighting Tool", - "Line Measurement Tool", - "Spot Measurement Tool", - "Change Parameters", - "Change Color Map", - "Invert Image Scale", - "Reset", - "Continue", - "Save thermal image", - "Exit", - ], - } - ] - - cv.waitKey(1) - opt = prompt(questions)["main_operation"] - cv.destroyAllWindows() - - os.system("cls" if os.name == "nt" else "clear") - - vals = [] - - logger.info(f"Selected {opt}") - if opt == "ROI Temperature Scaling": - logger.info("Draw a region in the image, then hit Enter") - array, image = therm_obj.get_scaled_image(image, array, raw_np, cmap) - default_scaled_array = array.copy() - # image = changed_image.copy() - - elif opt == "Area Highlighting Tool": - questions_2 = [ - { - "type": "list", - "name": "area_type", - "message": "What type of area would you like to draw?", - "choices": ["Free Hand", "Rectangle"], - } - ] - aopt = prompt(questions_2)["area_type"] - logger.info(f"Draw a region in the image, then hit Enter") - if aopt == "Free Hand": - therm_obj.get_measurement_contours(image) - elif aopt == "Rectangle": - therm_obj.get_measurement_contours(image, is_rect=True) - - elif opt == "Line Measurement Tool": - logger.info( - "Please draw the extremes of the line along which you would like to measure temperature, then hit Enter" - ) - therm_obj.line_measurement(image, corrected_array, cmap) - - elif opt == "Spot Measurement Tool": - logger.info("Please select the points where you would like to measure temperature") - therm_obj.get_spots(image) - - elif opt == "Change Parameters": - if OD is None: - OD = CFlir.parse_length(therm_obj.meta["ObjectDistance"]) - if RH is None: - RH = CFlir.parse_percent(therm_obj.meta["RelativeHumidity"]) - if RAT is None: - RAT = CFlir.parse_temp(therm_obj.meta["ReflectedApparentTemperature"]) - if AT is None: - AT = CFlir.parse_temp(therm_obj.meta["AtmosphericTemperature"]) - if E is None: - E = therm_obj.meta["Emissivity"] - - cv.imshow("Parameter Change", image) - cv.waitKey(1) - - logger.info( - f"""Existing values of parameters: - 1.Object Distance: {OD} - 2.Relative Humidity: {RH} - 3.Reflected Apparent Temperature: {RAT} - 4.Atmospheric Temperature: {AT} - 5.Emissivity of image: {E}""" - ) - logger.info("Please press enter new values:") - - def is_float(value): - try: - float(value) - return True - except ValueError: - return False - - questions_2 = [ - { - "type": "input", - "name": "OD", - "message": "Object Distance", - "default": str(OD), - "validate": is_float, - "filter": lambda val: float(val), - }, - { - "type": "input", - "name": "RH", - "message": "Relative Humidity", - "default": str(RH), - "validate": is_float, - "filter": lambda val: float(val), - }, - { - "type": "input", - "name": "RAT", - "message": "Reflected Apparent Temperature", - "default": str(RAT), - "validate": is_float, - "filter": lambda val: float(val), - }, - { - "type": "input", - "name": "AT", - "message": "Atmospheric Temperature", - "default": str(AT), - "validate": is_float, - "filter": lambda val: float(val), - }, - { - "type": "input", - "name": "E", - "message": "Emissivity of image", - "default": str(E), - "validate": is_float, - "filter": lambda val: float(val), - }, - ] - - popt = prompt(questions_2) - OD, RH, RAT, AT, E = popt["OD"], popt["RH"], popt["RAT"], popt["AT"], popt["E"] - cv.destroyAllWindows() - - raw2tempfunc = lambda x: CFlir.raw2temp( - x, - E=E, - OD=OD, - RTemp=RAT, - ATemp=AT, - IRWTemp=CFlir.parse_temp(therm_obj.meta["IRWindowTemperature"]), - IRT=therm_obj.meta["IRWindowTransmission"], - RH=RH, - PR1=therm_obj.meta["PlanckR1"], - PB=therm_obj.meta["PlanckB"], - PF=therm_obj.meta["PlanckF"], - PO=therm_obj.meta["PlanckO"], - PR2=therm_obj.meta["PlanckR2"], - ) - corrected_array = raw2tempfunc(raw_np) - default_scaled_array = therm_obj.default_scaling_image(corrected_array, cmap)[1] - - elif opt == "Change Color Map": - questions_2 = [ - { - "type": "list", - "name": "cmap", - "message": "What Colourmap would you like to use?", - "choices": ["Jet(Default)", "Gray(No false color map)", "Rainbow", "Hot"], - } - ] - copt = prompt(questions_2)["cmap"] - - if copt == "Jet(Default)": - cmap = cv.COLORMAP_JET - elif copt == "Gray(No false color map)": - cmap = None - elif copt == "Rainbow": - cmap = cv.COLORMAP_RAINBOW - elif copt == "Hot": - cmap = cv.COLORMAP_HOT - - image = CFlir.get_temp_image(default_scaled_array, colormap=cmap) - - elif opt == "Invert Image Scale": - logger.info("Changing the scale of the image") - if default_scale is True: - default_scale = False - image = therm_obj.thermal_image.copy() - array = original_array.copy() - default_scaled_array = array.copy() - else: - default_scale = True - image = default_scaled_image.copy() - default_scaled_array = original_default_scaled_array.copy() - continue - - elif opt == "Reset": - array = original_array.copy() - image = default_scaled_image.copy() - corrected_array = original_array.copy() - default_scaled_array = original_default_scaled_array.copy() - therm_obj.scale_contours.clear() - therm_obj.measurement_contours.clear() - therm_obj.measurement_rects.clear() - therm_obj.spots.clear() - cmap = cv.COLORMAP_JET - continue - - elif opt == "Continue": - logger.warning("Continuing Without Change") - - elif opt == "Save thermal image": - logger.info("Saving image") - cv.imwrite(f"{Path(input_image_path).name}_formatted.jpg", image) - - elif opt == "Exit": - logger.warning("Exiting...") - break - - vals, measurement_indices = therm_obj.get_measurement_areas_values(image, corrected_array, raw_np) - spot_vals = therm_obj.get_spots_values(image, corrected_array, raw_np, therm_obj.spots) - - cv.namedWindow("Main Window", 0) - cv.resizeWindow("Main Window", (image.shape[1], image.shape[0])) - cv.setMouseCallback( - "Main Window", - CFlir.move_contours, - ( - therm_obj.measurement_contours, - therm_obj.measurement_rects, - therm_obj.scale_contours, - therm_obj.spots, - image, - vals, - spot_vals, - ), - ) - - original_image = image.copy() - - temp_min, temp_max = round(np.amin(default_scaled_array), 2), round(np.amax(default_scaled_array), 2) - - while True: - append_img = therm_obj.generate_colorbar(temp_min, temp_max, cmap) - - if len(image.shape) == 2: - image = cv.cvtColor(image, cv.COLOR_GRAY2BGR) - image = np.concatenate((image, append_img), axis=1) - - if len(vals) > 0: - for i in range(0, len(vals)): - vals[i] = therm_obj.get_measurement_areas_values(image, corrected_array, raw_np)[0][ - i - ] # list assignment will have to be done this way so that 'vals' remains the same list which is passed to the mouse callback - - if len(spot_vals) > 0: - for i in range(0, len(spot_vals)): - spot_vals[i] = therm_obj.get_spots_values(image, corrected_array, raw_np, therm_obj.spots)[i] - - if len(therm_obj.scale_contours) > 0: - cv.drawContours(image, therm_obj.scale_contours, -1, (0, 0, 0), 1, 8) - - if len(therm_obj.measurement_contours) > 0: - cv.drawContours(image, therm_obj.measurement_contours, -1, (0, 0, 255), 1, 8) - - if len(therm_obj.measurement_rects) > 0: - for i in range(len(therm_obj.measurement_rects)): - cv.rectangle(image, therm_obj.measurement_rects[i], (0, 0, 255)) - - if len(therm_obj.spots) > 0: - cv.drawContours(image, therm_obj.spots, -1, (255, 255, 255), -1) - - if CFlir.xdisp != None and CFlir.ydisp != None: - temp = Decimal(corrected_array[CFlir.ydisp][CFlir.xdisp]) - temp = round(temp, 2) - cv.putText(image, str(temp) + "C", (CFlir.xdisp, CFlir.ydisp), cv.FONT_HERSHEY_PLAIN, 1, 0, 2, 8) - - cv.imshow("Main Window", image) - - if len(therm_obj.scale_contours) > 0 and len(therm_obj.scale_contours[0]) > 15: - roi_vals = CFlir.get_roi(image, corrected_array, raw_np, therm_obj.scale_contours, 0)[1] - scaled = CFlir.scale_with_roi(corrected_array, roi_vals) - image = CFlir.get_temp_image(scaled, colormap=cmap) - temp_min, temp_max = round(np.amin(roi_vals), 2), round(np.amax(roi_vals), 2) - else: - image = original_image.copy() - - k = cv.waitKey(1) & 0xFF - - if k == 13 or k == 141: - break - - cv.destroyWindow("Main Window") diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..156fa92 --- /dev/null +++ b/utils.py @@ -0,0 +1,276 @@ +"""Utilities for handling backend functions.""" +import pickle +import threading +from tkinter import Tk, filedialog, messagebox, ttk +from tkinter.constants import S + +import numpy as np +import pandas as pd +import pygame +from matplotlib import figure +from matplotlib.backends.backend_tkagg import (FigureCanvasTkAgg, + NavigationToolbar2Tk) +from PIL import Image + + +class SaveData: + """Empty data class.""" + + pass + + +class TableView(threading.Thread): + """Tkinter thread constituting the table.""" + + def __init__(self): + """Initializer for table thread.""" + threading.Thread.__init__(self) + self.start() + self.initialized = False + self.data = [] + + def addRow(self, entry): + """Add row to table.""" + while not self.initialized: + pass + entry[1:] = [round(ent, 3) for ent in entry[1:]] + self.treev.insert("", "end", text="L1", values=entry) + self.data.append(entry) + + def getDF(self): + """Get table values as a pandas data frame.""" + data = pd.DataFrame(self.data, columns=["Element", "Min", "Max", "Average"]) + data.set_index("Element", inplace=True) + return data + + def killTable(self): + """Kill table thread.""" + self.root.quit() + self.root.update() + + def run(self): + """Run.""" + self.root = Tk() + self.root.protocol("WM_DELETE_WINDOW", lambda: None) + self.root.title("Table") + + self.treev = ttk.Treeview(self.root, selectmode="browse") + self.treev.pack(side="right") + self.treev.pack(side="right") + + verscrlbar = ttk.Scrollbar( + self.root, orient="vertical", command=self.treev.yview + ) + verscrlbar.pack(side="right", fill="x") + + self.treev.configure(xscrollcommand=verscrlbar.set) + self.treev["columns"] = ("1", "2", "3", "4") + self.treev["show"] = "headings" + + self.treev.column("1", width=100, anchor="c") + self.treev.column("2", width=100, anchor="se") + self.treev.column("3", width=100, anchor="se") + self.treev.column("4", width=100, anchor="se") + + self.treev.heading("1", text="Element") + self.treev.heading("2", text="min") + self.treev.heading("3", text="max") + self.treev.heading("4", text="average") + + self.initialized = True + + self.root.mainloop() + + +class Figure(threading.Thread): + """Matplotlib threading using tkinter.""" + + def __init__(self, plots): + """Figure thread initializer.""" + threading.Thread.__init__(self) + self.plots = plots + self.start() + + def killFigure(self): + """Kill figure thread.""" + self.root.quit() + self.root.update() + + def saveFig(self, filename): + """Save figure.""" + self.fig.savefig(filename) + + def run(self): + """Run.""" + self.root = Tk() + self.root.protocol("WM_DELETE_WINDOW", lambda: None) + self.root.title("Plot") + + self.fig = figure.Figure() + plot = self.fig.add_subplot(111) + + for x, y, label in self.plots: + plot.plot(x, y, label=label) + plot.legend() + + canvas = FigureCanvasTkAgg(self.fig, master=self.root) + canvas.draw() + canvas.get_tk_widget().pack() + + toolbar = NavigationToolbar2Tk(canvas, self.root) + toolbar.update() + + canvas.get_tk_widget().pack() + + self.root.mainloop() + + +class WindowHandler: + """Handles external(graphs/tables) windows.""" + + def __init__(self): + """Initializer for window handler.""" + self.mainTable = None + self.mainFigure = None + self.plots = [] + self.killed = False + self.rects = [] + + def __del__(self): + if not self.killed: + self.killThreads() + + def killThreads(self): + """Kill all running threads.""" + if self.mainTable: + self.mainTable.killTable() + self.mainTable.join() + if self.mainFigure: + self.mainFigure.killFigure() + self.mainFigure.join() + self.killed = True + + def addRects(self, rects): + """Add rectangle coordinates.""" + for rect in rects: + self.rects.append(rect) + + def addToTable(self, entry): + """Add entry to table.""" + if self.mainTable is None: + self.mainTable = TableView() + self.mainTable.addRow(entry) + + def loadGraph(self, plots_in): + """Load the graph window.""" + self.plots = plots_in + if self.plots: + self.mainFigure = Figure(self.plots) + + def linePlot( + self, mat, label, startPoint, endPoint, resolution=100, interpolation="bilinear" + ): + """Plot line graph.""" + direcion = endPoint - startPoint + distance = np.linalg.norm(direcion) + distances = [] + values = [] + + for i in range(resolution): + + point = startPoint + i * direcion / resolution + + if interpolation == "bilinear": + x = int(point[0]) + y = int(point[1]) + dx = point[0] - x + dy = point[1] - y + val = ( + np.array([[1 - dx, dx]]) + @ mat[x : x + 2, y : y + 2] + @ np.array([[1 - dy], [dy]]) + )[0, 0] + + if interpolation == "nearest_neighbour": + val = mat[round(point[0]), round(point[1])] + + values.append(val) + distances.append(i * distance / resolution) + + self.addToTable([label, min(values), max(values), sum(values) / len(values)]) + + if self.mainFigure: + self.mainFigure.killFigure() + self.plots.append([distances, values, label]) + self.mainFigure = Figure(self.plots) + + +def saveImage(window): + """Save current image.""" + imageSurface = window.imsurf + overlays = window.overlays + exthandler = window.exthandler + Tk().withdraw() + file = filedialog.asksaveasfile( + filetypes=[("PNG Image", "*.png")], defaultextension=".png" + ) + + if file: + filename = file.name + + if ( + messagebox.askquestion( + "Save options", "Do you want to save with the annotations?" + ) + == "yes" + ): + imageSurface.blit(overlays, (0, 0)) + + data = SaveData() + data.plots = window.exthandler.plots + data.rects = window.exthandler.rects + data.tableEntries = [] + data.mat = window.mat + data.mat_orig = window.mat_orig + data.mat_emm = window.mat_emm + data.raw = window.raw + data.meta = window.meta + data.overlays = pygame.image.tostring(overlays, "RGBA") + + if exthandler.mainFigure: + exthandler.mainFigure.saveFig( + ".".join(filename.split(".")[:-1]) + "_plot.png" + ) + if exthandler.mainTable: + with pd.ExcelWriter( + ".".join(filename.split(".")[:-1]) + "_values.xlsx", + engine="xlsxwriter", + ) as writer: + exthandler.mainTable.getDF().to_excel(writer, sheet_name="Table") + if len(data.plots) > 0: + pd.DataFrame( + [plot[1] for plot in data.plots], + index=[f"l{i+1}" for i in range(len(data.plots))], + ).to_excel(writer, sheet_name="Lines") + data.tableEntries = exthandler.mainTable.data + for i, (x1, x2, y1, y2) in enumerate(data.rects): + pd.DataFrame( + data.mat_emm[y1:y2, x1:x2], + index=y1 + np.arange(y2 - y1), + columns=x1 + np.arange(x2 - x1), + ).to_excel(writer, sheet_name=f"Area{i+1}") + + with open(".".join(filename.split(".")[:-1]) + ".pkl", "wb") as f: + pickle.dump(data, f, -1) + + imgdata = pygame.surfarray.pixels3d(imageSurface).astype(np.uint8) + imgdata = np.swapaxes(imgdata, 0, 1) + Image.fromarray(imgdata).save(filename) + print("Saved successfully") + + +def openImage(): + """Open new image.""" + Tk().withdraw() + filename = filedialog.askopenfilename(title="Open Thermal Image") + return filename