Spaces:
Sleeping
Sleeping
| """ | |
| Pizza Size Labeler — Gradio web app (Hugging Face Spaces) | |
| =========================================================== | |
| Automatically loads photos from size.zip (bundled in this repo, extracted | |
| once and cached). Click the matching size button for each photo, then | |
| download the sorted result from the Output section. | |
| All diagnostic messages go to the backend console / Space container logs | |
| only (via the `logging` module) — nothing clutters the UI. | |
| """ | |
| import csv | |
| import logging | |
| import random | |
| import shutil | |
| import tempfile | |
| import zipfile | |
| from collections import Counter | |
| from datetime import datetime | |
| from pathlib import Path | |
| import gradio as gr | |
| from PIL import Image | |
| try: | |
| import spaces | |
| _HAS_SPACES = True | |
| except ImportError: | |
| _HAS_SPACES = False | |
| if _HAS_SPACES: | |
| def _zerogpu_warmup(): | |
| """No-op. This app never uses a GPU -- this function only exists so | |
| Hugging Face's ZeroGPU hardware tier finds a @spaces.GPU function at | |
| startup (it requires one, even if unused).""" | |
| return True | |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s [pizza_labeler] %(levelname)s: %(message)s') | |
| log = logging.getLogger('pizza_labeler') | |
| # ============================================================ | |
| # CONFIG | |
| # ============================================================ | |
| SIZE_CLASSES = { | |
| '20': 'pizza_20sm', | |
| '25': 'pizza_25sm', | |
| '30': 'pizza_30sm', | |
| '35': 'pizza_35sm', | |
| } | |
| SKIP_LABEL = '__skipped__' | |
| IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.bmp', '.webp'} | |
| IGNORE_NAME_FRAGMENTS = {'__MACOSX', '.DS_Store', 'Thumbs.db'} | |
| BUNDLED_ZIP_PATH = Path(__file__).resolve().parent / "size.zip" | |
| SHARED_CACHE_DIR = Path(tempfile.gettempdir()) / "pizza_shared_cache" | |
| _shared_bundle_cache = None # (extract_dir: Path, images: list[Path]) once computed | |
| # Output + manifest live outside any per-session temp dir so labeling | |
| # progress survives page reloads / new browser sessions for as long as the | |
| # container keeps running. | |
| PERSISTENT_OUTPUT_ROOT = SHARED_CACHE_DIR / "output" / "size_labeled" | |
| PERSISTENT_MANIFEST_PATH = PERSISTENT_OUTPUT_ROOT / "_manifest.csv" | |
| # ============================================================ | |
| # Helpers | |
| # ============================================================ | |
| def _is_junk(rel_path: Path) -> bool: | |
| return any(part in IGNORE_NAME_FRAGMENTS or part.startswith('.') for part in rel_path.parts) | |
| def find_images_recursive(root: Path): | |
| found = [] | |
| for p in root.rglob('*'): | |
| if p.is_file() and p.suffix.lower() in IMAGE_EXTS and not _is_junk(p.relative_to(root)): | |
| found.append(p) | |
| return sorted(found) | |
| def dest_filename_for(path: Path, scan_root: Path) -> str: | |
| rel = path.relative_to(scan_root) | |
| if rel.parent == Path('.'): | |
| return path.name | |
| parent_tag = '_'.join(rel.parent.parts) | |
| return f"{parent_tag}__{path.name}" | |
| def get_shared_bundled_images(): | |
| """Extract BUNDLED_ZIP_PATH exactly once per running container and cache | |
| the result in memory for every subsequent session/page load.""" | |
| global _shared_bundle_cache | |
| if _shared_bundle_cache is not None: | |
| return _shared_bundle_cache | |
| if not BUNDLED_ZIP_PATH.exists(): | |
| raise FileNotFoundError(f"{BUNDLED_ZIP_PATH.name} not found next to app.py") | |
| extract_dir = SHARED_CACHE_DIR / "input" | |
| marker = SHARED_CACHE_DIR / ".extracted_ok" | |
| if not marker.exists(): | |
| log.info(f"Extracting {BUNDLED_ZIP_PATH.name} ({BUNDLED_ZIP_PATH.stat().st_size / 1e6:.1f} MB) ...") | |
| extract_dir.mkdir(parents=True, exist_ok=True) | |
| with zipfile.ZipFile(BUNDLED_ZIP_PATH, 'r') as zf: | |
| zf.extractall(extract_dir) | |
| marker.touch() | |
| log.info("Extraction complete.") | |
| else: | |
| log.info("Using previously extracted cache (no re-extraction needed).") | |
| images = find_images_recursive(extract_dir) | |
| log.info(f"Found {len(images)} image file(s).") | |
| _shared_bundle_cache = (extract_dir, images) | |
| return _shared_bundle_cache | |
| def append_manifest(manifest_path: Path, key: str, label: str): | |
| is_new = not manifest_path.exists() | |
| with open(manifest_path, 'a', newline='', encoding='utf-8') as f: | |
| writer = csv.writer(f) | |
| if is_new: | |
| writer.writerow(['filename', 'label', 'timestamp']) | |
| writer.writerow([key, label, datetime.now().isoformat(timespec='seconds')]) | |
| def remove_last_manifest_entry(manifest_path: Path, key: str): | |
| if not manifest_path.exists(): | |
| return | |
| with open(manifest_path, newline='', encoding='utf-8') as f: | |
| rows = list(csv.reader(f)) | |
| if not rows: | |
| return | |
| header, body = rows[0], rows[1:] | |
| for i in range(len(body) - 1, -1, -1): | |
| if body[i][0] == key: | |
| del body[i] | |
| break | |
| with open(manifest_path, 'w', newline='', encoding='utf-8') as f: | |
| writer = csv.writer(f) | |
| writer.writerow(header) | |
| writer.writerows(body) | |
| def load_manifest(manifest_path: Path): | |
| counts = {} | |
| if manifest_path.exists(): | |
| with open(manifest_path, newline='', encoding='utf-8') as f: | |
| for row in csv.DictReader(f): | |
| counts[row['filename']] = row['label'] | |
| return counts | |
| def summary_text(manifest_path: Path): | |
| counts = Counter(load_manifest(manifest_path).values()) | |
| parts = [f"{key}: {counts.get(key, 0)}" for key in SIZE_CLASSES] | |
| parts.append(f"skipped: {counts.get(SKIP_LABEL, 0)}") | |
| return "Totals — " + " ".join(parts) | |
| def fresh_state(): | |
| return { | |
| 'input_root': None, | |
| 'output_root': None, | |
| 'manifest_path': None, | |
| 'images': [], # list of (abs_path_str, rel_key_str) | |
| 'idx': 0, | |
| 'history': [], # list of (abs_path_str, rel_key_str, label, dest_path_str_or_None) | |
| 'total_images': 0, | |
| } | |
| # ============================================================ | |
| # Core actions | |
| # ============================================================ | |
| def load_photos(state): | |
| state = fresh_state() | |
| output_root = PERSISTENT_OUTPUT_ROOT | |
| output_root.mkdir(parents=True, exist_ok=True) | |
| for folder in SIZE_CLASSES.values(): | |
| (output_root / folder).mkdir(parents=True, exist_ok=True) | |
| state['output_root'] = str(output_root) | |
| state['manifest_path'] = str(PERSISTENT_MANIFEST_PATH) | |
| try: | |
| input_root, all_images = get_shared_bundled_images() | |
| except Exception as e: | |
| log.error(f"Could not load bundled zip: {e}") | |
| return None, "Could not load photos — see server logs.", "", state | |
| state['input_root'] = str(input_root) | |
| if not all_images: | |
| log.warning("No images found in size.zip.") | |
| return None, "No images found.", "", state | |
| labeled_map = load_manifest(Path(state['manifest_path'])) | |
| pairs = [(str(p), str(p.relative_to(input_root).as_posix())) for p in all_images] | |
| remaining = [(p, k) for p, k in pairs if k not in labeled_map] | |
| random.Random(42).shuffle(remaining) | |
| state['images'] = remaining | |
| state['idx'] = 0 | |
| state['history'] = [] | |
| state['total_images'] = len(pairs) | |
| log.info(f"Session ready with {len(remaining)} photo(s) to label.") | |
| if not remaining: | |
| return None, "All photos already labeled.", summary_text(Path(state['manifest_path'])), state | |
| first_path, first_key = remaining[0] | |
| progress = f"labeled {len(labeled_map)} / {len(pairs)} — 1 / {len(remaining)} remaining ({first_key})" | |
| return first_path, progress, summary_text(Path(state['manifest_path'])), state | |
| def _current_view(state): | |
| manifest_path = Path(state['manifest_path']) | |
| idx = state['idx'] | |
| images = state['images'] | |
| total = state.get('total_images', len(images)) | |
| labeled_so_far = total - len(images) + idx | |
| if idx >= len(images): | |
| return None, f"All done! {labeled_so_far} / {total} labeled.", summary_text(manifest_path), state | |
| abs_path, key = images[idx] | |
| progress = f"labeled {labeled_so_far} / {total} — {idx + 1} / {len(images)} remaining ({key})" | |
| return abs_path, progress, summary_text(manifest_path), state | |
| def _advance(state, label_key): | |
| manifest_path = Path(state['manifest_path']) | |
| input_root = Path(state['input_root']) | |
| output_root = Path(state['output_root']) | |
| idx = state['idx'] | |
| images = state['images'] | |
| if idx >= len(images): | |
| return _current_view(state) | |
| abs_path, key = images[idx] | |
| path = Path(abs_path) | |
| if label_key == SKIP_LABEL: | |
| dest_path = None | |
| log.info(f"Skipped: {key}") | |
| else: | |
| folder = SIZE_CLASSES[label_key] | |
| dest_name = dest_filename_for(path, input_root) | |
| dest_path = output_root / folder / dest_name | |
| shutil.copy2(path, dest_path) | |
| log.info(f'Labeled "{key}" -> {label_key} ({folder}/{dest_name})') | |
| append_manifest(manifest_path, key, label_key) | |
| state['history'].append((abs_path, key, label_key, str(dest_path) if dest_path else None)) | |
| state['idx'] += 1 | |
| return _current_view(state) | |
| def label_20(state): | |
| return _advance(state, '20') | |
| def label_25(state): | |
| return _advance(state, '25') | |
| def label_30(state): | |
| return _advance(state, '30') | |
| def label_35(state): | |
| return _advance(state, '35') | |
| def skip(state): | |
| return _advance(state, SKIP_LABEL) | |
| def undo(state): | |
| if not state.get('history'): | |
| log.warning("Nothing to undo.") | |
| return _current_view(state) | |
| abs_path, key, label_key, dest_path = state['history'].pop() | |
| if dest_path: | |
| p = Path(dest_path) | |
| if p.exists(): | |
| p.unlink() | |
| remove_last_manifest_entry(Path(state['manifest_path']), key) | |
| state['idx'] -= 1 | |
| log.info(f"Undid label for: {key}") | |
| return _current_view(state) | |
| def prepare_download(state): | |
| if not state.get('output_root'): | |
| log.warning("Nothing to download yet.") | |
| return None | |
| output_root = Path(state['output_root']) | |
| work_dir = Path(tempfile.mkdtemp(prefix='pizza_zip_')) | |
| zip_path = work_dir / 'pizza_labeled_output.zip' | |
| with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: | |
| for f in output_root.rglob('*'): | |
| if f.is_file(): | |
| zf.write(f, f.relative_to(output_root.parent)) | |
| log.info(f"Prepared download: {zip_path.name}") | |
| return str(zip_path) | |
| # ============================================================ | |
| # UI | |
| # ============================================================ | |
| with gr.Blocks(title="Pizza Size Labeler") as demo: | |
| state = gr.State(fresh_state()) | |
| gr.Markdown( | |
| "## 🍕 Pizza Size Labeler\n" | |
| "Click the size that matches the photo (20 / 25 / 30 / 35)." | |
| ) | |
| progress_box = gr.Textbox(label="Progress", interactive=False) | |
| image_view = gr.Image(label="Current photo", type="filepath", height=420) | |
| with gr.Row(): | |
| b20 = gr.Button("20") | |
| b25 = gr.Button("25") | |
| b30 = gr.Button("30") | |
| b35 = gr.Button("35") | |
| with gr.Row(): | |
| skip_btn = gr.Button("Skip") | |
| undo_btn = gr.Button("Undo") | |
| summary_box = gr.Textbox(label="Totals", interactive=False) | |
| with gr.Row(): | |
| download_btn = gr.Button("Prepare download zip ⬇", variant="primary") | |
| download_file = gr.File(label="Labeled output (zip)", interactive=False) | |
| demo.load(load_photos, inputs=[state], outputs=[image_view, progress_box, summary_box, state]) | |
| for btn, fn in [(b20, label_20), (b25, label_25), (b30, label_30), (b35, label_35), | |
| (skip_btn, skip), (undo_btn, undo)]: | |
| btn.click(fn, inputs=[state], outputs=[image_view, progress_box, summary_box, state]) | |
| download_btn.click(prepare_download, inputs=[state], outputs=[download_file]) | |
| if __name__ == "__main__": | |
| demo.launch() |