commit f4398998532f1ecfd4741b87cc320414d3ed6e8c Author: Schmidt Till (CSS TO PME DSI PAO MUC) Date: Thu Sep 10 15:26:38 2026 +0200 Initial commit: garment photo unwrinkle and background cleanup tool diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..349851f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*$py.class +*.env +.env +.venv/ +env/ +*_clean.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..b26e5ad --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Garment photo cleaner + +CLI tool that cleans up second-hand clothing photos for marketplace listings (Vinted, eBay, Depop). It removes fabric wrinkles, puts the garment on a studio backdrop (warm beige or bright white), and preserves shapes, colors, prints, buttons, and defects. + +Uses OpenRouter's Image API with `meta/muse-image`. + +## Requirements + +- Python 3.9 or higher +- OpenRouter API key with image generation credits + +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +Set your API key: + +```bash +export OPENROUTER_API_KEY="sk-or-v1-..." +``` + +## Usage + +Basic run with default soft beige background: + +```bash +python3 unwrap_clothes.py shirt.jpg +``` + +Output saves next to the source photo as `shirt_clean.jpg`. + +### Options + +Select a white background: + +```bash +python3 unwrap_clothes.py shirt.jpg --bg white +``` + +Custom output path: + +```bash +python3 unwrap_clothes.py shirt.jpg -o /path/to/listing_photo.jpg +``` + +Custom background description: + +```bash +python3 unwrap_clothes.py shirt.jpg --bg "Soft cool grey studio background, even lighting" +``` + +Keep native model resolution (skip automatic upscale to input dimensions): + +```bash +python3 unwrap_clothes.py shirt.jpg --no-restore-res +``` + +Override the model: + +```bash +python3 unwrap_clothes.py shirt.jpg --model meta/muse-image +``` + +## Why meta/muse-image is the default + +Other image models on OpenRouter run into policy or cost issues on second-hand clothing photos: + +1. `openai/gpt-5-image-mini`: OpenAI's input image filter rejects photos with licensed character artwork (such as Winnie the Pooh, Disney, or cartoon prints), returning HTTP 400 safety errors regardless of prompt phrasing. +2. `qwen/qwen-image-3`: Alibaba moderation frequently blocks full garment photos, and Qwen 3 Pro costs roughly $0.08 per image. +3. `meta/muse-image`: Accepts photos of branded and character clothing, costs $0.01 per image, and finishes in 15 to 25 seconds. + +## Resolution handling + +`meta/muse-image` caps output at roughly 1.3 to 1.8 MP (for example, 1376x1824) regardless of the size parameters passed to the API. + +To avoid downsized uploads on marketplaces that expect high-resolution smartphone photos (such as 3024x4032), the script automatically resizes the generated image back to the source file's exact dimensions using Lanczos interpolation. This preserves the aspect ratio and frame size of the original photo. + +## Known limitations + +- Garments that are physically folded or bunched over themselves in the photo remain bunched. Generative editing smooths surface wrinkles, but will not unfold fabric that covers other parts of the garment. +- Small text on labels and complex repeating patterns can drift slightly from the original during generation. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eb20200 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.28.0 +Pillow>=9.0.0 \ No newline at end of file diff --git a/unwrap_clothes.py b/unwrap_clothes.py new file mode 100755 index 0000000..76cf91c --- /dev/null +++ b/unwrap_clothes.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Unwrinkle clothing photos and unify backgrounds for marketplace listings. + +Preserves garment shape, colors, prints, and defects while removing creases +and replacing the background with a studio backdrop. +""" + +import argparse +import base64 +import mimetypes +import os +import sys + +import requests + +API_URL = "https://openrouter.ai/api/v1/images" +DEFAULT_MODEL = os.environ.get("MODEL", "meta/muse-image") + +BACKGROUND_PRESETS = { + "beige": "Soft warm beige studio background, evenly lit, no shadows, no props.", + "white": "Plain bright white studio backdrop, evenly lit, no shadows, no gradients, no props.", +} + +EXTENSIONS = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/webp": ".webp", +} + + +def build_prompt(bg_choice: str) -> str: + bg_description = BACKGROUND_PRESETS.get(bg_choice.lower(), bg_choice) + return ( + "Product photography edit of this garment photo. Keep the garments pixel-faithful: identical " + "shape, colors, prints, labels, buttons, and every existing flaw unchanged. Remove ALL wrinkles " + "and creases completely: the fabric must look perfectly smooth and freshly ironed, flat like a " + f"new catalog product photo. {bg_description}" + ) + + +def encode_image(path: str) -> str: + mime = mimetypes.guess_type(path)[0] or "image/png" + with open(path, "rb") as f: + return f"data:{mime};base64,{base64.b64encode(f.read()).decode()}" + + +def restore_resolution(out: str, src: str) -> None: + """Resize the generated image back to the input dimensions using Lanczos resampling.""" + try: + from PIL import Image + except ImportError: + print("Pillow not installed; skipping resolution restore (pip install Pillow)") + return + + with Image.open(src) as orig, Image.open(out) as gen: + if gen.size == orig.size: + return + resized = gen.resize(orig.size, Image.LANCZOS) + ext = os.path.splitext(out)[1].lower() + fmt = "JPEG" if ext in (".jpg", ".jpeg") else None + kwargs = {"quality": 95} if fmt == "JPEG" else {} + if fmt: + resized.save(out, fmt, **kwargs) + else: + resized.save(out) + print(f"Restored resolution: {gen.size} -> {orig.size}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Unwrinkle clothing photos and unify backgrounds via OpenRouter." + ) + parser.add_argument("image", help="Path to input garment photo") + parser.add_argument( + "-o", + "--output", + help="Path for cleaned output image (defaults to _clean.)", + ) + parser.add_argument( + "--bg", + default="beige", + help="Background preset ('beige', 'white') or a custom background description", + ) + parser.add_argument( + "--model", + default=DEFAULT_MODEL, + help=f"OpenRouter image model slug (default: {DEFAULT_MODEL})", + ) + parser.add_argument( + "--api-key", + default=os.environ.get("OPENROUTER_API_KEY"), + help="OpenRouter API key (defaults to OPENROUTER_API_KEY env var)", + ) + parser.add_argument( + "--no-restore-res", + action="store_true", + help="Skip upscaling back to source photo dimensions", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + if not os.path.isfile(args.image): + sys.exit(f"Error: file not found: {args.image}") + + if not args.api_key: + sys.exit( + "Error: missing API key. Set OPENROUTER_API_KEY in your environment or pass --api-key." + ) + + prompt = build_prompt(args.bg) + + print(f"Submitting to {args.model}...") + resp = requests.post( + API_URL, + headers={"Authorization": f"Bearer {args.api_key}"}, + json={ + "model": args.model, + "prompt": prompt, + "background": "opaque", + "input_references": [ + {"type": "image_url", "image_url": {"url": encode_image(args.image)}} + ], + "output_format": "jpeg", + }, + timeout=300, + ) + + if resp.status_code != 200: + try: + message = resp.json()["error"]["message"] + except Exception: + message = resp.text + sys.exit(f"API error {resp.status_code}: {message}") + + result = resp.json() + image = result["data"][0] + ext = EXTENSIONS.get(image.get("media_type"), ".jpg") + + out = args.output or (os.path.splitext(args.image)[0] + "_clean" + ext) + + with open(out, "wb") as f: + f.write(base64.b64decode(image["b64_json"])) + + if not args.no_restore_res: + restore_resolution(out, args.image) + + cost = result.get("usage", {}).get("cost") + cost_str = f" (cost ${cost:.2f})" if cost is not None else "" + print(f"Saved: {out}{cost_str}") + + +if __name__ == "__main__": + main() \ No newline at end of file