commit a9412fe58bf43806d7c971d73e4174953d8c1106 Author: Steve Briggs Date: Sat May 23 21:32:55 2026 -0400 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..693d28d --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# ๐ŸŽฎ Sprite Tagger + +[![WARN-LLM GENERATED](https://img.shields.io/badge/WARN-LLM%20GENERATED-FF6347)](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. + +![Python](https://img.shields.io/badge/Python-3.9+-blue?style=flat-square&logo=python) +![Platform](https://img.shields.io/badge/Platform-macOS%20%7C%20Linux-lightgrey?style=flat-square) +![License](https://img.shields.io/badge/License-MIT-green?style=flat-square) + +![Sprite Tagger screenshot](screenshot.png) + +--- + +## โœจ 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 `/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) diff --git a/labelstore.py b/labelstore.py new file mode 100644 index 0000000..65963d6 --- /dev/null +++ b/labelstore.py @@ -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 + } diff --git a/playback.py b/playback.py new file mode 100644 index 0000000..9302f46 --- /dev/null +++ b/playback.py @@ -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] diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..c459c43 Binary files /dev/null and b/screenshot.png differ diff --git a/sprite_tagger.py b/sprite_tagger.py new file mode 100755 index 0000000..d70d46c --- /dev/null +++ b/sprite_tagger.py @@ -0,0 +1,423 @@ +#!/opt/homebrew/bin/python3.13 +"""Sprite frame tagger โ€” navigate frames, assign labels, copy to labeled dirs. + +Usage: + sprite_tagger.py [label2 ...] [--out ] + +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('', 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('', lambda _e, l=label: self._assign(l)) + lbl.bind('', lambda _e, w=lbl: w.configure(bg='#1a5a9a')) + lbl.bind('', 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('', lambda _e: command()) + w.bind('', lambda _e: w.configure(bg=hover_bg, fg='black')) + w.bind('', 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('', lambda _e: self._navigate(1)) + self.root.bind('', lambda _e: self._navigate(-1)) + self.root.bind('', lambda _e: self._export()) + self.root.bind('', 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() diff --git a/test_labelstore.py b/test_labelstore.py new file mode 100644 index 0000000..5ba1e7b --- /dev/null +++ b/test_labelstore.py @@ -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() diff --git a/test_playback.py b/test_playback.py new file mode 100644 index 0000000..16445d5 --- /dev/null +++ b/test_playback.py @@ -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()