Merge pyside6-migration into master

This commit is contained in:
lukas 2026-03-10 16:09:23 +01:00
commit 95907d6314
45 changed files with 1590 additions and 81 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

@ -59,3 +59,4 @@
"dialog.no_image_loaded" = "Kein Bild geladen."
"dialog.no_preview_available" = "Keine Preview vorhanden."
"dialog.overlay_saved" = "Overlay gespeichert: {path}"
"status.drag_drop" = "Bild oder Ordner hier ablegen."

View File

@ -59,3 +59,4 @@
"dialog.no_image_loaded" = "No image loaded."
"dialog.no_preview_available" = "No preview available."
"dialog.overlay_saved" = "Overlay saved: {path}"
"status.drag_drop" = "Drop an image or folder here to open it."

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__":

View File

@ -108,8 +108,6 @@ def _extract_options(data: dict[str, Any]) -> dict[str, Any]:
return result
_CONFIG_DATA = _load_config_data()
DEFAULTS = {**_DEFAULTS_BASE, **_extract_default_overrides(_CONFIG_DATA)}
LANGUAGE = _extract_language(_CONFIG_DATA)
OPTIONS = {**_OPTION_DEFAULTS, **_extract_options(_CONFIG_DATA)}

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()

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

@ -0,0 +1,314 @@
"""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
import numpy as np
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,
)
def _rgb_to_hsv_numpy(arr: np.ndarray) -> np.ndarray:
"""Vectorized RGB→HSV conversion. arr shape: (H, W, 3), dtype float32, range [0,1].
Returns array of same shape with channels [H(0-360), S(0-100), V(0-100)].
"""
r = arr[..., 0]
g = arr[..., 1]
b = arr[..., 2]
cmax = np.maximum(np.maximum(r, g), b)
cmin = np.minimum(np.minimum(r, g), b)
delta = cmax - cmin
# Value
v = cmax
# Saturation
s = np.where(cmax > 0, delta / cmax, 0.0)
# Hue
h = np.zeros_like(r)
mask_r = (delta > 0) & (cmax == r)
mask_g = (delta > 0) & (cmax == g)
mask_b = (delta > 0) & (cmax == b)
h[mask_r] = (60.0 * ((g[mask_r] - b[mask_r]) / delta[mask_r])) % 360.0
h[mask_g] = (60.0 * ((b[mask_g] - r[mask_g]) / delta[mask_g]) + 120.0) % 360.0
h[mask_b] = (60.0 * ((r[mask_b] - g[mask_b]) / delta[mask_b]) + 240.0) % 360.0
return np.stack([h, s * 100.0, v * 100.0], axis=-1)
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:
"""Build colour-match overlay using vectorized NumPy operations."""
if self.preview_img is None:
self.overlay_img = None
self.stats = Stats()
return
base = self.preview_img.convert("RGBA")
arr = np.asarray(base, dtype=np.float32) # (H, W, 4)
rgb = arr[..., :3] / 255.0
alpha_ch = arr[..., 3] # alpha channel of the image
hsv = _rgb_to_hsv_numpy(rgb) # (H, W, 3): H°, S%, V%
hue = hsv[..., 0]
sat = hsv[..., 1]
val = hsv[..., 2]
hue_min = float(self.hue_min)
hue_max = float(self.hue_max)
if hue_min <= hue_max:
hue_ok = (hue >= hue_min) & (hue <= hue_max)
else:
hue_ok = (hue >= hue_min) | (hue <= hue_max)
match_mask = (
hue_ok
& (sat >= float(self.sat_min))
& (val >= float(self.val_min))
& (val <= float(self.val_max))
& (alpha_ch > 0)
)
# Exclusion mask (same pixel space as preview)
excl_mask = self._build_exclusion_mask_numpy(base.size) # bool (H,W)
keep_match = match_mask & ~excl_mask
excl_match = match_mask & excl_mask
visible = alpha_ch > 0
matches_all = int(match_mask[visible].sum())
total_all = int(visible.sum())
matches_keep = int(keep_match[visible].sum())
total_keep = int((visible & ~excl_mask).sum())
matches_excl = int(excl_match[visible].sum())
total_excl = int((visible & excl_mask).sum())
# Build overlay image
overlay_arr = np.zeros((base.height, base.width, 4), dtype=np.uint8)
overlay_arr[keep_match, 0] = 255
overlay_arr[keep_match, 3] = int(self.alpha)
self.overlay_img = Image.fromarray(overlay_arr, "RGBA")
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:
"""Single-pixel match — kept for compatibility / eyedropper use."""
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
def pick_colour(self, x: int, y: int) -> Tuple[float, float, float] | None:
"""Return (hue°, sat%, val%) of the preview pixel at (x, y), or None."""
if self.preview_img is None:
return None
img = self.preview_img.convert("RGBA")
try:
r, g, b, a = img.getpixel((x, y))
except IndexError:
return None
if a == 0:
return None
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
return (h * 360.0) % 360.0, s * 100.0, v * 100.0
# 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
def _build_exclusion_mask_numpy(self, size: Tuple[int, int]) -> np.ndarray:
"""Return a boolean (H, W) mask — True where pixels are excluded."""
w, h = size
if not self.exclude_shapes:
return np.zeros((h, w), dtype=bool)
pil_mask = self._build_exclusion_mask(size)
if pil_mask is None:
return np.zeros((h, w), dtype=bool)
return np.asarray(pil_mask, dtype=bool)

1159
app/qt/main_window.py Normal file

File diff suppressed because it is too large Load Diff

BIN
images/ingame/271.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/296.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/328.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/460.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/487.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/552.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/572.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/583.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/654.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/696.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/70.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/705.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/83.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/858.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
images/ingame/86.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/862.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/ingame/911.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 384 KiB

View File

Before

Width:  |  Height:  |  Size: 464 KiB

After

Width:  |  Height:  |  Size: 464 KiB

View File

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 369 KiB

View File

Before

Width:  |  Height:  |  Size: 376 KiB

After

Width:  |  Height:  |  Size: 376 KiB

View File

Before

Width:  |  Height:  |  Size: 380 KiB

After

Width:  |  Height:  |  Size: 380 KiB

View File

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 387 KiB

View File

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 456 KiB

View File

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 457 KiB

View File

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 457 KiB

View File

Before

Width:  |  Height:  |  Size: 465 KiB

After

Width:  |  Height:  |  Size: 465 KiB

View File

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 384 KiB

View File

Before

Width:  |  Height:  |  Size: 378 KiB

After

Width:  |  Height:  |  Size: 378 KiB

View File

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 384 KiB

View File

Before

Width:  |  Height:  |  Size: 393 KiB

After

Width:  |  Height:  |  Size: 393 KiB

View File

Before

Width:  |  Height:  |  Size: 375 KiB

After

Width:  |  Height:  |  Size: 375 KiB

View File

Before

Width:  |  Height:  |  Size: 441 KiB

After

Width:  |  Height:  |  Size: 441 KiB

View File

Before

Width:  |  Height:  |  Size: 389 KiB

After

Width:  |  Height:  |  Size: 389 KiB

View File

@ -1,13 +1,15 @@
[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 = [
"numpy>=1.26",
"pillow>=10.0.0",
"PySide6>=6.7",
]
[project.scripts]