From c4fe56021f6d01d4fa361e8d428f71e1e95ee613 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 28 Jun 2026 16:27:52 +0300 Subject: [PATCH] Add sprite review GUI (DearPyGui) - Desktop GUI similar to Soundgen's Training tab - Thumbnail preview, star rating (1-5), text feedback - Filter: all / unrated / top rated - Export dataset as JSONL - Updated README and requirements --- README.md | 14 +++ requirements.txt | 3 + review_gui.py | 249 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 review_gui.py diff --git a/README.md b/README.md index e02146f..4f9e29f 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,20 @@ export IMAGEGEN_OUTPUT_DIR=/path/to/output ./venv/bin/python server.py ``` +### 5. Review & rate sprites (GUI) + +```bash +./venv/bin/pip install dearpygui pillow +./venv/bin/python review_gui.py +``` + +Features: +- Thumbnail preview of each generated sprite +- Star rating (1-5) with one click +- Text feedback per sprite +- Filter: all / unrated / top rated +- Export rated dataset as JSONL for fine-tuning + Or configure in your MCP client: ```json diff --git a/requirements.txt b/requirements.txt index 41a982f..7dd1520 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,7 @@ diffusers>=0.37.0 transformers accelerate torch +peft pillow +numpy +dearpygui diff --git a/review_gui.py b/review_gui.py new file mode 100644 index 0000000..ac2415d --- /dev/null +++ b/review_gui.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +""" +Sprite Review GUI — rate generated sprites, give feedback, export dataset. + +Desktop GUI (DearPyGui) similar to Soundgen's Training tab. +Run: ./venv/bin/python review_gui.py +""" + +import os +import sys + +import dearpygui.dearpygui as dpg +from PIL import Image + +from feedback import FeedbackDB + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_PATH = os.environ.get("IMAGEGEN_DB_PATH", os.path.join(BASE_DIR, "feedback.db")) +THUMB_SIZE = 96 + +_db = FeedbackDB.open(DB_PATH) +_entries = [] +_thumbnails = {} +_filter = "all" + + +def _load_thumbnail(path, tag): + if not path or not os.path.exists(path): + return False + try: + img = Image.open(path).convert("RGBA") + img.thumbnail((THUMB_SIZE, THUMB_SIZE), Image.NEAREST) + + bg = Image.new("RGBA", (THUMB_SIZE, THUMB_SIZE), (40, 40, 40, 255)) + offset = ((THUMB_SIZE - img.width) // 2, (THUMB_SIZE - img.height) // 2) + bg.paste(img, offset, img if img.mode == "RGBA" else None) + rgb = bg.convert("RGB") + + dpg.add_static_texture(THUMB_SIZE, THUMB_SIZE, list(rgb.getdata()), tag=tag) + _thumbnails[tag] = True + return True + except Exception: + return False + + +def _refresh(): + global _entries + + if _filter == "unrated": + _entries = _db.get_unrated() + elif _filter == "top": + _entries = _db.top_rated(200, 1) + else: + _entries = _db.get_all() + + stats = _db.stats() + dpg.set_value( + "stats_text", + f"{stats.total} sprites | {stats.rated} rated | {stats.unrated} unrated | avg {stats.avg_rating:.1f}", + ) + + dpg.delete_item("sprite_list", children=True) + + for i, entry in enumerate(_entries): + with dpg.group(parent="sprite_list", tag=f"row_{entry.id}"): + with dpg.group(horizontal=True): + thumb_tag = f"thumb_{entry.id}" + if _load_thumbnail(entry.image_path, thumb_tag): + dpg.add_image(thumb_tag) + else: + dpg.add_text("[no img]", default_value="[no img]") + + with dpg.group(): + prompt_display = ( + entry.prompt[:90] + "..." + if len(entry.prompt) > 90 + else entry.prompt + ) + dpg.add_text(prompt_display, color=(140, 180, 255)) + + if entry.image_path: + p = entry.image_path + if len(p) > 80: + p = "..." + p[-77:] + dpg.add_text(p, color=(100, 100, 100)) + + with dpg.group(horizontal=True): + for star in range(1, 6): + filled = star <= entry.rating + label = "★" if filled else "☆" + color = (255, 200, 80) if filled else (80, 80, 90) + dpg.add_button( + label=label, + tag=f"star_{entry.id}_{star}", + callback=lambda s, a, e=entry: _rate(e, s), + width=24, + ) + dpg.bind_item_theme( + f"star_{entry.id}_{star}", _star_theme(color) + ) + + rating_text = ( + f"{entry.rating}★" if entry.rating > 0 else "unrated" + ) + dpg.add_text(rating_text, tag=f"rating_label_{entry.id}") + + dpg.add_input_text( + hint="feedback...", + default_value=entry.feedback or "", + tag=f"feedback_{entry.id}", + width=180, + ) + + dpg.add_button( + label="Save", + callback=lambda s, a, e=entry: _save_feedback(e), + width=50, + ) + + dpg.add_button( + label="Del", + callback=lambda s, a, e=entry: _delete(e), + width=40, + ) + + if entry.feedback: + dpg.add_text(f'saved: "{entry.feedback}"', color=(80, 180, 100)) + + dpg.add_separator() + + +def _rate(entry, star_tag): + star_num = int(star_tag.split("_")[-1]) + current = _get_current_rating(entry.id) + new_rating = 0 if current == star_num else star_num + + fb_widget = f"feedback_{entry.id}" + feedback = dpg.get_value(fb_widget) if dpg.does_item_exist(fb_widget) else "" + feedback = feedback if feedback else None + + _db.update_rating(entry.id, new_rating, feedback) + _refresh() + + +def _get_current_rating(entry_id): + for e in _entries: + if e.id == entry_id: + return e.rating + return 0 + + +def _save_feedback(entry): + fb_tag = f"feedback_{entry.id}" + feedback = dpg.get_value(fb_tag) if dpg.does_item_exist(fb_tag) else "" + rating = _get_current_rating(entry.id) + _db.update_rating(entry.id, rating, feedback if feedback else None) + _refresh() + + +def _delete(entry): + _db.delete(entry.id) + _refresh() + + +def _on_filter(sender, app_data): + global _filter + _filter = dpg.get_value("filter_radio") + _refresh() + + +def _export_dataset(): + import dearpygui.dearpygui as dpg_filedialog + + def _do_export(sender, app_data): + path = ( + app_data["file_path_name"] + if "file_path_name" in app_data + else app_data.get("file_name", "") + ) + if path: + count = _db.export_jsonl(path, min_rating=4) + dpg.set_value("stats_text", f"Exported {count} examples to {path}") + + with dpg.file_dialog( + directory_selector=False, + callback=_do_export, + width=600, + height=400, + tag="export_dialog", + ): + dpg.add_file_extension(".jsonl") + + +def _star_theme(color): + with dpg.theme() as theme: + with dpg.theme_component(dpg.mvButton): + dpg.add_theme_color(dpg.mvThemeCol_Text, color) + dpg.add_theme_color(dpg.mvThemeCol_Button, (0, 0, 0, 0)) + dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, (60, 60, 60, 50)) + dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, (40, 40, 40, 50)) + with dpg.theme_component(dpg.mvButton, enabled_state=False): + dpg.add_theme_color(dpg.mvThemeCol_Text, color) + return theme + + +def main(): + dpg.create_context() + + with dpg.window(tag="main_window"): + with dpg.group(horizontal=True): + dpg.add_button(label="Refresh", callback=_refresh) + dpg.add_button(label="Export Dataset", callback=_export_dataset) + + dpg.add_text("Filter:", indent=20) + dpg.add_radio_button( + ["all", "unrated", "top"], + tag="filter_radio", + horizontal=True, + callback=_on_filter, + default_value="all", + ) + + dpg.add_text("", tag="stats_text", color=(180, 180, 200)) + dpg.add_separator() + + with dpg.child_window(tag="sprite_list", autosize_x=True, autosize_y=True): + pass + + dpg.create_viewport(title="Imagen — Sprite Review", width=1000, height=750) + dpg.set_viewport_resizable(True) + dpg.configure_app(init_file="") + + texture_registry = dpg.add_texture_registry() + dpg.bind_texture_registry(texture_registry) + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.set_primary_window("main_window", True) + + _refresh() + + while dpg.is_dearpygui_running(): + dpg.render_dearpygui_frame() + + dpg.destroy_context() + + +if __name__ == "__main__": + main()