diff --git a/README.md b/README.md index 649081f..447613e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ -# ColorCalc +# ICRS (Interactive Color Range Analyzer) -ColorCalc is a small Tkinter tool for analysing colour ranges in images. You load a picture, pick or click a reference colour, adjust hue/saturation/value sliders, and the app marks matching pixels while showing quick stats. +ICRS is a small Tkinter tool for analysing colour ranges in images. You load a picture, pick or click a reference colour, adjust hue/saturation/value sliders, and the app marks matching pixels while showing quick stats. ## Features - Two synced previews (original + overlay) - Hue/Sat/Value sliders with presets and image colour picker - Exclusion rectangles to ignore regions - Theme toggle (light/dark) with rounded toolbar buttons +- Folder support with previous/next navigation - Quick overlay export (PNG) and optional defaults via `config.toml` ## Requirements @@ -16,12 +17,12 @@ ColorCalc is a small Tkinter tool for analysing colour ranges in images. You loa ## Setup with uv ```bash -git clone https://github.com//ColorCalc.git -cd ColorCalc +git clone https://github.com//ICRS.git +cd ICRS uv venv source .venv/bin/activate # Windows: .venv\Scripts\activate uv pip sync # installs Pillow + optional deps from pyproject -uv run colorcalc # launches the GUI +uv run icrs # launches the GUI ``` To include the optional ttkbootstrap theme pack: ```bash @@ -29,10 +30,11 @@ uv pip install '.[ui]' ``` ## Workflow -1. Load an image (`📂`). +1. Load an image (`📂`) or a folder (`📁`). 2. Pick a colour (`🎨` dialog, `🖱️` image click, or preset swatch). 3. Fine‑tune sliders; watch the overlay update on the right. -4. Draw exclusions with right drag; reset or save when ready. +4. Move through folder images with `⬅️` / `➡️`. +5. Draw exclusions with right drag; reset or save when ready. ## Config Defaults Optional `config.toml`: diff --git a/app/__init__.py b/app/__init__.py index 20b175c..dd361ae 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,5 @@ """Application package.""" -from .app import ColorCalcApp, start_app +from .app import ICRSApp, start_app -__all__ = ["ColorCalcApp", "start_app"] +__all__ = ["ICRSApp", "start_app"] diff --git a/app/app.py b/app/app.py index 1e35b60..97ea947 100644 --- a/app/app.py +++ b/app/app.py @@ -8,7 +8,7 @@ from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin from .logic import DEFAULTS, ImageProcessingMixin, ResetMixin -class ColorCalcApp( +class ICRSApp( ThemeMixin, UIBuilderMixin, ImageProcessingMixin, @@ -20,7 +20,7 @@ class ColorCalcApp( def __init__(self, root: tk.Tk): self.root = root - self.root.title("ColorCalc — Bild + Overlay") + self.root.title("ICRS — Interactive Color Range Analyzer") try: self.root.state("zoomed") except Exception: @@ -56,6 +56,8 @@ class ColorCalcApp( self.preview_img = None self.preview_tk = None self.overlay_tk = None + self.image_paths = [] + self.current_image_index = -1 # Build UI self.setup_ui() @@ -66,8 +68,8 @@ class ColorCalcApp( def start_app() -> None: """Entry point used by the CLI script.""" root = tk.Tk() - app = ColorCalcApp(root) + app = ICRSApp(root) root.mainloop() -__all__ = ["ColorCalcApp", "start_app"] +__all__ = ["ICRSApp", "start_app"] diff --git a/app/gui/ui.py b/app/gui/ui.py index 163d68a..566b5b5 100644 --- a/app/gui/ui.py +++ b/app/gui/ui.py @@ -15,6 +15,9 @@ class UIBuilderMixin: toolbar.pack(fill=tk.X, padx=12, pady=8) buttons = [ ("📂 Bild laden", self.load_image), + ("📁 Ordner laden", self.load_folder), + ("⬅️ Bild zurück", self.show_previous_image), + ("➡️ Nächstes Bild", self.show_next_image), ("🎨 Farbe wählen", self.choose_color), ("🖱️Farbe aus Bild klicken", self.enable_pick_mode), ("💾 Overlay speichern", self.save_overlay), diff --git a/app/logic/__init__.py b/app/logic/__init__.py index ff4a440..55be9fd 100644 --- a/app/logic/__init__.py +++ b/app/logic/__init__.py @@ -1,6 +1,6 @@ """Logic utilities and mixins for processing and configuration.""" -from .constants import BASE_DIR, DEFAULTS, IMAGES_DIR, PREVIEW_MAX_SIZE +from .constants import BASE_DIR, DEFAULTS, IMAGES_DIR, PREVIEW_MAX_SIZE, SUPPORTED_IMAGE_EXTENSIONS from .image_processing import ImageProcessingMixin from .reset import ResetMixin @@ -9,6 +9,7 @@ __all__ = [ "DEFAULTS", "IMAGES_DIR", "PREVIEW_MAX_SIZE", + "SUPPORTED_IMAGE_EXTENSIONS", "ImageProcessingMixin", "ResetMixin", ] diff --git a/app/logic/constants.py b/app/logic/constants.py index 76c4b47..eec0ff2 100644 --- a/app/logic/constants.py +++ b/app/logic/constants.py @@ -29,6 +29,8 @@ _DEFAULTS_BASE = { "alpha": 120, } +SUPPORTED_IMAGE_EXTENSIONS = (".webp", ".png", ".jpg", ".jpeg", ".bmp") + _DEFAULT_TYPES: dict[str, Callable[[Any], Any]] = { "hue_min": float, "hue_max": float, diff --git a/app/logic/image_processing.py b/app/logic/image_processing.py index c15a906..1674b6e 100644 --- a/app/logic/image_processing.py +++ b/app/logic/image_processing.py @@ -4,13 +4,13 @@ from __future__ import annotations import colorsys from pathlib import Path -from typing import Iterable, Tuple +from typing import Iterable, Sequence, Tuple from tkinter import filedialog, messagebox from PIL import Image, ImageDraw, ImageTk -from .constants import IMAGES_DIR, PREVIEW_MAX_SIZE +from .constants import IMAGES_DIR, PREVIEW_MAX_SIZE, SUPPORTED_IMAGE_EXTENSIONS class ImageProcessingMixin: @@ -22,6 +22,9 @@ class ImageProcessingMixin: preview_tk: ImageTk.PhotoImage | None overlay_tk: ImageTk.PhotoImage | None + image_paths: list[Path] + current_image_index: int + def load_image(self) -> None: default_dir = IMAGES_DIR if IMAGES_DIR.exists() else Path.cwd() path = filedialog.askopenfilename( @@ -31,21 +34,88 @@ class ImageProcessingMixin: ) if not path: return - self.image_path = Path(path) + self._set_image_collection([Path(path)], 0) + + def load_folder(self) -> None: + default_dir = IMAGES_DIR if IMAGES_DIR.exists() else Path.cwd() + directory = filedialog.askdirectory( + title="Ordner mit Bildern wählen", + initialdir=str(default_dir), + ) + if not directory: + return + folder = Path(directory) + if not folder.exists(): + messagebox.showerror("Fehler", "Der Ordner wurde nicht gefunden.") + return + image_files = sorted( + ( + path + for path in folder.iterdir() + if path.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS and path.is_file() + ), + key=lambda item: item.name.lower(), + ) + if not image_files: + messagebox.showinfo("Info", "Keine unterstützten Bilder im Ordner gefunden.") + return + self._set_image_collection(image_files, 0) + + def show_next_image(self, event=None) -> None: + if not getattr(self, "image_paths", None): + return + next_index = getattr(self, "current_image_index", -1) + 1 + if next_index < len(self.image_paths): + self._display_image_by_index(next_index) + + def show_previous_image(self, event=None) -> None: + if not getattr(self, "image_paths", None): + return + prev_index = getattr(self, "current_image_index", -1) - 1 + if prev_index >= 0: + self._display_image_by_index(prev_index) + + def _set_image_collection(self, paths: Sequence[Path], start_index: int) -> None: + self.image_paths = list(paths) + if not self.image_paths: + return + self.current_image_index = -1 + self._display_image_by_index(max(0, start_index)) + + def _display_image_by_index(self, index: int) -> None: + if not self.image_paths: + return + if index < 0 or index >= len(self.image_paths): + return + path = self.image_paths[index] + if not path.exists(): + messagebox.showerror("Fehler", f"Datei nicht gefunden: {path}") + return try: image = Image.open(path).convert("RGBA") except Exception as exc: messagebox.showerror("Fehler", f"Bild konnte nicht geladen werden: {exc}") return + + self.image_path = path self.orig_img = image + self.exclude_rects = [] + self._rubber_start = None + self._rubber_id = None + self.pick_mode = False + self.prepare_preview() self.update_preview() + dimensions = f"{self.orig_img.width}x{self.orig_img.height}" - status_text = f"Geladen: {self.image_path.name} — {dimensions}" + suffix = f" [{index + 1}/{len(self.image_paths)}]" if len(self.image_paths) > 1 else "" + status_text = f"Geladen: {path.name} — {dimensions}{suffix}" 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}") + self.filename_label.config(text=f"{path.name} — {dimensions}{suffix}") + + self.current_image_index = index def save_overlay(self) -> None: if self.orig_img is None: diff --git a/pyproject.toml b/pyproject.toml index 0053de0..19a88f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] -name = "colorcalc" +name = "icrs" version = "0.1.0" -description = "Interactive colour range analyser for Tkinter" +description = "Interactive Color Range Analyzer (ICRS) for Tkinter" readme = "README.md" -authors = [{ name = "ColorCalc contributors" }] +authors = [{ name = "ICRS contributors" }] license = { text = "MIT" } requires-python = ">=3.10" dependencies = [ @@ -14,7 +14,7 @@ dependencies = [ ui = ["ttkbootstrap>=1.10.0"] [project.scripts] -colorcalc = "app.app:start_app" +icrs = "app.app:start_app" [tool.uv] package = false