Refine UI layout and theme behavior
Generalize color selection, reorganize GUI, add presets, dynamic slider adjustments, config defaults, multiple UI updates etc
This commit is contained in:
parent
02255f5dee
commit
f9e8d01a70
|
|
@ -1,5 +1,5 @@
|
||||||
"""Application package."""
|
"""Application package."""
|
||||||
|
|
||||||
from .app import PurpleTunerApp, start_app
|
from .app import ColorCalcApp, start_app
|
||||||
|
|
||||||
__all__ = ["PurpleTunerApp", "start_app"]
|
__all__ = ["ColorCalcApp", "start_app"]
|
||||||
|
|
|
||||||
10
app/app.py
10
app/app.py
|
|
@ -8,7 +8,7 @@ from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin
|
||||||
from .logic import DEFAULTS, ImageProcessingMixin, ResetMixin
|
from .logic import DEFAULTS, ImageProcessingMixin, ResetMixin
|
||||||
|
|
||||||
|
|
||||||
class PurpleTunerApp(
|
class ColorCalcApp(
|
||||||
ThemeMixin,
|
ThemeMixin,
|
||||||
UIBuilderMixin,
|
UIBuilderMixin,
|
||||||
ImageProcessingMixin,
|
ImageProcessingMixin,
|
||||||
|
|
@ -16,11 +16,11 @@ class PurpleTunerApp(
|
||||||
ColorPickerMixin,
|
ColorPickerMixin,
|
||||||
ResetMixin,
|
ResetMixin,
|
||||||
):
|
):
|
||||||
"""Tkinter based application for highlighting purple hues in images."""
|
"""Tkinter based application for isolating configurable colour ranges."""
|
||||||
|
|
||||||
def __init__(self, root: tk.Tk):
|
def __init__(self, root: tk.Tk):
|
||||||
self.root = root
|
self.root = root
|
||||||
self.root.title("Purple Tuner — Bild + Overlay")
|
self.root.title("ColorCalc — Bild + Overlay")
|
||||||
try:
|
try:
|
||||||
self.root.state("zoomed")
|
self.root.state("zoomed")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -66,8 +66,8 @@ class PurpleTunerApp(
|
||||||
def start_app() -> None:
|
def start_app() -> None:
|
||||||
"""Entry point used by the CLI script."""
|
"""Entry point used by the CLI script."""
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
app = PurpleTunerApp(root)
|
app = ColorCalcApp(root)
|
||||||
root.mainloop()
|
root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["PurpleTunerApp", "start_app"]
|
__all__ = ["ColorCalcApp", "start_app"]
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,34 @@ class ColorPickerMixin:
|
||||||
"""Handles colour selection from dialogs and mouse clicks."""
|
"""Handles colour selection from dialogs and mouse clicks."""
|
||||||
|
|
||||||
ref_hue: float | None
|
ref_hue: float | None
|
||||||
|
hue_span: float = 45.0 # degrees around the picked hue
|
||||||
|
|
||||||
def choose_color(self):
|
def choose_color(self):
|
||||||
rgb, hex_colour = colorchooser.askcolor(title="Farbe wählen")
|
rgb, hex_colour = colorchooser.askcolor(title="Farbe wählen")
|
||||||
if rgb is None:
|
if rgb is None:
|
||||||
return
|
return
|
||||||
r, g, b = [int(channel) for channel in rgb]
|
r, g, b = (int(round(channel)) for channel in rgb)
|
||||||
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
|
hue_deg, sat_pct, val_pct = self._apply_rgb_selection(r, g, b)
|
||||||
self.ref_hue = h * 360.0
|
label = hex_colour or f"RGB({r}, {g}, {b})"
|
||||||
span = 30
|
self.status.config(
|
||||||
self.hue_min.set((self.ref_hue - span) % 360)
|
text=f"Farbe gewählt: {label} — Hue {hue_deg:.1f}°, S {sat_pct:.0f}%, V {val_pct:.0f}%"
|
||||||
self.hue_max.set((self.ref_hue + span) % 360)
|
)
|
||||||
self.update_preview()
|
|
||||||
self.status.config(text=f"Farbe gewählt: {hex_colour} (Hue {self.ref_hue:.1f}°)")
|
def apply_sample_colour(self, hex_colour: str, name: str | None = None) -> None:
|
||||||
|
"""Apply a predefined colour preset."""
|
||||||
|
rgb = self._parse_hex_colour(hex_colour)
|
||||||
|
if rgb is None:
|
||||||
|
return
|
||||||
|
hue_deg, sat_pct, val_pct = self._apply_rgb_selection(*rgb)
|
||||||
|
if self.pick_mode:
|
||||||
|
self.pick_mode = False
|
||||||
|
label = name or hex_colour.upper()
|
||||||
|
self.status.config(
|
||||||
|
text=(
|
||||||
|
f"Beispielfarbe gewählt: {label} ({hex_colour}) — "
|
||||||
|
f"Hue {hue_deg:.1f}°, S {sat_pct:.0f}%, V {val_pct:.0f}%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def enable_pick_mode(self):
|
def enable_pick_mode(self):
|
||||||
if self.preview_img is None:
|
if self.preview_img is None:
|
||||||
|
|
@ -47,14 +62,62 @@ class ColorPickerMixin:
|
||||||
r, g, b, a = self.preview_img.getpixel((x, y))
|
r, g, b, a = self.preview_img.getpixel((x, y))
|
||||||
if a == 0:
|
if a == 0:
|
||||||
return
|
return
|
||||||
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
|
hue_deg, sat_pct, val_pct = self._apply_rgb_selection(r, g, b)
|
||||||
self.ref_hue = h * 360.0
|
|
||||||
span = 30
|
|
||||||
self.hue_min.set((self.ref_hue - span) % 360)
|
|
||||||
self.hue_max.set((self.ref_hue + span) % 360)
|
|
||||||
self.disable_pick_mode()
|
self.disable_pick_mode()
|
||||||
|
self.status.config(
|
||||||
|
text=f"Farbe vom Bild gewählt: Hue {hue_deg:.1f}°, S {sat_pct:.0f}%, V {val_pct:.0f}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_rgb_selection(self, r: int, g: int, b: int) -> tuple[float, float, float]:
|
||||||
|
"""Update slider ranges based on an RGB colour and return HSV summary."""
|
||||||
|
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
|
||||||
|
hue_deg = (h * 360.0) % 360.0
|
||||||
|
self.ref_hue = hue_deg
|
||||||
|
self._set_slider_targets(hue_deg, s, v)
|
||||||
self.update_preview()
|
self.update_preview()
|
||||||
self.status.config(text=f"Farbe vom Bild gewählt: Hue {self.ref_hue:.1f}°")
|
return hue_deg, s * 100.0, v * 100.0
|
||||||
|
|
||||||
|
def _set_slider_targets(self, hue_deg: float, saturation: float, value: float) -> None:
|
||||||
|
span = getattr(self, "hue_span", 45.0)
|
||||||
|
self.hue_min.set((hue_deg - span) % 360)
|
||||||
|
self.hue_max.set((hue_deg + span) % 360)
|
||||||
|
|
||||||
|
sat_pct = saturation * 100.0
|
||||||
|
sat_margin = 35.0
|
||||||
|
sat_min = max(0.0, min(100.0, sat_pct - sat_margin))
|
||||||
|
if saturation <= 0.05:
|
||||||
|
sat_min = 0.0
|
||||||
|
self.sat_min.set(sat_min)
|
||||||
|
|
||||||
|
v_pct = value * 100.0
|
||||||
|
val_margin = 35.0
|
||||||
|
val_min = max(0.0, v_pct - val_margin)
|
||||||
|
val_max = min(100.0, v_pct + val_margin)
|
||||||
|
if value <= 0.15:
|
||||||
|
val_max = min(45.0, max(val_max, 25.0))
|
||||||
|
if value >= 0.85:
|
||||||
|
val_min = max(55.0, min(val_min, 80.0))
|
||||||
|
if val_max <= val_min:
|
||||||
|
val_max = min(100.0, val_min + 10.0)
|
||||||
|
self.val_min.set(val_min)
|
||||||
|
self.val_max.set(val_max)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_hex_colour(hex_colour: str | None) -> tuple[int, int, int] | None:
|
||||||
|
if not hex_colour:
|
||||||
|
return None
|
||||||
|
value = hex_colour.strip().lstrip("#")
|
||||||
|
if len(value) == 3:
|
||||||
|
value = "".join(ch * 2 for ch in value)
|
||||||
|
if len(value) != 6:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
r = int(value[0:2], 16)
|
||||||
|
g = int(value[2:4], 16)
|
||||||
|
b = int(value[4:6], 16)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return r, g, b
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["ColorPickerMixin"]
|
__all__ = ["ColorPickerMixin"]
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,12 @@ class ThemeMixin:
|
||||||
else:
|
else:
|
||||||
self.scale_style = "Horizontal.TScale"
|
self.scale_style = "Horizontal.TScale"
|
||||||
|
|
||||||
bg, fg = ("#0f0f10", "#f1f1f1") if self.theme == "dark" else ("#f2f2f7", "#202020")
|
if self.theme == "dark":
|
||||||
|
bg, fg = "#0f0f10", "#f1f1f1"
|
||||||
|
status_fg = "#f5f5f5"
|
||||||
|
else:
|
||||||
|
bg, fg = "#ededf2", "#202020"
|
||||||
|
status_fg = "#1c1c1c"
|
||||||
self.root.configure(bg=bg) # type: ignore[attr-defined]
|
self.root.configure(bg=bg) # type: ignore[attr-defined]
|
||||||
|
|
||||||
s = self.style
|
s = self.style
|
||||||
|
|
@ -69,6 +74,14 @@ class ThemeMixin:
|
||||||
)
|
)
|
||||||
s.map("TButton", background=[("active", "#d0d0d0")])
|
s.map("TButton", background=[("active", "#d0d0d0")])
|
||||||
|
|
||||||
|
button_refresher = getattr(self, "_refresh_toolbar_buttons_theme", None)
|
||||||
|
if callable(button_refresher):
|
||||||
|
button_refresher()
|
||||||
|
|
||||||
|
status_refresher = getattr(self, "_refresh_status_palette", None)
|
||||||
|
if callable(status_refresher) and hasattr(self, "status"):
|
||||||
|
status_refresher(status_fg)
|
||||||
|
|
||||||
def detect_system_theme(self) -> str:
|
def detect_system_theme(self) -> str:
|
||||||
"""Best-effort detection of the OS theme preference."""
|
"""Best-effort detection of the OS theme preference."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
289
app/gui/ui.py
289
app/gui/ui.py
|
|
@ -3,10 +3,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
import tkinter.font as tkfont
|
||||||
from tkinter import ttk
|
from tkinter import ttk
|
||||||
|
|
||||||
from .theme import HAS_TTKBOOTSTRAP
|
|
||||||
|
|
||||||
|
|
||||||
class UIBuilderMixin:
|
class UIBuilderMixin:
|
||||||
"""Constructs the Tkinter UI and common widgets."""
|
"""Constructs the Tkinter UI and common widgets."""
|
||||||
|
|
@ -24,13 +23,33 @@ class UIBuilderMixin:
|
||||||
("🔄 Slider zurücksetzen", self.reset_sliders),
|
("🔄 Slider zurücksetzen", self.reset_sliders),
|
||||||
("🌓 Theme umschalten", self.toggle_theme),
|
("🌓 Theme umschalten", self.toggle_theme),
|
||||||
]
|
]
|
||||||
for text, command in buttons:
|
self._toolbar_buttons: list[dict[str, object]] = []
|
||||||
if HAS_TTKBOOTSTRAP:
|
|
||||||
from ttkbootstrap import Button # type: ignore
|
|
||||||
|
|
||||||
Button(toolbar, text=text, command=command, bootstyle="secondary").pack(side=tk.LEFT, padx=6)
|
buttons_frame = ttk.Frame(toolbar)
|
||||||
else:
|
buttons_frame.pack(side=tk.LEFT)
|
||||||
ttk.Button(toolbar, text=text, command=command).pack(side=tk.LEFT, padx=6)
|
for text, command in buttons:
|
||||||
|
self._add_toolbar_button(buttons_frame, text, command)
|
||||||
|
|
||||||
|
status_container = ttk.Frame(toolbar)
|
||||||
|
status_container.pack(side=tk.RIGHT, expand=True, fill=tk.X)
|
||||||
|
self.status = ttk.Label(
|
||||||
|
status_container,
|
||||||
|
text="Keine Datei geladen.",
|
||||||
|
anchor="e",
|
||||||
|
foreground="#efefef",
|
||||||
|
)
|
||||||
|
self.status.pack(fill=tk.X)
|
||||||
|
self._attach_copy_menu(self.status)
|
||||||
|
self.status_default_text = self.status.cget("text")
|
||||||
|
self._status_palette = {"fg": self.status.cget("foreground")}
|
||||||
|
|
||||||
|
palette_frame = ttk.Frame(self.root)
|
||||||
|
palette_frame.pack(fill=tk.X, padx=12, pady=(0, 8))
|
||||||
|
ttk.Label(palette_frame, text="Beispielfarben:").pack(side=tk.LEFT, padx=(0, 8))
|
||||||
|
swatch_container = ttk.Frame(palette_frame)
|
||||||
|
swatch_container.pack(side=tk.LEFT)
|
||||||
|
for name, hex_code in self._preset_colours():
|
||||||
|
self._add_palette_swatch(swatch_container, name, hex_code)
|
||||||
|
|
||||||
sliders_frame = ttk.Frame(self.root)
|
sliders_frame = ttk.Frame(self.root)
|
||||||
sliders_frame.pack(fill=tk.X, padx=12, pady=4)
|
sliders_frame.pack(fill=tk.X, padx=12, pady=4)
|
||||||
|
|
@ -49,30 +68,45 @@ class UIBuilderMixin:
|
||||||
main = ttk.Frame(self.root)
|
main = ttk.Frame(self.root)
|
||||||
main.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
|
main.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
|
||||||
|
|
||||||
self.canvas_orig = tk.Canvas(main, bg="#1e1e1e", highlightthickness=0, relief="flat")
|
left_column = ttk.Frame(main)
|
||||||
self.canvas_orig.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 6))
|
left_column.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 6))
|
||||||
|
|
||||||
|
self.canvas_orig = tk.Canvas(left_column, bg="#1e1e1e", highlightthickness=0, relief="flat")
|
||||||
|
self.canvas_orig.pack(fill=tk.BOTH, expand=True)
|
||||||
self.canvas_orig.bind("<Button-1>", self.on_canvas_click)
|
self.canvas_orig.bind("<Button-1>", self.on_canvas_click)
|
||||||
self.canvas_orig.bind("<ButtonPress-3>", self._exclude_start)
|
self.canvas_orig.bind("<ButtonPress-3>", self._exclude_start)
|
||||||
self.canvas_orig.bind("<B3-Motion>", self._exclude_drag)
|
self.canvas_orig.bind("<B3-Motion>", self._exclude_drag)
|
||||||
self.canvas_orig.bind("<ButtonRelease-3>", self._exclude_end)
|
self.canvas_orig.bind("<ButtonRelease-3>", self._exclude_end)
|
||||||
|
|
||||||
self.canvas_overlay = tk.Canvas(main, bg="#1e1e1e", highlightthickness=0, relief="flat")
|
right_column = ttk.Frame(main)
|
||||||
self.canvas_overlay.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(6, 0))
|
right_column.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(6, 0))
|
||||||
|
|
||||||
status_frame = ttk.Frame(self.root)
|
self.canvas_overlay = tk.Canvas(right_column, bg="#1e1e1e", highlightthickness=0, relief="flat")
|
||||||
status_frame.pack(fill=tk.X, padx=12, pady=6)
|
self.canvas_overlay.pack(fill=tk.BOTH, expand=True)
|
||||||
self.status = ttk.Label(status_frame, text="Keine Datei geladen.")
|
|
||||||
self.status.pack(anchor="w")
|
info_frame = ttk.Frame(self.root)
|
||||||
self._attach_copy_menu(self.status)
|
info_frame.pack(fill=tk.X, padx=12, pady=(0, 12))
|
||||||
self.ratio_label = ttk.Label(status_frame, text="Purple (mit Excludes): —")
|
self.filename_label = ttk.Label(
|
||||||
self.ratio_label.pack(anchor="w")
|
info_frame,
|
||||||
self._attach_copy_menu(self.ratio_label)
|
text="—",
|
||||||
self.hint = ttk.Label(
|
foreground="#f2c744",
|
||||||
status_frame,
|
font=("Segoe UI", 10, "bold"),
|
||||||
text="Tipp: Rechtsklick + Ziehen auf dem linken Bild, um Bereiche auszuschließen. Esc beendet den Pick-Modus.",
|
anchor="center",
|
||||||
|
justify="center",
|
||||||
)
|
)
|
||||||
self.hint.pack(anchor="w", pady=(2, 0))
|
self.filename_label.pack(anchor="center")
|
||||||
self._attach_copy_menu(self.hint)
|
self._attach_copy_menu(self.filename_label)
|
||||||
|
|
||||||
|
self.ratio_label = ttk.Label(
|
||||||
|
info_frame,
|
||||||
|
text="Treffer (mit Excludes): —",
|
||||||
|
foreground="#f2c744",
|
||||||
|
font=("Segoe UI", 10, "bold"),
|
||||||
|
anchor="center",
|
||||||
|
justify="center",
|
||||||
|
)
|
||||||
|
self.ratio_label.pack(anchor="center", pady=(4, 0))
|
||||||
|
self._attach_copy_menu(self.ratio_label)
|
||||||
|
|
||||||
self.root.bind("<Escape>", self.disable_pick_mode)
|
self.root.bind("<Escape>", self.disable_pick_mode)
|
||||||
|
|
||||||
|
|
@ -114,6 +148,211 @@ class UIBuilderMixin:
|
||||||
pass
|
pass
|
||||||
self._update_job = self.root.after(self.update_delay_ms, self.update_preview)
|
self._update_job = self.root.after(self.update_delay_ms, self.update_preview)
|
||||||
|
|
||||||
|
def _preset_colours(self):
|
||||||
|
return [
|
||||||
|
("Rot", "#ff3b30"),
|
||||||
|
("Orange", "#ff9500"),
|
||||||
|
("Gelb", "#ffd60a"),
|
||||||
|
("Grün", "#34c759"),
|
||||||
|
("Türkis", "#5ac8fa"),
|
||||||
|
("Blau", "#0a84ff"),
|
||||||
|
("Violett", "#af52de"),
|
||||||
|
("Magenta", "#ff2d55"),
|
||||||
|
("Weiß", "#ffffff"),
|
||||||
|
("Grau", "#8e8e93"),
|
||||||
|
("Schwarz", "#000000"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _add_palette_swatch(self, parent, name: str, hex_code: str) -> None:
|
||||||
|
swatch = tk.Canvas(
|
||||||
|
parent,
|
||||||
|
width=26,
|
||||||
|
height=26,
|
||||||
|
highlightthickness=1,
|
||||||
|
highlightbackground="#b1b1b6",
|
||||||
|
background="#ffffff",
|
||||||
|
bd=0,
|
||||||
|
relief="flat",
|
||||||
|
takefocus=1,
|
||||||
|
cursor="hand2",
|
||||||
|
)
|
||||||
|
swatch.pack(side=tk.LEFT, padx=4, pady=2)
|
||||||
|
swatch.create_rectangle(3, 3, 23, 23, outline="#4a4a4a", fill=hex_code)
|
||||||
|
|
||||||
|
def trigger(_event=None, colour=hex_code, label=name):
|
||||||
|
self.apply_sample_colour(colour, label)
|
||||||
|
|
||||||
|
swatch.bind("<Button-1>", trigger)
|
||||||
|
swatch.bind("<space>", trigger)
|
||||||
|
swatch.bind("<Return>", trigger)
|
||||||
|
swatch.bind("<Enter>", lambda _e: swatch.configure(highlightbackground="#71717a"))
|
||||||
|
swatch.bind("<Leave>", lambda _e: swatch.configure(highlightbackground="#b1b1b6"))
|
||||||
|
|
||||||
|
def _add_toolbar_button(self, parent, text: str, command) -> None:
|
||||||
|
font = tkfont.Font(root=self.root, family="Segoe UI", size=10, weight="bold")
|
||||||
|
padding_x = 18
|
||||||
|
width = font.measure(text) + padding_x * 2
|
||||||
|
height = 32
|
||||||
|
radius = 12
|
||||||
|
bg = self.root.cget("bg") if hasattr(self.root, "cget") else "#f2f2f7"
|
||||||
|
canvas = tk.Canvas(
|
||||||
|
parent,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
bd=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
bg=bg,
|
||||||
|
relief="flat",
|
||||||
|
cursor="hand2",
|
||||||
|
takefocus=1,
|
||||||
|
)
|
||||||
|
canvas.pack(side=tk.LEFT, padx=6)
|
||||||
|
|
||||||
|
palette = self._toolbar_palette()
|
||||||
|
rect_id = self._create_round_rect(
|
||||||
|
canvas,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
width - 1,
|
||||||
|
height - 1,
|
||||||
|
radius,
|
||||||
|
fill=palette["normal"],
|
||||||
|
outline=palette["outline"],
|
||||||
|
width=1,
|
||||||
|
)
|
||||||
|
text_id = canvas.create_text(
|
||||||
|
width / 2,
|
||||||
|
height / 2,
|
||||||
|
text=text,
|
||||||
|
font=font,
|
||||||
|
fill=palette["text"],
|
||||||
|
)
|
||||||
|
|
||||||
|
button_data = {
|
||||||
|
"canvas": canvas,
|
||||||
|
"rect": rect_id,
|
||||||
|
"text_id": text_id,
|
||||||
|
"command": command,
|
||||||
|
"palette": palette.copy(),
|
||||||
|
"dimensions": (width, height, radius),
|
||||||
|
}
|
||||||
|
self._toolbar_buttons.append(button_data)
|
||||||
|
|
||||||
|
def set_fill(state: str) -> None:
|
||||||
|
pal: dict[str, str] = button_data["palette"] # type: ignore[index]
|
||||||
|
canvas.itemconfigure(rect_id, fill=pal[state]) # type: ignore[index]
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
command()
|
||||||
|
|
||||||
|
def on_press(_event=None):
|
||||||
|
set_fill("active")
|
||||||
|
|
||||||
|
def on_release(event=None):
|
||||||
|
if event is not None and (
|
||||||
|
event.x < 0 or event.y < 0 or event.x > width or event.y > height
|
||||||
|
):
|
||||||
|
set_fill("normal")
|
||||||
|
return
|
||||||
|
set_fill("hover")
|
||||||
|
self.root.after_idle(execute)
|
||||||
|
|
||||||
|
def on_enter(_event):
|
||||||
|
set_fill("hover")
|
||||||
|
|
||||||
|
def on_leave(_event):
|
||||||
|
set_fill("normal")
|
||||||
|
|
||||||
|
def on_focus_in(_event):
|
||||||
|
pal: dict[str, str] = button_data["palette"] # type: ignore[index]
|
||||||
|
canvas.itemconfigure(rect_id, outline=pal["outline_focus"]) # type: ignore[index]
|
||||||
|
|
||||||
|
def on_focus_out(_event):
|
||||||
|
pal: dict[str, str] = button_data["palette"] # type: ignore[index]
|
||||||
|
canvas.itemconfigure(rect_id, outline=pal["outline"]) # type: ignore[index]
|
||||||
|
|
||||||
|
def invoke_keyboard(_event=None):
|
||||||
|
set_fill("active")
|
||||||
|
canvas.after(120, lambda: set_fill("hover"))
|
||||||
|
self.root.after_idle(execute)
|
||||||
|
|
||||||
|
canvas.bind("<ButtonPress-1>", on_press)
|
||||||
|
canvas.bind("<ButtonRelease-1>", on_release)
|
||||||
|
canvas.bind("<Enter>", on_enter)
|
||||||
|
canvas.bind("<Leave>", on_leave)
|
||||||
|
canvas.bind("<FocusIn>", on_focus_in)
|
||||||
|
canvas.bind("<FocusOut>", on_focus_out)
|
||||||
|
canvas.bind("<space>", invoke_keyboard)
|
||||||
|
canvas.bind("<Return>", invoke_keyboard)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_round_rect(canvas: tk.Canvas, x1, y1, x2, y2, radius, **kwargs):
|
||||||
|
points = [
|
||||||
|
x1 + radius,
|
||||||
|
y1,
|
||||||
|
x2 - radius,
|
||||||
|
y1,
|
||||||
|
x2,
|
||||||
|
y1,
|
||||||
|
x2,
|
||||||
|
y1 + radius,
|
||||||
|
x2,
|
||||||
|
y2 - radius,
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
x2 - radius,
|
||||||
|
y2,
|
||||||
|
x1 + radius,
|
||||||
|
y2,
|
||||||
|
x1,
|
||||||
|
y2,
|
||||||
|
x1,
|
||||||
|
y2 - radius,
|
||||||
|
x1,
|
||||||
|
y1 + radius,
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
]
|
||||||
|
return canvas.create_polygon(points, smooth=True, splinesteps=24, **kwargs)
|
||||||
|
|
||||||
|
def _toolbar_palette(self) -> dict[str, str]:
|
||||||
|
is_dark = getattr(self, "theme", "light") == "dark"
|
||||||
|
if is_dark:
|
||||||
|
return {
|
||||||
|
"normal": "#2f2f35",
|
||||||
|
"hover": "#3a3a40",
|
||||||
|
"active": "#1f1f25",
|
||||||
|
"outline": "#4d4d50",
|
||||||
|
"outline_focus": "#7c7c88",
|
||||||
|
"text": "#f1f1f5",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"normal": "#ffffff",
|
||||||
|
"hover": "#ededf4",
|
||||||
|
"active": "#dcdce6",
|
||||||
|
"outline": "#d0d0d8",
|
||||||
|
"outline_focus": "#a9a9b2",
|
||||||
|
"text": "#1f1f1f",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _refresh_toolbar_buttons_theme(self) -> None:
|
||||||
|
if not getattr(self, "_toolbar_buttons", None):
|
||||||
|
return
|
||||||
|
bg = self.root.cget("bg") if hasattr(self.root, "cget") else "#f2f2f7"
|
||||||
|
palette = self._toolbar_palette()
|
||||||
|
for data in self._toolbar_buttons:
|
||||||
|
canvas = data["canvas"] # type: ignore[index]
|
||||||
|
rect_id = data["rect"] # type: ignore[index]
|
||||||
|
text_id = data["text_id"] # type: ignore[index]
|
||||||
|
data["palette"] = palette.copy()
|
||||||
|
canvas.configure(bg=bg)
|
||||||
|
canvas.itemconfigure(rect_id, fill=palette["normal"], outline=palette["outline"])
|
||||||
|
canvas.itemconfigure(text_id, fill=palette["text"])
|
||||||
|
|
||||||
|
def _refresh_status_palette(self, fg: str) -> None:
|
||||||
|
self.status.configure(foreground=fg)
|
||||||
|
self._status_palette["fg"] = fg
|
||||||
|
|
||||||
def _init_copy_menu(self):
|
def _init_copy_menu(self):
|
||||||
self._copy_target = None
|
self._copy_target = None
|
||||||
self.copy_menu = tk.Menu(self.root, tearoff=0)
|
self.copy_menu = tk.Menu(self.root, tearoff=0)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ except ModuleNotFoundError: # pragma: no cover - fallback for <3.11
|
||||||
if "tomllib" not in globals():
|
if "tomllib" not in globals():
|
||||||
tomllib = None # type: ignore[assignment]
|
tomllib = None # type: ignore[assignment]
|
||||||
|
|
||||||
PREVIEW_MAX_SIZE = (1200, 800)
|
PREVIEW_MAX_SIZE = (900, 660)
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||||
IMAGES_DIR = BASE_DIR / "images"
|
IMAGES_DIR = BASE_DIR / "images"
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,12 @@ class ImageProcessingMixin:
|
||||||
self.orig_img = image
|
self.orig_img = image
|
||||||
self.prepare_preview()
|
self.prepare_preview()
|
||||||
self.update_preview()
|
self.update_preview()
|
||||||
self.status.config(
|
dimensions = f"{self.orig_img.width}x{self.orig_img.height}"
|
||||||
text=f"Geladen: {self.image_path.name} — {self.orig_img.width}x{self.orig_img.height}"
|
status_text = f"Geladen: {self.image_path.name} — {dimensions}"
|
||||||
)
|
self.status.config(text=status_text)
|
||||||
|
self.status_default_text = status_text
|
||||||
|
if hasattr(self, "filename_label"):
|
||||||
|
self.filename_label.config(text=f"{self.image_path.name} — {dimensions}")
|
||||||
|
|
||||||
def save_overlay(self) -> None:
|
def save_overlay(self) -> None:
|
||||||
if self.orig_img is None:
|
if self.orig_img is None:
|
||||||
|
|
@ -57,7 +60,7 @@ class ImageProcessingMixin:
|
||||||
self.exclude_rects,
|
self.exclude_rects,
|
||||||
alpha=int(self.alpha.get()),
|
alpha=int(self.alpha.get()),
|
||||||
scale_from_preview=self.preview_img.size,
|
scale_from_preview=self.preview_img.size,
|
||||||
is_purple_fn=self.is_purple_pixel,
|
is_match_fn=self.matches_target_color,
|
||||||
)
|
)
|
||||||
merged = Image.alpha_composite(self.orig_img.convert("RGBA"), overlay)
|
merged = Image.alpha_composite(self.orig_img.convert("RGBA"), overlay)
|
||||||
|
|
||||||
|
|
@ -100,18 +103,18 @@ class ImageProcessingMixin:
|
||||||
|
|
||||||
stats = self.compute_stats_preview()
|
stats = self.compute_stats_preview()
|
||||||
if stats:
|
if stats:
|
||||||
p_all, t_all = stats["all"]
|
matches_all, total_all = stats["all"]
|
||||||
p_keep, t_keep = stats["keep"]
|
matches_keep, total_keep = stats["keep"]
|
||||||
p_ex, t_ex = stats["excl"]
|
matches_ex, total_ex = stats["excl"]
|
||||||
r_with = (p_keep / t_keep * 100) if t_keep else 0.0
|
r_with = (matches_keep / total_keep * 100) if total_keep else 0.0
|
||||||
r_no = (p_all / t_all * 100) if t_all else 0.0
|
r_no = (matches_all / total_all * 100) if total_all else 0.0
|
||||||
excl_share = (t_ex / t_all * 100) if t_all else 0.0
|
excl_share = (total_ex / total_all * 100) if total_all else 0.0
|
||||||
excl_purp = (p_ex / t_ex * 100) if t_ex else 0.0
|
excl_match = (matches_ex / total_ex * 100) if total_ex else 0.0
|
||||||
self.ratio_label.config(
|
self.ratio_label.config(
|
||||||
text=(
|
text=(
|
||||||
f"Purple (mit Excludes): {r_with:.2f}% | "
|
f"Treffer (mit Excludes): {r_with:.2f}% | "
|
||||||
f"Purple (ohne Excludes): {r_no:.2f}% | "
|
f"Treffer (ohne Excludes): {r_no:.2f}% | "
|
||||||
f"Excluded: {excl_share:.2f}% vom Bild, davon {excl_purp:.2f}% purple"
|
f"Excluded: {excl_share:.2f}% vom Bild, davon {excl_match:.2f}% Treffer"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -135,7 +138,7 @@ class ImageProcessingMixin:
|
||||||
r, g, b, a = pixels[x, y]
|
r, g, b, a = pixels[x, y]
|
||||||
if a == 0:
|
if a == 0:
|
||||||
continue
|
continue
|
||||||
if self.is_purple_pixel(r, g, b):
|
if self.matches_target_color(r, g, b):
|
||||||
draw.point((x, y), fill=(255, 0, 0, alpha))
|
draw.point((x, y), fill=(255, 0, 0, alpha))
|
||||||
merged = Image.alpha_composite(base, overlay)
|
merged = Image.alpha_composite(base, overlay)
|
||||||
for (x0, y0, x1, y1) in self.exclude_rects:
|
for (x0, y0, x1, y1) in self.exclude_rects:
|
||||||
|
|
@ -147,9 +150,9 @@ class ImageProcessingMixin:
|
||||||
return None
|
return None
|
||||||
px = self.preview_img.convert("RGBA").load()
|
px = self.preview_img.convert("RGBA").load()
|
||||||
width, height = self.preview_img.size
|
width, height = self.preview_img.size
|
||||||
purple_all = total_all = 0
|
matches_all = total_all = 0
|
||||||
purple_keep = total_keep = 0
|
matches_keep = total_keep = 0
|
||||||
purple_excl = total_excl = 0
|
matches_excl = total_excl = 0
|
||||||
for y in range(height):
|
for y in range(height):
|
||||||
for x in range(width):
|
for x in range(width):
|
||||||
r, g, b, a = px[x, y]
|
r, g, b, a = px[x, y]
|
||||||
|
|
@ -157,19 +160,23 @@ class ImageProcessingMixin:
|
||||||
continue
|
continue
|
||||||
is_excluded = self._is_excluded(x, y)
|
is_excluded = self._is_excluded(x, y)
|
||||||
total_all += 1
|
total_all += 1
|
||||||
if self.is_purple_pixel(r, g, b):
|
if self.matches_target_color(r, g, b):
|
||||||
purple_all += 1
|
matches_all += 1
|
||||||
if not is_excluded:
|
if not is_excluded:
|
||||||
total_keep += 1
|
total_keep += 1
|
||||||
if self.is_purple_pixel(r, g, b):
|
if self.matches_target_color(r, g, b):
|
||||||
purple_keep += 1
|
matches_keep += 1
|
||||||
else:
|
else:
|
||||||
total_excl += 1
|
total_excl += 1
|
||||||
if self.is_purple_pixel(r, g, b):
|
if self.matches_target_color(r, g, b):
|
||||||
purple_excl += 1
|
matches_excl += 1
|
||||||
return {"all": (purple_all, total_all), "keep": (purple_keep, total_keep), "excl": (purple_excl, total_excl)}
|
return {
|
||||||
|
"all": (matches_all, total_all),
|
||||||
|
"keep": (matches_keep, total_keep),
|
||||||
|
"excl": (matches_excl, total_excl),
|
||||||
|
}
|
||||||
|
|
||||||
def is_purple_pixel(self, r, g, b) -> bool:
|
def matches_target_color(self, r, g, b) -> bool:
|
||||||
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
|
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
|
||||||
hue = h * 360.0
|
hue = h * 360.0
|
||||||
hmin = float(self.hue_min.get())
|
hmin = float(self.hue_min.get())
|
||||||
|
|
@ -209,7 +216,7 @@ class ImageProcessingMixin:
|
||||||
*,
|
*,
|
||||||
alpha: int,
|
alpha: int,
|
||||||
scale_from_preview: Tuple[int, int],
|
scale_from_preview: Tuple[int, int],
|
||||||
is_purple_fn,
|
is_match_fn,
|
||||||
) -> Image.Image:
|
) -> Image.Image:
|
||||||
overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||||
draw = ImageDraw.Draw(overlay)
|
draw = ImageDraw.Draw(overlay)
|
||||||
|
|
@ -223,7 +230,7 @@ class ImageProcessingMixin:
|
||||||
r, g, b, a = pixels[x, y]
|
r, g, b, a = pixels[x, y]
|
||||||
if a == 0:
|
if a == 0:
|
||||||
continue
|
continue
|
||||||
if is_purple_fn(r, g, b):
|
if is_match_fn(r, g, b):
|
||||||
draw.point((x, y), fill=(255, 0, 0, alpha))
|
draw.point((x, y), fill=(255, 0, 0, alpha))
|
||||||
return overlay
|
return overlay
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ class ResetMixin:
|
||||||
self.val_max.set(self.DEFAULTS["val_max"])
|
self.val_max.set(self.DEFAULTS["val_max"])
|
||||||
self.alpha.set(self.DEFAULTS["alpha"])
|
self.alpha.set(self.DEFAULTS["alpha"])
|
||||||
self.update_preview()
|
self.update_preview()
|
||||||
|
default_text = getattr(self, "status_default_text", "Standardwerte aktiv.")
|
||||||
|
if hasattr(self, "status"):
|
||||||
|
self.status.config(text=default_text)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["ResetMixin"]
|
__all__ = ["ResetMixin"]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue