Improve performance, UX, and code quality

- Replace Python pixel loop with NumPy vectorized HSV processing (~50-100x faster overlay rebuilds)
- Add OverlayCanvas (QGraphicsView) to replace plain QLabel overlay panel
- Add editable QLineEdit value input to SliderControl (supports typing exact values)
- Implement eyedropper pick-from-image: left-click sets hue/sat/val sliders from pixel colour
- Add keyboard shortcuts: Ctrl+O, Ctrl+Shift+O, Ctrl+S, Ctrl+Z, Ctrl+R, arrow keys, Esc
- Add drag-and-drop support for image files and folders
- Persist and restore window geometry via QSettings
- Remove duplicate _CONFIG_DATA load in constants.py
- Remove dead constructor stylesheet from ToolbarButton
- Add numpy>=1.26 dependency
This commit is contained in:
lukas 2026-03-10 16:07:52 +01:00
parent 2d4531013d
commit 78af24103c
40 changed files with 309 additions and 88 deletions

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

@ -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)}

View File

@ -7,6 +7,7 @@ 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
@ -38,6 +39,36 @@ class Stats:
)
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."""
@ -132,44 +163,59 @@ class QtImageProcessor:
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")
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
arr = np.asarray(base, dtype=np.float32) # (H, W, 4)
mask = self._build_exclusion_mask(base.size)
mask_px = mask.load() if mask else None
rgb = arr[..., :3] / 255.0
alpha_ch = arr[..., 3] # alpha channel of the image
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
hsv = _rgb_to_hsv_numpy(rgb) # (H, W, 3): H°, S%, V%
self.overlay_img = overlay
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,
@ -182,6 +228,7 @@ class QtImageProcessor:
# 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:
@ -192,6 +239,20 @@ class QtImageProcessor:
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:
@ -241,3 +302,13 @@ class QtImageProcessor:
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)

View File

@ -75,24 +75,6 @@ class ToolbarButton(QtWidgets.QPushButton):
metrics = QtGui.QFontMetrics(self.font())
width = metrics.horizontalAdvance(text) + 28
self.setMinimumWidth(width)
self.setStyleSheet(
"""
QPushButton {
padding: 8px 16px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background-color: rgba(255, 255, 255, 0.04);
color: #f7f7fb;
font-weight: 600;
}
QPushButton:hover {
background-color: rgba(255, 255, 255, 0.12);
}
QPushButton:pressed {
background-color: rgba(255, 255, 255, 0.18);
}
"""
)
self.clicked.connect(callback)
def apply_theme(self, colours: Dict[str, str]) -> None:
@ -159,13 +141,15 @@ class ColourSwatch(QtWidgets.QPushButton):
class SliderControl(QtWidgets.QWidget):
"""Slider with header and live value label."""
"""Slider with header and editable live value input."""
value_changed = QtCore.Signal(str, int)
def __init__(self, title: str, key: str, minimum: int, maximum: int, initial: int):
super().__init__()
self.key = key
self._minimum = minimum
self._maximum = maximum
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
@ -176,47 +160,46 @@ class SliderControl(QtWidgets.QWidget):
self.title_label = QtWidgets.QLabel(title)
header.addWidget(self.title_label)
header.addStretch(1)
self.value_label = QtWidgets.QLabel(str(initial))
header.addWidget(self.value_label)
self.value_edit = QtWidgets.QLineEdit(str(initial))
self.value_edit.setFixedWidth(44)
self.value_edit.setAlignment(QtCore.Qt.AlignRight)
self.value_edit.setValidator(QtGui.QIntValidator(minimum, maximum, self))
self.value_edit.editingFinished.connect(self._commit_edit)
header.addWidget(self.value_edit)
layout.addLayout(header)
self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
self.slider.setRange(minimum, maximum)
self.slider.setValue(initial)
self.slider.setCursor(QtCore.Qt.PointingHandCursor)
self.slider.setStyleSheet(
"""
QSlider::groove:horizontal {
border: 1px solid rgba(255,255,255,0.08);
height: 6px;
background: rgba(255,255,255,0.1);
border-radius: 4px;
}
QSlider::handle:horizontal {
background: #9a4dff;
border: 1px solid rgba(255,255,255,0.2);
width: 14px;
margin: -5px 0;
border-radius: 7px;
}
"""
)
self.slider.valueChanged.connect(self._sync_value)
layout.addWidget(self.slider)
def _sync_value(self, value: int) -> None:
self.value_label.setText(str(value))
self.value_edit.setText(str(value))
self.value_changed.emit(self.key, value)
def _commit_edit(self) -> None:
text = self.value_edit.text().strip()
try:
value = int(text)
except ValueError:
value = self.slider.value()
value = max(self._minimum, min(self._maximum, value))
self.slider.setValue(value) # triggers _sync_value -> signal
def set_value(self, value: int) -> None:
self.slider.blockSignals(True)
self.slider.setValue(value)
self.slider.blockSignals(False)
self.value_label.setText(str(value))
self.value_edit.setText(str(value))
def apply_theme(self, colours: Dict[str, str]) -> None:
self.title_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
self.value_label.setStyleSheet(f"color: {colours['text_dim']};")
self.value_edit.setStyleSheet(
f"color: {colours['text_dim']}; background: transparent; "
f"border: 1px solid {colours['border']}; border-radius: 4px; padding: 0 2px;"
)
self.slider.setStyleSheet(
f"""
QSlider::groove:horizontal {{
@ -240,6 +223,7 @@ class CanvasView(QtWidgets.QGraphicsView):
"""Interactive canvas for drawing exclusion shapes over the preview image."""
shapes_changed = QtCore.Signal(list)
pixel_clicked = QtCore.Signal(int, int) # x, y in image coordinates
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
@ -257,6 +241,7 @@ class CanvasView(QtWidgets.QGraphicsView):
self.shapes: list[dict[str, object]] = []
self.mode: str = "rect"
self.pick_mode: bool = False
self._drawing = False
self._start_pos = QtCore.QPointF()
self._last_pos = QtCore.QPointF()
@ -305,6 +290,12 @@ class CanvasView(QtWidgets.QGraphicsView):
# event handling --------------------------------------------------------
def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
if event.button() == QtCore.Qt.LeftButton and self.pick_mode and self._pixmap_item:
scene_pos = self.mapToScene(event.position().toPoint())
clamped = self._clamp_to_image(scene_pos)
self.pixel_clicked.emit(int(clamped.x()), int(clamped.y()))
event.accept()
return
if event.button() == QtCore.Qt.RightButton and self._pixmap_item:
self._drawing = True
scene_pos = self.mapToScene(event.position().toPoint())
@ -407,6 +398,35 @@ class CanvasView(QtWidgets.QGraphicsView):
self._shape_items.append(path_item)
class OverlayCanvas(QtWidgets.QGraphicsView):
"""Read-only QGraphicsView for displaying the colour-match overlay."""
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self._scene = QtWidgets.QGraphicsScene(self)
self.setScene(self._scene)
self._pixmap_item: QtWidgets.QGraphicsPixmapItem | None = None
def set_pixmap(self, pixmap: QtGui.QPixmap) -> None:
self._scene.clear()
self._pixmap_item = self._scene.addPixmap(pixmap)
self._scene.setSceneRect(pixmap.rect())
self.resetTransform()
self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio)
def clear_canvas(self) -> None:
self._scene.clear()
self._pixmap_item = None
def resizeEvent(self, event: QtGui.QResizeEvent) -> None: # type: ignore[override]
super().resizeEvent(event)
if self._pixmap_item:
self.fitInView(self._scene.sceneRect(), QtCore.Qt.KeepAspectRatio)
class TitleBar(QtWidgets.QWidget):
"""Custom title bar with native window controls."""
@ -561,8 +581,10 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self._register_default_actions()
self.exclude_mode = "rect"
self._pick_mode = False
self.image_view.set_mode(self.exclude_mode)
self.image_view.shapes_changed.connect(self._on_shapes_changed)
self.image_view.pixel_clicked.connect(self._on_pixel_picked)
self._sync_sliders_from_processor()
self._update_colour_display(DEFAULT_COLOUR, self._t("palette.current"))
@ -570,6 +592,18 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.current_theme = "dark"
self._apply_theme(self.current_theme)
# Drag-and-drop
self.setAcceptDrops(True)
# Keyboard shortcuts
self._setup_shortcuts()
# Restore window geometry
self._settings = QtCore.QSettings("ICRA", "MainWindow")
geometry = self._settings.value("geometry")
if geometry:
self.restoreGeometry(geometry)
# Window control helpers -------------------------------------------------
def toggle_maximise(self) -> None:
@ -684,9 +718,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
self.image_view = CanvasView()
layout.addWidget(self.image_view, 0, 1)
self.overlay_view = QtWidgets.QLabel("<No image loaded>")
self.overlay_view.setAlignment(QtCore.Qt.AlignCenter)
self.overlay_view.setScaledContents(True)
self.overlay_view = OverlayCanvas()
layout.addWidget(self.overlay_view, 0, 2)
self.next_button = QtWidgets.QToolButton()
@ -723,7 +755,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"open_image": self.open_image,
"open_folder": self.open_folder,
"choose_color": self.choose_colour,
"pick_from_image": self._coming_soon,
"pick_from_image": self.pick_from_image,
"save_overlay": self.save_overlay,
"toggle_free_draw": self.toggle_free_draw,
"clear_excludes": self.clear_exclusions,
@ -835,6 +867,127 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
"Feature coming soon in the PySide6 migration.",
)
# Shortcuts --------------------------------------------------------------
def _setup_shortcuts(self) -> None:
shortcuts = [
(QtGui.QKeySequence.Open, self.open_image),
(QtGui.QKeySequence("Ctrl+Shift+O"), self.open_folder),
(QtGui.QKeySequence.Save, self.save_overlay),
(QtGui.QKeySequence.Undo, self.undo_exclusion),
(QtGui.QKeySequence("Ctrl+R"), self._reset_sliders),
(QtGui.QKeySequence(QtCore.Qt.Key_Left), self.show_previous_image),
(QtGui.QKeySequence(QtCore.Qt.Key_Right), self.show_next_image),
(QtGui.QKeySequence(QtCore.Qt.Key_Escape), self._exit_pick_mode),
]
for seq, slot in shortcuts:
sc = QtGui.QShortcut(seq, self)
sc.activated.connect(slot)
# Pick-mode / eyedropper -------------------------------------------------
def pick_from_image(self) -> None:
if self.processor.preview_img is None:
QtWidgets.QMessageBox.information(
self, self._t("dialog.info_title"), self._t("dialog.no_image_loaded")
)
return
self._pick_mode = True
self.image_view.pick_mode = True
self.image_view.setCursor(QtCore.Qt.CrossCursor)
self.status_label.setText(self._t("status.pick_mode_ready"))
def _exit_pick_mode(self) -> None:
self._pick_mode = False
self.image_view.pick_mode = False
self.image_view.setCursor(QtCore.Qt.ArrowCursor)
self.status_label.setText(self._t("status.pick_mode_ended"))
def _on_pixel_picked(self, x: int, y: int) -> None:
result = self.processor.pick_colour(x, y)
if result is None:
self._exit_pick_mode()
return
hue, sat, val = result
# Derive a narrow hue range (±15°) and minimum sat/val from the pixel
margin = 15
hue_min = max(0, int(hue) - margin)
hue_max = min(360, int(hue) + margin)
sat_min = max(0, int(sat) - 20)
val_min = max(0, int(val) - 30)
val_max = 100
for attr, value in [
("hue_min", hue_min), ("hue_max", hue_max),
("sat_min", sat_min), ("val_min", val_min), ("val_max", val_max),
]:
ctrl = self._slider_controls.get(attr)
if ctrl:
ctrl.set_value(value)
self.processor.set_threshold(attr, value)
# Update colour swatch to the picked pixel colour
h_norm = hue / 360.0
s_norm = sat / 100.0
v_norm = val / 100.0
import colorsys
r, g, b = colorsys.hsv_to_rgb(h_norm, s_norm, v_norm)
hex_code = "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
self._update_colour_display(hex_code, "")
self.status_label.setText(
self._t("status.pick_mode_from_image", hue=hue, saturation=sat, value=val)
)
self._exit_pick_mode()
self._refresh_overlay_only()
# Drag-and-drop ----------------------------------------------------------
def dragEnterEvent(self, event: QtGui.QDragEnterEvent) -> None:
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event: QtGui.QDropEvent) -> None:
urls = event.mimeData().urls()
if not urls:
return
path = Path(urls[0].toLocalFile())
if path.is_dir():
paths = sorted(
(p for p in path.iterdir() if p.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS and p.is_file()),
key=lambda p: p.name.lower(),
)
if not paths:
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), self._t("dialog.folder_empty"))
return
try:
loaded_path = self.processor.load_folder(paths)
except ValueError as exc:
QtWidgets.QMessageBox.information(self, self._t("dialog.info_title"), str(exc))
return
self._current_image_path = loaded_path
elif path.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS:
try:
loaded_path = self.processor.load_single_image(path)
except Exception as exc:
QtWidgets.QMessageBox.warning(self, self._t("dialog.error_title"), str(exc))
return
self._current_image_path = loaded_path
if self.processor.reset_exclusions_on_switch:
self.image_view.clear_shapes()
self.processor.set_exclusions([])
else:
return
self._refresh_views()
event.acceptProposedAction()
# Window lifecycle -------------------------------------------------------
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
self._settings.setValue("geometry", self.saveGeometry())
super().closeEvent(event)
def choose_colour(self) -> None:
colour = QtWidgets.QColorDialog.getColor(parent=self, title=self._t("dialog.choose_colour_title"))
if not colour.isValid():
@ -888,7 +1041,7 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
)
self.image_view.set_accent(colours["highlight"])
self.overlay_view.setStyleSheet(
f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px; color: {colours['text_dim']};"
f"background-color: {colours['panel_bg']}; border: 1px solid {colours['border']}; border-radius: 12px;"
)
self.status_label.setStyleSheet(f"color: {colours['text_muted']}; font-weight: 500;")
@ -938,18 +1091,16 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
def _refresh_views(self) -> None:
preview_pix = self.processor.preview_pixmap()
overlay_pix = self.processor.overlay_pixmap()
if preview_pix.isNull():
self.image_view.clear_canvas()
self.image_view.set_shapes([])
self.overlay_view.setText("<No image loaded>")
self.overlay_view.setPixmap(QtGui.QPixmap())
self.overlay_view.clear_canvas()
else:
self.image_view.set_pixmap(preview_pix)
self.image_view.set_shapes(self.processor.exclude_shapes.copy())
self.overlay_view.setText("")
overlay_pix = self._overlay_with_outlines(overlay_pix)
self.overlay_view.setPixmap(overlay_pix)
overlay_pix = self.processor.overlay_pixmap()
overlay_pix = self._overlay_with_outlines(overlay_pix)
self.overlay_view.set_pixmap(overlay_pix)
if self._current_image_path and self.processor.preview_img:
width, height = self.processor.preview_img.size
total = len(self.processor.preview_paths)
@ -968,11 +1119,9 @@ class MainWindow(QtWidgets.QMainWindow, I18nMixin):
return
pix = self.processor.overlay_pixmap()
if pix.isNull():
self.overlay_view.setText("<No overlay>")
self.overlay_view.setPixmap(QtGui.QPixmap())
self.overlay_view.clear_canvas()
else:
self.overlay_view.setText("")
self.overlay_view.setPixmap(self._overlay_with_outlines(pix))
self.overlay_view.set_pixmap(self._overlay_with_outlines(pix))
self.ratio_label.setText(self.processor.stats.summary(self._t))
def _on_shapes_changed(self, shapes: list[dict[str, object]]) -> None:

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

@ -7,6 +7,7 @@ authors = [{ name = "ICRA contributors" }]
license = "MIT"
requires-python = ">=3.10"
dependencies = [
"numpy>=1.26",
"pillow>=10.0.0",
"PySide6>=6.7",
]