mirror of
https://github.com/mr0xb/spritetagger.git
synced 2026-08-27 20:44:56 -04:00
initial commit
This commit is contained in:
commit
a9412fe58b
7 changed files with 711 additions and 0 deletions
108
README.md
Normal file
108
README.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# 🎮 Sprite Tagger
|
||||
|
||||
[](https://github.com/40ants/ai-badges)
|
||||
|
||||
A fast, keyboard-driven tool for sorting sprite animation frames into labeled directories. Load a folder of frames, tag them as `idle`, `walk`, `run`, whatever — then export them into organized subfolders in one shot.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🖼️ **Pixel-perfect display** — small sprites zoom in with nearest-neighbor scaling; transparency shows on a checkerboard background
|
||||
- ⌨️ **Fully keyboard-driven** — tag and advance without touching the mouse
|
||||
- ▶️ **Live animation preview** — `Space` plays through all frames; `P` plays back *only the current frame's label* on a loop, so you can confirm a walk cycle or attack animation reads right
|
||||
- 🏷️ **Label up to 9 categories** — one keypress per tag, auto-advances to the next frame; add new labels on the fly with `N`
|
||||
- 📁 **Non-destructive** — copies originals into labeled subdirectories, leaves your source untouched
|
||||
- 💾 **Autosave & resume** — every label is written to a `.sprite_tags.json` sidecar as you go, so you can quit mid-batch and pick up exactly where you left off
|
||||
- 🎨 **Dark theme UI** — easy on the eyes during long tagging sessions
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
pip install Pillow
|
||||
```
|
||||
|
||||
> tkinter is included with most Python installs. On macOS with Homebrew: `brew install python-tk`
|
||||
|
||||
### Run it
|
||||
|
||||
```bash
|
||||
python sprite_tagger.py /path/to/frames idle walk run jump attack
|
||||
```
|
||||
|
||||
With a custom output directory:
|
||||
|
||||
```bash
|
||||
python sprite_tagger.py ./sprites idle walk run --out ./sorted_frames
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ Controls
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `←` `→` | Previous / next frame |
|
||||
| `1` – `9` | Assign label and advance to next frame |
|
||||
| `Space` | Play / pause all frames |
|
||||
| `P` | Play / pause only the current frame's label, looping |
|
||||
| `[` `]` | Decrease / increase playback speed (1–30 fps) |
|
||||
| `X` | Clear label on current frame |
|
||||
| `N` | Add a new label on the fly |
|
||||
| `Enter` | Export all labeled frames |
|
||||
|
||||
---
|
||||
|
||||
## 📂 Output Structure
|
||||
|
||||
After exporting, your labeled frames are copied into subdirectories named after each label:
|
||||
|
||||
```
|
||||
output/
|
||||
├── idle/
|
||||
│ ├── frame_000.png
|
||||
│ ├── frame_001.png
|
||||
│ └── frame_002.png
|
||||
├── walk/
|
||||
│ ├── frame_003.png
|
||||
│ └── frame_004.png
|
||||
└── run/
|
||||
├── frame_005.png
|
||||
└── frame_006.png
|
||||
```
|
||||
|
||||
If no `--out` path is specified, output goes to `<frames_dir>/labeled/`.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Workflow
|
||||
|
||||
1. Point it at a directory of exported sprite frames
|
||||
2. Use `←` `→` to step through frames, or hit `Space` to play them back and get a feel for the animation
|
||||
3. When you land on a frame you want to tag, press the number key for that label — it tags and jumps to the next frame automatically
|
||||
4. Made a mistake? Navigate back and press the same or different number to reassign, or `X` to clear
|
||||
5. Hit `Enter` when done — a summary dialog confirms what was copied where
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Supported Formats
|
||||
|
||||
`.png` · `.jpg` · `.jpeg` · `.gif` · `.bmp` · `.webp` · `.tga`
|
||||
|
||||
---
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- Python 3.9+
|
||||
- [Pillow](https://python-pillow.org/)
|
||||
- tkinter (standard library, included with most Python distributions)
|
||||
55
labelstore.py
Normal file
55
labelstore.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Persistence for sprite frame label assignments.
|
||||
|
||||
Assignments are stored in a JSON sidecar keyed by filename (not frame index),
|
||||
so resuming a session survives frames being added, removed, or reordered.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
SIDECAR_NAME = '.sprite_tags.json'
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def sidecar_path(frames_dir: Path) -> Path:
|
||||
return frames_dir / SIDECAR_NAME
|
||||
|
||||
|
||||
def save_labels(path: Path, assignments_by_name: dict, labels: list) -> None:
|
||||
data = {
|
||||
'version': SCHEMA_VERSION,
|
||||
'labels': list(labels),
|
||||
'assignments': dict(assignments_by_name),
|
||||
}
|
||||
Path(path).write_text(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def load_labels(path: Path):
|
||||
"""Return (assignments_by_name, labels). Missing or invalid file → ({}, [])."""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
return {}, []
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError, ValueError):
|
||||
return {}, []
|
||||
assignments = data.get('assignments', {})
|
||||
labels = data.get('labels', [])
|
||||
if not isinstance(assignments, dict) or not isinstance(labels, list):
|
||||
return {}, []
|
||||
return assignments, labels
|
||||
|
||||
|
||||
def to_name_keyed(assignments_by_index: dict, frames: list) -> dict:
|
||||
"""{index: label} → {filename: label}."""
|
||||
return {frames[i].name: label for i, label in assignments_by_index.items()}
|
||||
|
||||
|
||||
def to_index_keyed(assignments_by_name: dict, frames: list) -> dict:
|
||||
"""{filename: label} → {index: label}, dropping files not in `frames`."""
|
||||
name_to_index = {p.name: i for i, p in enumerate(frames)}
|
||||
return {
|
||||
name_to_index[name]: label
|
||||
for name, label in assignments_by_name.items()
|
||||
if name in name_to_index
|
||||
}
|
||||
17
playback.py
Normal file
17
playback.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"""Playback helpers — selecting and cycling through frames by label."""
|
||||
|
||||
|
||||
def frames_with_label(assignments: dict, label: str) -> list:
|
||||
"""Sorted frame indices assigned the given label."""
|
||||
return sorted(i for i, lbl in assignments.items() if lbl == label)
|
||||
|
||||
|
||||
def next_in_cycle(indices: list, current: int, delta: int) -> int:
|
||||
"""Next index in `indices` after `current`, wrapping around.
|
||||
|
||||
If `current` isn't in the set, returns the first index.
|
||||
"""
|
||||
if current in indices:
|
||||
pos = indices.index(current)
|
||||
return indices[(pos + delta) % len(indices)]
|
||||
return indices[0]
|
||||
BIN
screenshot.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
423
sprite_tagger.py
Executable file
423
sprite_tagger.py
Executable file
|
|
@ -0,0 +1,423 @@
|
|||
#!/opt/homebrew/bin/python3.13
|
||||
"""Sprite frame tagger — navigate frames, assign labels, copy to labeled dirs.
|
||||
|
||||
Usage:
|
||||
sprite_tagger.py <frames_dir> <label1> [label2 ...] [--out <output_dir>]
|
||||
|
||||
Example:
|
||||
sprite_tagger.py ./sprites idle walk run jump
|
||||
|
||||
Keys:
|
||||
← → previous / next frame
|
||||
1-9 assign label and advance
|
||||
N add a new label
|
||||
Space play/pause all frames
|
||||
P play/pause only the current frame's label (looping)
|
||||
[ ] slower / faster playback
|
||||
X clear label on current frame
|
||||
Enter export labeled frames to output dir
|
||||
"""
|
||||
|
||||
import sys
|
||||
import shutil
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageTk
|
||||
|
||||
import labelstore
|
||||
import playback
|
||||
|
||||
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tga'}
|
||||
|
||||
BG = '#1a1a2e'
|
||||
BG_PANEL = '#16213e'
|
||||
ACCENT = '#4ecca3'
|
||||
FG = '#e0e0e0'
|
||||
FG_DIM = '#556677'
|
||||
BTN_BG = '#0f3460'
|
||||
BTN_ACT = '#e94560'
|
||||
FONT_MONO = ('Courier', 11)
|
||||
FONT_TINY = ('Courier', 9)
|
||||
|
||||
CANVAS_W = 860
|
||||
CANVAS_H = 620
|
||||
FPS_DEFAULT = 10
|
||||
FPS_MIN = 1
|
||||
FPS_MAX = 30
|
||||
|
||||
|
||||
def load_frames(directory: Path) -> list:
|
||||
return sorted(
|
||||
p for p in directory.iterdir()
|
||||
if p.suffix.lower() in IMAGE_EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
class SpriteTagger:
|
||||
def __init__(self, root: tk.Tk, frames: list, labels: list, output_dir: Path,
|
||||
store_path: Path, assignments: dict = None):
|
||||
self.root = root
|
||||
self.frames = frames
|
||||
self.labels = labels
|
||||
self.output_dir = output_dir
|
||||
self.store_path = store_path
|
||||
self.current = 0
|
||||
self.assignments: dict = assignments or {} # frame index → label name
|
||||
self._photo = None # ImageTk ref — must not be GC'd
|
||||
self._playing = False
|
||||
self._fps = FPS_DEFAULT
|
||||
self._play_job = None
|
||||
self._preview_label = None # None = play all; label str = preview that label
|
||||
|
||||
self._build_ui()
|
||||
self._bind_keys()
|
||||
self._show_frame()
|
||||
|
||||
# ── UI construction ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
self.root.title('Sprite Tagger')
|
||||
self.root.configure(bg=BG)
|
||||
self.root.resizable(True, True)
|
||||
|
||||
# canvas
|
||||
self.canvas = tk.Canvas(
|
||||
self.root, bg=BG_PANEL,
|
||||
width=CANVAS_W, height=CANVAS_H,
|
||||
highlightthickness=1, highlightbackground='#2a2a4e',
|
||||
)
|
||||
self.canvas.pack(fill=tk.BOTH, expand=True, padx=10, pady=(10, 0))
|
||||
self.canvas.bind('<Configure>', lambda _e: self._show_frame())
|
||||
|
||||
# status row
|
||||
status = tk.Frame(self.root, bg=BG)
|
||||
status.pack(fill=tk.X, padx=10, pady=4)
|
||||
|
||||
self._var_info = tk.StringVar()
|
||||
self._var_label = tk.StringVar()
|
||||
self._var_fps = tk.StringVar()
|
||||
|
||||
tk.Label(status, textvariable=self._var_info, bg=BG, fg=FG, font=FONT_MONO).pack(side=tk.LEFT)
|
||||
tk.Label(status, textvariable=self._var_fps, bg=BG, fg=FG_DIM, font=FONT_TINY).pack(side=tk.LEFT, padx=10)
|
||||
tk.Label(status, textvariable=self._var_label, bg=BG, fg=ACCENT, font=(*FONT_MONO, 'bold')).pack(side=tk.RIGHT)
|
||||
|
||||
# label buttons row
|
||||
self._btn_row = tk.Frame(self.root, bg=BG)
|
||||
self._btn_row.pack(fill=tk.X, padx=10, pady=(0, 4))
|
||||
|
||||
self._label_btns = {}
|
||||
for label in self.labels:
|
||||
self._add_label_button(label)
|
||||
|
||||
self._make_utility_buttons()
|
||||
|
||||
# hint row
|
||||
tk.Label(
|
||||
self.root,
|
||||
text='← → navigate 1-9 label+advance N new label Space play all P preview label [ ] speed X clear ↵ export',
|
||||
bg=BG, fg=FG_DIM, font=FONT_TINY,
|
||||
).pack(pady=(0, 6))
|
||||
|
||||
def _add_label_button(self, label: str):
|
||||
idx = len(self._label_btns)
|
||||
key = str(idx + 1) if idx < 9 else '-'
|
||||
# tk.Label used instead of tk.Button — macOS ignores fg/bg on native buttons
|
||||
lbl = tk.Label(
|
||||
self._btn_row, text=f'[{key}] {label}',
|
||||
bg=BTN_BG, fg='white',
|
||||
padx=8, pady=4, font=FONT_MONO,
|
||||
cursor='hand2', relief=tk.FLAT,
|
||||
)
|
||||
lbl.pack(side=tk.LEFT, padx=2)
|
||||
lbl.bind('<Button-1>', lambda _e, l=label: self._assign(l))
|
||||
lbl.bind('<Enter>', lambda _e, w=lbl: w.configure(bg='#1a5a9a'))
|
||||
lbl.bind('<Leave>', lambda _e, w=lbl: self._restore_btn_color(w, label))
|
||||
self._label_btns[label] = lbl
|
||||
|
||||
def _make_utility_buttons(self):
|
||||
self._make_action_label(self._btn_row, '[X] clear', '#5a1a3a', '#ff6688', self._clear, side=tk.LEFT, padx=(8, 2))
|
||||
self._make_action_label(self._btn_row, '[N] + label', '#3a3a1a', '#dddd44', self._prompt_new_label, side=tk.LEFT, padx=2)
|
||||
self._make_action_label(self._btn_row, '[↵] export', '#0a3a1a', ACCENT, self._export, side=tk.RIGHT, padx=2, bold=True)
|
||||
|
||||
def _make_action_label(self, parent, text, bg, hover_bg, command, side, padx, bold=False):
|
||||
font = (*FONT_MONO, 'bold') if bold else FONT_MONO
|
||||
w = tk.Label(parent, text=text, bg=bg, fg='white', padx=8, pady=4, font=font, cursor='hand2', relief=tk.FLAT)
|
||||
w.pack(side=side, padx=padx)
|
||||
w.bind('<Button-1>', lambda _e: command())
|
||||
w.bind('<Enter>', lambda _e: w.configure(bg=hover_bg, fg='black'))
|
||||
w.bind('<Leave>', lambda _e: w.configure(bg=bg, fg='white'))
|
||||
|
||||
def _restore_btn_color(self, widget: tk.Label, label: str):
|
||||
current_label = self.assignments.get(self.current)
|
||||
if label == current_label:
|
||||
widget.configure(bg=ACCENT, fg='black')
|
||||
else:
|
||||
widget.configure(bg=BTN_BG, fg='white')
|
||||
|
||||
def _bind_keys(self):
|
||||
self.root.bind('<Right>', lambda _e: self._navigate(1))
|
||||
self.root.bind('<Left>', lambda _e: self._navigate(-1))
|
||||
self.root.bind('<Return>', lambda _e: self._export())
|
||||
self.root.bind('<space>', lambda _e: self._toggle_play())
|
||||
self.root.bind('p', lambda _e: self._toggle_preview())
|
||||
self.root.bind('P', lambda _e: self._toggle_preview())
|
||||
self.root.bind('[', lambda _e: self._change_fps(-2))
|
||||
self.root.bind(']', lambda _e: self._change_fps(+2))
|
||||
self.root.bind('x', lambda _e: self._clear())
|
||||
self.root.bind('X', lambda _e: self._clear())
|
||||
self.root.bind('n', lambda _e: self._prompt_new_label())
|
||||
self.root.bind('N', lambda _e: self._prompt_new_label())
|
||||
self._rebind_number_keys()
|
||||
|
||||
def _rebind_number_keys(self):
|
||||
for i, label in enumerate(self.labels):
|
||||
if i < 9:
|
||||
self.root.bind(str(i + 1), lambda _e, l=label: self._assign(l))
|
||||
|
||||
# ── Frame display ────────────────────────────────────────────────────────
|
||||
|
||||
def _show_frame(self):
|
||||
path = self.frames[self.current]
|
||||
img = Image.open(path).convert('RGBA')
|
||||
|
||||
cw = self.canvas.winfo_width() if self.canvas.winfo_width() > 1 else CANVAS_W
|
||||
ch = self.canvas.winfo_height() if self.canvas.winfo_height() > 1 else CANVAS_H
|
||||
|
||||
if img.width <= cw // 2 and img.height <= ch // 2:
|
||||
# small sprite — zoom with nearest-neighbor to preserve pixels
|
||||
zoom = min(cw // img.width, ch // img.height)
|
||||
zoom = max(zoom, 1)
|
||||
img = img.resize((img.width * zoom, img.height * zoom), Image.NEAREST)
|
||||
elif img.width > cw or img.height > ch:
|
||||
# large image — scale down with lanczos
|
||||
scale = min(cw / img.width, ch / img.height)
|
||||
img = img.resize((int(img.width * scale), int(img.height * scale)), Image.LANCZOS)
|
||||
|
||||
# checkerboard background for transparency
|
||||
check_size = max(8, min(img.width, img.height) // 16)
|
||||
bg = Image.new('RGBA', img.size, (40, 40, 60, 255))
|
||||
draw = ImageDraw.Draw(bg)
|
||||
for y in range(0, img.height, check_size):
|
||||
for x in range(0, img.width, check_size):
|
||||
if (x // check_size + y // check_size) % 2 == 0:
|
||||
draw.rectangle(
|
||||
[x, y, min(x + check_size, img.width) - 1, min(y + check_size, img.height) - 1],
|
||||
fill=(50, 50, 70, 255),
|
||||
)
|
||||
bg.paste(img, mask=img.split()[3])
|
||||
|
||||
self._photo = ImageTk.PhotoImage(bg)
|
||||
self.canvas.delete('all')
|
||||
self.canvas.create_image(cw // 2, ch // 2, image=self._photo, anchor='center')
|
||||
|
||||
self._update_status()
|
||||
|
||||
def _update_status(self):
|
||||
path = self.frames[self.current]
|
||||
labeled = len(self.assignments)
|
||||
current_label = self.assignments.get(self.current)
|
||||
|
||||
self._var_info.set(f'{path.name} [{self.current + 1}/{len(self.frames)}] labeled: {labeled}')
|
||||
self._var_label.set(f'▶ {current_label}' if current_label else '—')
|
||||
play_sym = '⏸' if self._playing else '▶'
|
||||
if self._preview_label is not None:
|
||||
n = len(playback.frames_with_label(self.assignments, self._preview_label))
|
||||
self._var_fps.set(f'{play_sym} preview: {self._preview_label} ({n}) ⟳ {self._fps}fps')
|
||||
else:
|
||||
self._var_fps.set(f'{play_sym} {self._fps}fps')
|
||||
|
||||
for label, btn in self._label_btns.items():
|
||||
if label == current_label:
|
||||
btn.configure(bg=ACCENT, fg='black')
|
||||
else:
|
||||
btn.configure(bg=BTN_BG, fg='white')
|
||||
|
||||
# ── Navigation ───────────────────────────────────────────────────────────
|
||||
|
||||
def _navigate(self, delta: int):
|
||||
self.current = (self.current + delta) % len(self.frames)
|
||||
self._show_frame()
|
||||
|
||||
# ── Labeling ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _assign(self, label: str):
|
||||
self.assignments[self.current] = label
|
||||
self._save()
|
||||
self._navigate(1)
|
||||
|
||||
def _clear(self):
|
||||
self.assignments.pop(self.current, None)
|
||||
self._save()
|
||||
self._update_status()
|
||||
|
||||
def _save(self):
|
||||
by_name = labelstore.to_name_keyed(self.assignments, self.frames)
|
||||
labelstore.save_labels(self.store_path, by_name, self.labels)
|
||||
|
||||
# ── Playback ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _toggle_play(self):
|
||||
self._preview_label = None # Space always plays all frames
|
||||
if self._playing:
|
||||
self._stop_play()
|
||||
else:
|
||||
self._start_play()
|
||||
|
||||
def _toggle_preview(self):
|
||||
if self._preview_label is not None:
|
||||
self._stop_play()
|
||||
self._preview_label = None
|
||||
self._update_status()
|
||||
return
|
||||
label = self.assignments.get(self.current)
|
||||
if label is None:
|
||||
self._flash('tag this frame first to preview its label')
|
||||
return
|
||||
self._preview_label = label
|
||||
indices = playback.frames_with_label(self.assignments, label)
|
||||
self.current = indices[0]
|
||||
self._show_frame()
|
||||
self._start_play()
|
||||
|
||||
def _start_play(self):
|
||||
self._playing = True
|
||||
delay = max(1, 1000 // self._fps)
|
||||
self._play_job = self.root.after(delay, self._tick)
|
||||
self._update_status()
|
||||
|
||||
def _stop_play(self):
|
||||
self._playing = False
|
||||
if self._play_job:
|
||||
self.root.after_cancel(self._play_job)
|
||||
self._play_job = None
|
||||
self._update_status()
|
||||
|
||||
def _tick(self):
|
||||
if not self._playing:
|
||||
return
|
||||
if self._preview_label is not None:
|
||||
self._step_preview(1)
|
||||
else:
|
||||
self._navigate(1)
|
||||
delay = max(1, 1000 // self._fps)
|
||||
self._play_job = self.root.after(delay, self._tick)
|
||||
|
||||
def _step_preview(self, delta: int):
|
||||
indices = playback.frames_with_label(self.assignments, self._preview_label)
|
||||
if not indices:
|
||||
self._stop_play()
|
||||
self._preview_label = None
|
||||
return
|
||||
self.current = playback.next_in_cycle(indices, self.current, delta)
|
||||
self._show_frame()
|
||||
|
||||
def _change_fps(self, delta: int):
|
||||
self._fps = max(FPS_MIN, min(FPS_MAX, self._fps + delta))
|
||||
self._update_status()
|
||||
|
||||
def _flash(self, msg: str):
|
||||
self._var_label.set(msg)
|
||||
self.root.after(1500, self._update_status)
|
||||
|
||||
# ── Label management ─────────────────────────────────────────────────────
|
||||
|
||||
def _prompt_new_label(self):
|
||||
from tkinter import simpledialog
|
||||
name = simpledialog.askstring('New label', 'Label name:', parent=self.root)
|
||||
if not name:
|
||||
return
|
||||
name = name.strip().lower().replace(' ', '_')
|
||||
if not name or name in self._label_btns:
|
||||
return
|
||||
self.labels.append(name)
|
||||
self._add_label_button(name)
|
||||
self._rebind_number_keys()
|
||||
self._save()
|
||||
|
||||
# ── Export ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _export(self):
|
||||
if self._playing:
|
||||
self._toggle_play()
|
||||
|
||||
if not self.assignments:
|
||||
messagebox.showinfo('Nothing to export', 'No frames have been labeled yet.')
|
||||
return
|
||||
|
||||
counts: dict = {}
|
||||
for idx, label in self.assignments.items():
|
||||
dest = self.output_dir / label
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(self.frames[idx], dest / self.frames[idx].name)
|
||||
counts[label] = counts.get(label, 0) + 1
|
||||
|
||||
summary = '\n'.join(f' {label}/: {n} frame{"s" if n != 1 else ""}' for label, n in sorted(counts.items()))
|
||||
messagebox.showinfo('Export complete', f'Copied to:\n{self.output_dir}\n\n{summary}')
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_args(argv: list):
|
||||
if len(argv) < 3:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
args = list(argv[1:])
|
||||
output_dir = None
|
||||
|
||||
if '--out' in args:
|
||||
i = args.index('--out')
|
||||
if i + 1 >= len(args):
|
||||
print('Error: --out requires a path argument')
|
||||
sys.exit(1)
|
||||
output_dir = Path(args[i + 1])
|
||||
args = args[:i] + args[i + 2:]
|
||||
|
||||
frames_dir = Path(args[0])
|
||||
labels = args[1:]
|
||||
|
||||
if not frames_dir.is_dir():
|
||||
print(f'Error: {frames_dir} is not a directory')
|
||||
sys.exit(1)
|
||||
if not labels:
|
||||
print('Error: provide at least one label')
|
||||
sys.exit(1)
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = frames_dir / 'labeled'
|
||||
|
||||
return frames_dir, labels, output_dir
|
||||
|
||||
|
||||
def main():
|
||||
frames_dir, labels, output_dir = _parse_args(sys.argv)
|
||||
|
||||
frames = load_frames(frames_dir)
|
||||
if not frames:
|
||||
print(f'No image files found in {frames_dir}')
|
||||
print(f'Supported formats: {", ".join(sorted(IMAGE_EXTENSIONS))}')
|
||||
sys.exit(1)
|
||||
|
||||
store_path = labelstore.sidecar_path(frames_dir)
|
||||
saved_assignments, saved_labels = labelstore.load_labels(store_path)
|
||||
for lbl in saved_labels:
|
||||
if lbl not in labels:
|
||||
labels.append(lbl)
|
||||
assignments = labelstore.to_index_keyed(saved_assignments, frames)
|
||||
|
||||
print(f'Loaded {len(frames)} frames')
|
||||
print(f'Labels : {", ".join(labels)}')
|
||||
print(f'Output : {output_dir}')
|
||||
if assignments:
|
||||
print(f'Resumed {len(assignments)} labels from {store_path.name}')
|
||||
|
||||
root = tk.Tk()
|
||||
root.geometry(f'{CANVAS_W + 20}x{CANVAS_H + 130}')
|
||||
root.minsize(400, 400)
|
||||
SpriteTagger(root, frames, labels, output_dir, store_path, assignments)
|
||||
root.mainloop()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
70
test_labelstore.py
Normal file
70
test_labelstore.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import json
|
||||
import unittest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import labelstore
|
||||
|
||||
|
||||
class SaveLoadRoundTrip(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.dir = Path(self._tmp.name)
|
||||
self.path = self.dir / 'tags.json'
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_save_then_load_round_trips_assignments(self):
|
||||
assignments = {'frame_000.png': 'idle', 'frame_001.png': 'walk'}
|
||||
labelstore.save_labels(self.path, assignments, ['idle', 'walk'])
|
||||
loaded, _labels = labelstore.load_labels(self.path)
|
||||
self.assertEqual(loaded, assignments)
|
||||
|
||||
def test_save_persists_label_list(self):
|
||||
labelstore.save_labels(self.path, {}, ['idle', 'walk', 'crouch'])
|
||||
_assignments, labels = labelstore.load_labels(self.path)
|
||||
self.assertEqual(labels, ['idle', 'walk', 'crouch'])
|
||||
|
||||
def test_save_writes_filenames_not_indices(self):
|
||||
labelstore.save_labels(self.path, {'frame_007.png': 'jump'}, ['jump'])
|
||||
raw = json.loads(self.path.read_text())
|
||||
self.assertIn('frame_007.png', raw['assignments'])
|
||||
self.assertEqual(raw['assignments']['frame_007.png'], 'jump')
|
||||
|
||||
def test_load_missing_file_returns_empty(self):
|
||||
loaded, labels = labelstore.load_labels(self.dir / 'does_not_exist.json')
|
||||
self.assertEqual(loaded, {})
|
||||
self.assertEqual(labels, [])
|
||||
|
||||
def test_load_invalid_json_returns_empty(self):
|
||||
self.path.write_text('{not valid json at all')
|
||||
loaded, labels = labelstore.load_labels(self.path)
|
||||
self.assertEqual(loaded, {})
|
||||
self.assertEqual(labels, [])
|
||||
|
||||
|
||||
class IndexNameConversion(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.frames = [Path('a.png'), Path('b.png'), Path('c.png')]
|
||||
|
||||
def test_to_name_keyed_maps_index_to_filename(self):
|
||||
result = labelstore.to_name_keyed({0: 'idle', 2: 'run'}, self.frames)
|
||||
self.assertEqual(result, {'a.png': 'idle', 'c.png': 'run'})
|
||||
|
||||
def test_to_index_keyed_maps_filename_to_index(self):
|
||||
result = labelstore.to_index_keyed({'a.png': 'idle', 'c.png': 'run'}, self.frames)
|
||||
self.assertEqual(result, {0: 'idle', 2: 'run'})
|
||||
|
||||
def test_to_index_keyed_skips_files_no_longer_present(self):
|
||||
result = labelstore.to_index_keyed({'a.png': 'idle', 'gone.png': 'walk'}, self.frames)
|
||||
self.assertEqual(result, {0: 'idle'})
|
||||
|
||||
def test_to_index_keyed_remaps_after_reorder(self):
|
||||
reordered = [Path('c.png'), Path('a.png'), Path('b.png')]
|
||||
result = labelstore.to_index_keyed({'a.png': 'idle'}, reordered)
|
||||
self.assertEqual(result, {1: 'idle'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
38
test_playback.py
Normal file
38
test_playback.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import unittest
|
||||
|
||||
import playback
|
||||
|
||||
|
||||
class FramesWithLabel(unittest.TestCase):
|
||||
def test_returns_sorted_indices_matching_label(self):
|
||||
assignments = {5: 'walk', 1: 'walk', 3: 'idle', 8: 'walk'}
|
||||
self.assertEqual(playback.frames_with_label(assignments, 'walk'), [1, 5, 8])
|
||||
|
||||
def test_ignores_other_labels(self):
|
||||
assignments = {0: 'idle', 1: 'walk', 2: 'run'}
|
||||
self.assertEqual(playback.frames_with_label(assignments, 'idle'), [0])
|
||||
|
||||
def test_no_matches_returns_empty(self):
|
||||
assignments = {0: 'idle', 1: 'walk'}
|
||||
self.assertEqual(playback.frames_with_label(assignments, 'jump'), [])
|
||||
|
||||
|
||||
class NextInCycle(unittest.TestCase):
|
||||
def test_steps_forward(self):
|
||||
self.assertEqual(playback.next_in_cycle([1, 5, 8], 1, 1), 5)
|
||||
|
||||
def test_wraps_forward_at_end(self):
|
||||
self.assertEqual(playback.next_in_cycle([1, 5, 8], 8, 1), 1)
|
||||
|
||||
def test_steps_backward(self):
|
||||
self.assertEqual(playback.next_in_cycle([1, 5, 8], 5, -1), 1)
|
||||
|
||||
def test_wraps_backward_at_start(self):
|
||||
self.assertEqual(playback.next_in_cycle([1, 5, 8], 1, -1), 8)
|
||||
|
||||
def test_current_not_in_set_returns_first(self):
|
||||
self.assertEqual(playback.next_in_cycle([1, 5, 8], 3, 1), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue