From fe4737fda331107878cf7711d488a870e2434b9e Mon Sep 17 00:00:00 2001 From: Sawyer McLane Date: Sun, 30 Jan 2022 07:30:22 -0700 Subject: [PATCH] Added type annotations --- lifx_control_panel/_constants.py | 2 +- lifx_control_panel/frames.py | 33 ++++++----- lifx_control_panel/ui/colorscale.py | 61 ++++++++++++-------- lifx_control_panel/ui/icon_list.py | 2 +- lifx_control_panel/utilities/color_thread.py | 37 ++++++------ lifx_control_panel/utilities/utils.py | 46 ++++----------- 6 files changed, 88 insertions(+), 93 deletions(-) diff --git a/lifx_control_panel/_constants.py b/lifx_control_panel/_constants.py index beb86e8..5f55e94 100644 --- a/lifx_control_panel/_constants.py +++ b/lifx_control_panel/_constants.py @@ -1,4 +1,4 @@ VERSION = "2.2.1" -BUILD_DATE = "2021-12-22T20:21:34.343113" +BUILD_DATE = "2022-01-28T08:52:57.205948" AUTHOR = "Sawyer McLane" DEBUGGING = False diff --git a/lifx_control_panel/frames.py b/lifx_control_panel/frames.py index 71bf348..8deede4 100644 --- a/lifx_control_panel/frames.py +++ b/lifx_control_panel/frames.py @@ -25,12 +25,15 @@ from lifx_control_panel.ui.colorscale import ColorScale from lifx_control_panel.ui.settings import config from lifx_control_panel.utilities import color_thread -from lifx_control_panel.utilities.color_thread import getScreenAsImage, normalizeRects +from lifx_control_panel.utilities.color_thread import ( + get_screen_as_image, + normalize_rectangles, +) from lifx_control_panel.utilities.utils import ( Color, tuple2hex, hsbk_to_rgb, - hue_to_rgb, + hsv_to_rgb, kelvin_to_rgb, get_primary_monitor, str2list, @@ -256,7 +259,9 @@ def start_audio(): self.special_functions_lf, text="Music Color", command=start_audio, - state="normal" if self.master.audio_interface.initialized else "disabled", + state="disabled" + if not self.master.audio_interface.initialized + else "normal", ) self.music_button.grid(row=8, column=0) self.threads["eyedropper"] = color_thread.ColorThreadRunner( @@ -334,12 +339,12 @@ def setup_color_controls(self, init_color: Color): self.hsbk_labels: Tuple[ tkinter.Label, tkinter.Label, tkinter.Label, tkinter.Label ] = ( - tkinter.Label(self, text="%.3g" % (360 * (self.hsbk[0].get() / 65535))), + tkinter.Label(self, text=f"{360 * (self.hsbk[0].get() / 65535):.3g}"), tkinter.Label( - self, text=str("%.3g" % (100 * self.hsbk[1].get() / 65535)) + "%" + self, text=str(f"{100 * self.hsbk[1].get() / 65535:.3g}") + "%" ), tkinter.Label( - self, text=str("%.3g" % (100 * self.hsbk[2].get() / 65535)) + "%" + self, text=str(f"{100 * self.hsbk[2].get() / 65535:.3g}") + "%" ), tkinter.Label(self, text=str(self.hsbk[3].get()) + " K"), ) @@ -381,7 +386,7 @@ def setup_color_controls(self, init_color: Color): ] = ( tkinter.Canvas( self, - background=tuple2hex(hue_to_rgb(360 * (init_color.hue / 65535))), + background=tuple2hex(hsv_to_rgb(360 * (init_color.hue / 65535))), width=20, height=20, borderwidth=3, @@ -529,13 +534,13 @@ def update_label(self, key: int): """ Update scale labels, formatted accordingly. """ return [ self.hsbk_labels[0].config( - text=str("%.3g" % (360 * (self.hsbk[0].get() / 65535))) + text=str(f"{360 * (self.hsbk[0].get() / 65535):.3g}") ), self.hsbk_labels[1].config( - text=str("%.3g" % (100 * (self.hsbk[1].get() / 65535))) + "%" + text=str(f"{100 * (self.hsbk[1].get() / 65535):.3g}") + "%" ), self.hsbk_labels[2].config( - text=str("%.3g" % (100 * (self.hsbk[2].get() / 65535))) + "%" + text=str(f"{100 * (self.hsbk[2].get() / 65535):.3g}") + "%" ), self.hsbk_labels[3].config(text=str(self.hsbk[3].get()) + " K"), ][key] @@ -545,7 +550,7 @@ def update_display(self, key: int): h, s, b, k = self.get_color_values_hsbk() # pylint: disable=invalid-name if key == 0: self.hsbk_display[0].config( - background=tuple2hex(hue_to_rgb(360 * (h / 65535))) + background=tuple2hex(hsv_to_rgb(360 * (h / 65535))) ) elif key == 1: s = 65535 - s # pylint: disable=invalid-name @@ -585,7 +590,7 @@ def get_color_from_palette(self): def update_status_from_bulb(self, run_once=False): """ Periodically update status from the bulb to keep UI in sync. - :param run_once: Don't call `after` statement at end. Keeps a million workers from being instanced. + run_once - Don't call `after` statement at end. Keeps a million workers from being instanced. """ require_icon_update = False if not self.master.bulb_interface.power_queue[self.label].empty(): @@ -628,10 +633,10 @@ def eyedropper(self, *_, **__): break lifxlan.sleep(0.001) # tkinter.Button state changed - screen_img = getScreenAsImage() + screen_img = get_screen_as_image() cursor_pos = mouse.get_position() # Convert display coords to image coords - cursor_pos = normalizeRects( + cursor_pos = normalize_rectangles( get_display_rects() + [(cursor_pos[0], cursor_pos[1], 0, 0)] )[-1][:2] color = screen_img.getpixel(cursor_pos) diff --git a/lifx_control_panel/ui/colorscale.py b/lifx_control_panel/ui/colorscale.py index 1759146..8a31ae3 100644 --- a/lifx_control_panel/ui/colorscale.py +++ b/lifx_control_panel/ui/colorscale.py @@ -2,10 +2,14 @@ import tkinter as tk from typing import List -from ..utilities.utils import tuple2hex, hue_to_rgb, kelvin_to_rgb +from ..utilities.utils import tuple2hex, hsv_to_rgb, kelvin_to_rgb class ColorScale(tk.Canvas): + """ + A canvas that displays a color scale. + """ + def __init__( self, parent, @@ -53,7 +57,7 @@ def __init__( self._variable.set(val) self._variable.trace("w", self._update_val) - self.gradient = tk.PhotoImage(master=self, width=width, height=height) + self.gradient = tk.PhotoImage(master=self, width=int(width), height=int(height)) self.bind("", lambda _: self._draw_gradient(val)) self.bind("", self._on_click) @@ -71,7 +75,9 @@ def _draw_gradient(self, val): self.gradient = tk.PhotoImage(master=self, width=width, height=height) line: List[str] = [] - gradfunc = lambda x_coord: line.append(tuple2hex((0, 0, 0))) + + def gradfunc(x_coord): + return line.append(tuple2hex((0, 0, 0))) if self.color_grad == "bw": @@ -97,46 +103,51 @@ def gradfunc(x_coord): elif self.color_grad == "hue": def gradfunc(x_coord): - line.append(tuple2hex(hue_to_rgb(float(x_coord) / width * 360))) + line.append(tuple2hex(hsv_to_rgb(float(x_coord) / width * 360))) else: raise ValueError(f"gradient value {self.color_grad} not recognized") - for x in range(width): - gradfunc(x) + for x_coord in range(width): + gradfunc(x_coord) line: str = "{" + " ".join(line) + "}" self.gradient.put(" ".join([line for _ in range(height)])) self.create_image(0, 0, anchor="nw", tags="gradient", image=self.gradient) self.lower("gradient") + x_start: float = self.min try: - x = (val - self.min) / float(self.range) * width + x_start = (val - self.min) / float(self.range) * width except ZeroDivisionError: - x = self.min - self.create_line(x, 0, x, height, width=4, fill="white", tags="cursor") - self.create_line(x, 0, x, height, width=2, tags="cursor") + x_start = self.min + self.create_line( + x_start, 0, x_start, height, width=4, fill="white", tags="cursor" + ) + self.create_line(x_start, 0, x_start, height, width=2, tags="cursor") def _on_click(self, event): """Move selection cursor on click.""" - x = event.x - if x >= 0: + x_coord = event.x + if x_coord >= 0: width = self.winfo_width() - self.update_slider_value(width, x) + self.update_slider_value(width, x_coord) - def update_slider_value(self, width, x): - for s in self.find_withtag("cursor"): - self.coords(s, x, 0, x, self.winfo_height()) - self._variable.set(round((float(self.range) * x) / width + self.min, 2)) + def update_slider_value(self, width, x_coord): + """Update the slider value based on slider x coordinate.""" + height = self.winfo_height() + for x_start in self.find_withtag("cursor"): + self.coords(x_start, x_coord, 0, x_coord, height) + self._variable.set(round((float(self.range) * x_coord) / width + self.min, 2)) if self.command is not None: self.command() def _on_move(self, event): """Make selection cursor follow the cursor.""" - x = event.x - if x >= 0: - w = self.winfo_width() - x = min(max(abs(x), 0), w) - self.update_slider_value(w, x) + x_coord = event.x + if x_coord >= 0: + width = self.winfo_width() + x_coord = min(max(abs(x_coord), 0), width) + self.update_slider_value(width, x_coord) def _update_val(self, *_): val = int(self._variable.get()) @@ -154,9 +165,9 @@ def set(self, val): """Set cursor position on the color corresponding to the value""" width = self.winfo_width() try: - x = (val - self.min) / float(self.range) * width + x_coord = (val - self.min) / float(self.range) * width except ZeroDivisionError: return - for s in self.find_withtag("cursor"): - self.coords(s, x, 0, x, self.winfo_height()) + for x_start in self.find_withtag("cursor"): + self.coords(x_start, x_coord, 0, x_coord, self.winfo_height()) self._variable.set(val) diff --git a/lifx_control_panel/ui/icon_list.py b/lifx_control_panel/ui/icon_list.py index 695239d..aea9f13 100644 --- a/lifx_control_panel/ui/icon_list.py +++ b/lifx_control_panel/ui/icon_list.py @@ -54,7 +54,7 @@ def __init__(self, *args, is_group: bool = False, **kwargs): ) self.scroll_x = 0 self.scroll_y = 0 - self.bulb_dict = {} + self.bulb_dict: dict[str, tuple[tkinter.PhotoImage, int, int]] = {} self.canvas = tkinter.Canvas( self, width=self.settings.window_width, diff --git a/lifx_control_panel/utilities/color_thread.py b/lifx_control_panel/utilities/color_thread.py index 4094983..6a22a5b 100644 --- a/lifx_control_panel/utilities/color_thread.py +++ b/lifx_control_panel/utilities/color_thread.py @@ -6,6 +6,7 @@ import logging import threading from functools import lru_cache +from typing import List, Tuple import mss import numexpr as ne @@ -26,7 +27,8 @@ def get_monitor_bounds(func): return func() or config["AverageColor"]["DefaultMonitor"] -def getScreenAsImage(): +def get_screen_as_image(): + """Grabs the entire primary screen as an image""" with mss.mss() as sct: monitor = sct.monitors[0] @@ -40,7 +42,8 @@ def getScreenAsImage(): return Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") -def getRectAsImage(bounds): +def get_rect_as_image(bounds: Tuple[int, int, int, int]): + """ Grabs a rectangular area of the primary screen as an image """ with mss.mss() as sct: monitor = { "left": bounds[0], @@ -52,36 +55,38 @@ def getRectAsImage(bounds): return Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") -def normalizeRects(rects): - smallestX = min(rect[0] for rect in rects) - smallestY = min(rect[1] for rect in rects) +def normalize_rectangles(rects: List[Tuple[int, int, int, int]]): + """ Normalize the rectangles to the monitor size """ + x_min = min(rect[0] for rect in rects) + y_min = min(rect[1] for rect in rects) return [ - (-smallestX + left, -smallestY + top, -smallestX + right, -smallestY + bottom,) + (-x_min + left, -y_min + top, -x_min + right, -y_min + bottom,) for left, top, right, bottom in rects ] -def avg_screen_color(initial_color, func_bounds=lambda: None): - """ Capture an image of the monitor defined by func_bounds, then get the average color of the image in HSBK""" +def avg_screen_color(initial_color_hsbk, func_bounds=lambda: None): + """ Capture an image of the monitor defined by func_bounds, then get the average color of the image in HSBK """ monitor = get_monitor_bounds(func_bounds) if "full" in monitor: - screenshot = getScreenAsImage() + screenshot = get_screen_as_image() else: - screenshot = getRectAsImage(str2list(monitor, int)) + screenshot = get_rect_as_image(str2list(monitor, int)) # Resizing the image to 1x1 pixel will give us the average for the whole image (via HAMMING interpolation) color = screenshot.resize((1, 1), Image.HAMMING).getpixel((0, 0)) - return list(utils.RGBtoHSBK(color, temperature=initial_color[3])) + return list(utils.RGBtoHSBK(color, temperature=initial_color_hsbk[3])) def dominant_screen_color(initial_color, func_bounds=lambda: None): """ + Gets the dominant color of the screen defined by func_bounds https://stackoverflow.com/questions/50899692/most-dominant-color-in-rgb-image-opencv-numpy-python """ monitor = get_monitor_bounds(func_bounds) if "full" in monitor: - screenshot = getScreenAsImage() + screenshot = get_screen_as_image() else: - screenshot = getRectAsImage(str2list(monitor, int)) + screenshot = get_rect_as_image(str2list(monitor, int)) downscale_width, downscale_height = screenshot.width // 4, screenshot.height // 4 screenshot = screenshot.resize((downscale_width, downscale_height), Image.HAMMING) @@ -99,9 +104,7 @@ def dominant_screen_color(initial_color, func_bounds=lambda: None): a1D = ne.evaluate("a0*s0*s1+a1*s0+a2", eval_params) color = np.unravel_index(np.bincount(a1D).argmax(), col_range) - color_hsbk = list(utils.RGBtoHSBK(color, temperature=initial_color[3])) - # color_hsbk[2] = initial_color[2] # TODO Decide this - return color_hsbk + return list(utils.RGBtoHSBK(color, temperature=initial_color[3])) class ColorThread(threading.Thread): @@ -129,7 +132,7 @@ def __init__(self, bulb, color_function, parent, continuous=True, **kwargs): self.kwargs = kwargs self.parent = parent # couple to parent frame self.logger = logging.getLogger( - parent.logger.name + ".Thread({})".format(color_function.__name__) + parent.logger.name + f".Thread({color_function.__name__})" ) self.prev_color = parent.get_color_values_hsbk() self.continuous = continuous diff --git a/lifx_control_panel/utilities/utils.py b/lifx_control_panel/utilities/utils.py index 6d5aadc..5d61c07 100644 --- a/lifx_control_panel/utilities/utils.py +++ b/lifx_control_panel/utilities/utils.py @@ -1,19 +1,10 @@ # -*- coding: utf-8 -*- -"""General utility classes and functions - -Contains several classes and functions for quality of life. Used indiscriminately throughout the module. - -Notes ------ - Functions should attempt to not contain stateful information, as this module will be called by other modules - throughout the program, including other Threads, and as such states may not be coherent. -""" +"""General utility classes and functions.""" import os import sys -import time from functools import lru_cache from math import log, floor -from typing import Union, Tuple +from typing import Union, Tuple, List import mss @@ -73,8 +64,10 @@ def __iter__(self): def hsbk_to_rgb(hsbk: TypeHSBK) -> TypeRGB: - """ Convert Tuple in HSBK color-space to RGB space. - Converted from PHP https://gist.github.com/joshrp/5200913 """ + """ + Convert Tuple in HSBK color-space to RGB space. + Converted from PHP https://gist.github.com/joshrp/5200913 + """ # pylint: disable=invalid-name iH, iS, iB, iK = hsbk dS = (100 * iS / 65535) / 100.0 # Saturation: 0.0-1.0 @@ -135,7 +128,7 @@ def hsbk_to_rgb(hsbk: TypeHSBK) -> TypeRGB: return x, y, z -def hue_to_rgb(h: float, s: float = 1, v: float = 1) -> TypeRGB: +def hsv_to_rgb(h: float, s: float = 1, v: float = 1) -> TypeRGB: """ Convert a Hue-angle to an RGB value for display. """ # pylint: disable=invalid-name h = float(h) @@ -200,19 +193,19 @@ def tuple2hex(tuple_: TypeRGB) -> str: return "#%02x%02x%02x" % tuple_ -def str2list(string: str, type_func) -> list: +def str2list(string: str, type_func) -> List: """ Takes a Python list-formatted string and returns a list of elements of type type_func """ return list(map(type_func, string.strip("()[]").split(","))) -def str2tuple(string: str, type_func) -> tuple: +def str2tuple(string: str, type_func) -> Tuple: """ Takes a Python list-formatted string and returns a tuple of type type_func """ return tuple(map(type_func, string.strip("()[]").split(","))) # Multi monitor methods @lru_cache(maxsize=None) -def get_primary_monitor() -> Tuple[int, int, int, int]: +def get_primary_monitor() -> Tuple[int, ...]: """ Return the system's default primary monitor rectangle bounds. """ return [rect for rect in get_display_rects() if rect[:2] == (0, 0)][ 0 @@ -230,24 +223,7 @@ def resource_path(relative_path) -> Union[int, bytes]: return os.path.join(base_path, relative_path) -# Misc - - -def timeit(method): - def timed(*args, **kw): - t_start = time.time() - result = method(*args, **kw) - t_end = time.time() - if "log_time" in kw: - name = kw.get("log_name", method.__name__.upper()) - kw["log_time"][name] = int((t_end - t_start) * 1000) - else: - print("%r %2.2f ms" % (method.__name__, (t_end - t_start) * 1000)) - return result - - return timed - - def get_display_rects(): + """ Return a list of tuples of monitor rectangles. """ with mss.mss() as sct: return [tuple(m.values()) for m in sct.monitors]