Compare commits

...

13 Commits

8 changed files with 1367 additions and 79 deletions

View File

@ -1,58 +1,51 @@
<div style="display:flex; gap:16px; align-items:center;">
<img src="app/assets/logo.png" alt="ICRA" width="140"/>
<div>
<strong>Interactive Color Range Analyzer</strong> is a Tkinter-based desktop tool for highlighting customised colour ranges in images.<br/>
Load a single photo or an entire folder, fine-tune hue/saturation/value sliders, and export overlays complete with quick statistics.
<strong>Interactive Color Range Analyzer</strong> is being reimagined with a <em>PySide6</em> user interface.<br/>
This branch focuses on building a native desktop shell with modern window controls before porting the colour-analysis features.
</div>
</div>
## Features
- Two synced previews (original + overlay)
- Hue/Sat/Value sliders with presets and image colour picker
- Exclusion rectangles or freehand polygons that persist while browsing
- Theme toggle (light/dark) with rounded toolbar buttons and accent-aware highlights
- Folder support with wrap-around previous/next navigation
- Quick overlay export (PNG) with configurable defaults and language settings via `config.toml`
## Current prototype
- Custom frameless window with minimise / maximise / close controls that hook into Windows natively
- Dark themed layout and basic image preview powered by Qt
- “Open image” workflow that displays the selected asset scaled to the viewport
> ⚠️ Legacy Tk features (sliders, exclusions, folder navigation, stats) are not wired up yet. The goal here is to validate the PySide6 shell first.
## Requirements
- Python 3.11+ (3.10 works with `tomli`)
- Python 3.11+
- [uv](https://github.com/astral-sh/uv) for dependency management
- Tkinter (install separately on some Linux distros)
- Windows 10/11 recommended (PySide6 build included; Linux/macOS should work but are untested in this branch)
## Setup with uv (Windows PowerShell)
## Setup with uv (PowerShell example)
```bash
git clone https://git.lukasmahler.de/lm/ICRA.git
cd ICRA
uv venv
source .venv/Scripts/activate
source .venv/Scripts/activate # macOS/Linux: source .venv/bin/activate
uv pip install .
uv run icra
```
The launcher copies Tcl/Tk resources into the virtualenv on first run, so no manual environment tweaks are needed.
On macOS/Linux activate with `source .venv/bin/activate` instead.
## Workflow
1. Load an image (`🖼`) or a folder (`📂`).
2. Pick a colour (`🎨` dialog, `🖱️` image click, or preset swatch).
3. Finetune sliders; watch the overlay update on the right.
4. Toggle freehand mode (`△`) or stick with rectangles and mark areas to exclude (right mouse drag).
5. Move through folder images with `⬅️` / `➡️`; exclusions stay put unless you opt into automatic resets.
6. Save an overlay (`💾`) when ready.
The app launches directly as a PySide6 GUI—no browser or local web server involved. Use the “Open Image…” button to load a file and test resizing/snap behaviour.
## Project Layout
## Roadmap (branch scope)
1. Port hue/saturation/value controls to Qt widgets
2. Re-implement exclusion drawing using QPainter overlays
3. Integrate existing image-processing logic (`app/logic`) with the new UI
## Project layout
```
app/
app.py # main app assembly
gui/ # UI, theme, picker mixins
logic/ # image ops, defaults, config helpers
lang/ # localisation TOML files
config.toml # optional defaults
main.py # entry point
assets/ # Shared branding
gui/, logic/ # Legacy Tk code kept for reference
qt/ # New PySide6 implementation (main_window, app bootstrap)
config.toml # Historical defaults (unused in the prototype)
main.py # Entry point -> PySide6 launcher
```
## Localisation
- English and German translations ship in `app/lang`. Set the desired language via the top-level `language` key in `config.toml`.
## Development
- Quick check: `uv run python -m compileall app main.py`
- Contributions welcome; include screenshots for UI tweaks.
## Development notes
- Quick syntax check: `uv run python -m compileall app main.py`
- Uploaded images are not persisted; the preview uses Qt pixmaps only.
- Contributions welcome—please target this branch with PySide6-specific improvements.

View File

@ -1,5 +1,15 @@
"""Application package."""
"""Application package exposing the PySide6 entry points."""
from .app import ICRAApp, start_app
from __future__ import annotations
__all__ = ["ICRAApp", "start_app"]
try: # Legacy Tk support remains optional
from .app import ICRAApp, start_app as start_tk_app # type: ignore[attr-defined]
except Exception: # pragma: no cover
ICRAApp = None # type: ignore[assignment]
start_tk_app = None # type: ignore[assignment]
from .qt import create_application as create_qt_app, run as run_qt_app
start_app = run_qt_app
__all__ = ["ICRAApp", "start_tk_app", "create_qt_app", "run_qt_app", "start_app"]

View File

@ -1,49 +1,12 @@
"""Launcher ensuring Tcl/Tk resources are available before starting ICRA."""
"""Launcher for the PySide6 ICRA application."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
def _copy_tcl_runtime(venv_root: Path) -> tuple[Path, Path] | None:
"""Copy Tcl/Tk directories from the base interpreter into the venv if needed."""
base_prefix = Path(getattr(sys, "base_prefix", sys.prefix))
base_tcl_dir = base_prefix / "tcl"
if not base_tcl_dir.exists():
return None
tcl_src = base_tcl_dir / "tcl8.6"
tk_src = base_tcl_dir / "tk8.6"
if not tcl_src.exists() or not tk_src.exists():
return None
target_root = venv_root / "tcl"
tcl_dest = target_root / "tcl8.6"
tk_dest = target_root / "tk8.6"
if not tcl_dest.exists():
shutil.copytree(tcl_src, tcl_dest, dirs_exist_ok=True)
if not tk_dest.exists():
shutil.copytree(tk_src, tk_dest, dirs_exist_ok=True)
return tcl_dest, tk_dest
from .qt import run
def main() -> int:
venv_root = Path(sys.prefix)
tcl_paths = _copy_tcl_runtime(venv_root)
env = os.environ.copy()
if tcl_paths:
env.setdefault("TCL_LIBRARY", str(tcl_paths[0]))
env.setdefault("TK_LIBRARY", str(tcl_paths[1]))
return subprocess.call([sys.executable, "main.py"], env=env)
return run()
if __name__ == "__main__":

7
app/qt/__init__.py Normal file
View File

@ -0,0 +1,7 @@
"""PySide6 application entry points."""
from __future__ import annotations
from .app import create_application, run
__all__ = ["create_application", "run"]

61
app/qt/app.py Normal file
View File

@ -0,0 +1,61 @@
"""Application bootstrap for the PySide6 GUI."""
from __future__ import annotations
import sys
from pathlib import Path
from PySide6 import QtGui, QtWidgets
from app.logic import DEFAULTS, LANGUAGE, RESET_EXCLUSIONS_ON_IMAGE_CHANGE
from .main_window import MainWindow
def create_application() -> QtWidgets.QApplication:
"""Create the Qt application instance with customised styling."""
app = QtWidgets.QApplication.instance()
if app is None:
app = QtWidgets.QApplication(sys.argv)
app.setOrganizationName("ICRA")
app.setApplicationName("Interactive Color Range Analyzer")
app.setApplicationDisplayName("ICRA")
palette = QtGui.QPalette()
palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#111216"))
palette.setColor(QtGui.QPalette.WindowText, QtGui.QColor("#f5f5f5"))
palette.setColor(QtGui.QPalette.Base, QtGui.QColor("#1a1b21"))
palette.setColor(QtGui.QPalette.AlternateBase, QtGui.QColor("#20212a"))
palette.setColor(QtGui.QPalette.Button, QtGui.QColor("#20212a"))
palette.setColor(QtGui.QPalette.ButtonText, QtGui.QColor("#f5f5f5"))
palette.setColor(QtGui.QPalette.Text, QtGui.QColor("#f5f5f5"))
palette.setColor(QtGui.QPalette.Highlight, QtGui.QColor("#5168ff"))
palette.setColor(QtGui.QPalette.HighlightedText, QtGui.QColor("#ffffff"))
app.setPalette(palette)
font = QtGui.QFont("Segoe UI", 10)
app.setFont(font)
logo_path = Path(__file__).resolve().parents[1] / "assets" / "logo.png"
if logo_path.exists():
app.setWindowIcon(QtGui.QIcon(str(logo_path)))
return app
def run() -> int:
"""Run the PySide6 GUI."""
app = create_application()
window = MainWindow(
language=LANGUAGE,
defaults=DEFAULTS.copy(),
reset_exclusions=RESET_EXCLUSIONS_ON_IMAGE_CHANGE,
)
primary_screen = app.primaryScreen()
if primary_screen is not None:
geometry = primary_screen.availableGeometry()
window.setGeometry(geometry)
window.showMaximized()
else:
window.showMaximized()
return app.exec()

243
app/qt/image_processor.py Normal file
View File

@ -0,0 +1,243 @@
"""Minimal image processing pipeline adapted for the Qt frontend."""
from __future__ import annotations
import colorsys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, Tuple
from PIL import Image, ImageDraw
from PySide6 import QtGui
from app.logic import PREVIEW_MAX_SIZE
@dataclass
class Stats:
matches_all: int = 0
total_all: int = 0
matches_keep: int = 0
total_keep: int = 0
matches_excl: int = 0
total_excl: int = 0
def summary(self, translate) -> str:
if self.total_all == 0:
return translate("stats.placeholder")
with_pct = (self.matches_keep / self.total_keep * 100) if self.total_keep else 0.0
without_pct = (self.matches_all / self.total_all * 100) if self.total_all else 0.0
excluded_pct = (self.total_excl / self.total_all * 100) if self.total_all else 0.0
excluded_match_pct = (self.matches_excl / self.total_excl * 100) if self.total_excl else 0.0
return translate(
"stats.summary",
with_pct=with_pct,
without_pct=without_pct,
excluded_pct=excluded_pct,
excluded_match_pct=excluded_match_pct,
)
class QtImageProcessor:
"""Process images and build overlays for the Qt UI."""
def __init__(self) -> None:
self.orig_img: Image.Image | None = None
self.preview_img: Image.Image | None = None
self.overlay_img: Image.Image | None = None
self.preview_paths: list[Path] = []
self.current_index: int = -1
self.stats = Stats()
self.defaults: Dict[str, int] = {
"hue_min": 0,
"hue_max": 360,
"sat_min": 25,
"val_min": 15,
"val_max": 100,
"alpha": 120,
}
self.hue_min = self.defaults["hue_min"]
self.hue_max = self.defaults["hue_max"]
self.sat_min = self.defaults["sat_min"]
self.val_min = self.defaults["val_min"]
self.val_max = self.defaults["val_max"]
self.alpha = self.defaults["alpha"]
self.exclude_shapes: list[dict[str, object]] = []
self.reset_exclusions_on_switch: bool = False
def set_defaults(self, defaults: dict) -> None:
for key in self.defaults:
if key in defaults:
self.defaults[key] = int(defaults[key])
for key, value in self.defaults.items():
setattr(self, key, value)
self._rebuild_overlay()
# thresholds -------------------------------------------------------------
def set_threshold(self, key: str, value: int) -> None:
setattr(self, key, value)
if self.preview_img is not None:
self._rebuild_overlay()
# image handling --------------------------------------------------------
def load_single_image(self, path: Path, *, reset_collection: bool = True) -> Path:
image = Image.open(path).convert("RGBA")
self.orig_img = image
if reset_collection:
self.preview_paths = [path]
self.current_index = 0
self._build_preview()
self._rebuild_overlay()
return path
def load_folder(self, paths: Iterable[Path], start_index: int = 0) -> Path:
self.preview_paths = list(paths)
if not self.preview_paths:
raise ValueError("No images in folder.")
self.current_index = max(0, min(start_index, len(self.preview_paths) - 1))
return self._load_image_at_current()
def next_image(self) -> Path | None:
if not self.preview_paths:
return None
self.current_index = (self.current_index + 1) % len(self.preview_paths)
return self._load_image_at_current()
def previous_image(self) -> Path | None:
if not self.preview_paths:
return None
self.current_index = (self.current_index - 1) % len(self.preview_paths)
return self._load_image_at_current()
def _load_image_at_current(self) -> Path:
path = self.preview_paths[self.current_index]
return self.load_single_image(path, reset_collection=False)
# preview/overlay -------------------------------------------------------
def _build_preview(self) -> None:
if self.orig_img is None:
self.preview_img = None
return
width, height = self.orig_img.size
max_w, max_h = PREVIEW_MAX_SIZE
scale = min(max_w / width, max_h / height)
if scale <= 0:
scale = 1.0
size = (max(1, int(width * scale)), max(1, int(height * scale)))
self.preview_img = self.orig_img.resize(size, Image.LANCZOS)
def _rebuild_overlay(self) -> None:
if self.preview_img is None:
self.overlay_img = None
self.stats = Stats()
return
base = self.preview_img.convert("RGBA")
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
pixels = base.load()
width, height = base.size
highlight = (255, 0, 0, int(self.alpha))
matches_all = total_all = 0
matches_keep = total_keep = 0
matches_excl = total_excl = 0
mask = self._build_exclusion_mask(base.size)
mask_px = mask.load() if mask else None
for y in range(height):
for x in range(width):
r, g, b, a = pixels[x, y]
if a == 0:
continue
match = self._matches(r, g, b)
excluded = bool(mask_px and mask_px[x, y])
total_all += 1
if excluded:
total_excl += 1
if match:
matches_excl += 1
else:
total_keep += 1
if match:
draw.point((x, y), fill=highlight)
matches_keep += 1
if match:
matches_all += 1
self.overlay_img = overlay
self.stats = Stats(
matches_all=matches_all,
total_all=total_all,
matches_keep=matches_keep,
total_keep=total_keep,
matches_excl=matches_excl,
total_excl=total_excl,
)
# helpers ----------------------------------------------------------------
def _matches(self, r: int, g: int, b: int) -> bool:
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
hue = (h * 360.0) % 360.0
if self.hue_min <= self.hue_max:
hue_ok = self.hue_min <= hue <= self.hue_max
else:
hue_ok = hue >= self.hue_min or hue <= self.hue_max
sat_ok = s * 100.0 >= self.sat_min
val_ok = self.val_min <= v * 100.0 <= self.val_max
return hue_ok and sat_ok and val_ok
# exported data ----------------------------------------------------------
def preview_pixmap(self) -> QtGui.QPixmap:
return self._to_pixmap(self.preview_img)
def overlay_pixmap(self) -> QtGui.QPixmap:
if self.preview_img is None or self.overlay_img is None:
return QtGui.QPixmap()
merged = Image.alpha_composite(self.preview_img.convert("RGBA"), self.overlay_img)
return self._to_pixmap(merged)
@staticmethod
def _to_pixmap(image: Image.Image | None) -> QtGui.QPixmap:
if image is None:
return QtGui.QPixmap()
buffer = image.tobytes("raw", "RGBA")
qt_image = QtGui.QImage(buffer, image.width, image.height, QtGui.QImage.Format_RGBA8888)
return QtGui.QPixmap.fromImage(qt_image)
# exclusions -------------------------------------------------------------
def set_exclusions(self, shapes: list[dict[str, object]]) -> None:
copied: list[dict[str, object]] = []
for shape in shapes:
kind = shape.get("kind")
if kind == "rect":
coords = tuple(shape.get("coords", (0, 0, 0, 0))) # type: ignore[assignment]
copied.append({"kind": "rect", "coords": tuple(int(c) for c in coords)})
elif kind == "polygon":
pts = shape.get("points", [])
copied.append({"kind": "polygon", "points": [(int(x), int(y)) for x, y in pts]})
self.exclude_shapes = copied
self._rebuild_overlay()
def _build_exclusion_mask(self, size: Tuple[int, int]) -> Image.Image | None:
if not self.exclude_shapes:
return None
mask = Image.new("L", size, 0)
draw = ImageDraw.Draw(mask)
for shape in self.exclude_shapes:
kind = shape.get("kind")
if kind == "rect":
x0, y0, x1, y1 = shape["coords"] # type: ignore[index]
draw.rectangle([x0, y0, x1, y1], fill=255)
elif kind == "polygon":
points = shape.get("points", [])
if len(points) >= 3:
draw.polygon(points, fill=255)
return mask

1010
app/qt/main_window.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,14 @@
[project]
name = "icra"
version = "0.1.0"
description = "Interactive Color Range Analyzer (ICRA) for Tkinter"
description = "Interactive Color Range Analyzer (ICRA) desktop app (PySide6)"
readme = "README.md"
authors = [{ name = "ICRA contributors" }]
license = "MIT"
requires-python = ">=3.10"
dependencies = [
"pillow>=10.0.0",
"PySide6>=6.7",
]
[project.scripts]