diff --git a/cleaner.py b/cleaner.py new file mode 100644 index 0000000..4caac6f --- /dev/null +++ b/cleaner.py @@ -0,0 +1,222 @@ +"""Core garment photo cleaning logic for CLI and API reuse.""" + +import base64 +from dataclasses import dataclass +import io +import mimetypes +import os +from typing import Any + +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_bytes(data: bytes, mime: str = "image/jpeg") -> str: + return f"data:{mime};base64,{base64.b64encode(data).decode()}" + + +def encode_image(path: str) -> str: + mime = mimetypes.guess_type(path)[0] or "image/png" + with open(path, "rb") as f: + return encode_image_bytes(f.read(), mime=mime) + + +def restore_resolution_bytes( + gen_bytes: bytes, + orig_width: int, + orig_height: int, + fmt: str = "JPEG", +) -> bytes: + """Resize generated image bytes back to the original dimensions using Lanczos resampling.""" + try: + from PIL import Image + except ImportError: + return gen_bytes + + with Image.open(io.BytesIO(gen_bytes)) as gen: + if (gen.width, gen.height) == (orig_width, orig_height): + return gen_bytes + resample = getattr(Image, "Resampling", Image).LANCZOS + resized = gen.resize((orig_width, orig_height), resample) + buf = io.BytesIO() + kwargs = {"quality": 95} if fmt.upper() in ("JPEG", "JPG") else {} + resized.save(buf, format=fmt, **kwargs) + return buf.getvalue() + + +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 + resample = getattr(Image, "Resampling", Image).LANCZOS + resized = gen.resize(orig.size, resample) + 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}") + + +class CleanerError(Exception): + """Base error raised when cleaning or calling OpenRouter fails.""" + pass + + +@dataclass +class CleanResult: + image_bytes: bytes + media_type: str = "image/jpeg" + cost: float | None = None + width: int | None = None + height: int | None = None + original_dimensions: tuple[int, int] | None = None + model_dimensions: tuple[int, int] | None = None + + +def call_openrouter_images( + image_url_or_data_uri: str, + prompt: str, + api_key: str, + model: str = DEFAULT_MODEL, + api_url: str = API_URL, + timeout: int = 300, +) -> dict: + """Submit image edit request to OpenRouter images API.""" + headers = {"Authorization": f"Bearer {api_key}"} + payload: dict[str, Any] = { + "model": model, + "prompt": prompt, + "background": "opaque", + "input_references": [ + {"type": "image_url", "image_url": {"url": image_url_or_data_uri}} + ], + "output_format": "jpeg", + } + resp = requests.post(api_url, headers=headers, json=payload, timeout=timeout) + if resp.status_code != 200: + try: + message = resp.json()["error"]["message"] + except Exception: + message = resp.text + raise CleanerError(f"API error {resp.status_code}: {message}") + return resp.json() + + +def clean_garment( + image_data: bytes | str, + prompt: str, + api_key: str, + model: str = DEFAULT_MODEL, + restore_res: bool = True, + mime: str = "image/jpeg", + api_url: str = API_URL, + timeout: int = 300, +) -> CleanResult: + """Clean garment image given file path or bytes, returning CleanResult.""" + orig_width: int | None = None + orig_height: int | None = None + + if isinstance(image_data, bytes): + try: + from PIL import Image + with Image.open(io.BytesIO(image_data)) as im: + orig_width, orig_height = im.size + if im.format: + mime = Image.MIME.get(im.format, mime) + except Exception: + pass + data_uri = encode_image_bytes(image_data, mime=mime) + elif isinstance(image_data, str): + if image_data.startswith("data:"): + data_uri = image_data + else: + data_uri = encode_image(image_data) + try: + from PIL import Image + with Image.open(image_data) as im: + orig_width, orig_height = im.size + except Exception: + pass + else: + raise TypeError("image_data must be bytes or str path") + + result = call_openrouter_images( + image_url_or_data_uri=data_uri, + prompt=prompt, + api_key=api_key, + model=model, + api_url=api_url, + timeout=timeout, + ) + + image_entry = result["data"][0] + gen_bytes = base64.b64decode(image_entry["b64_json"]) + media_type = image_entry.get("media_type", "image/jpeg") + cost = result.get("usage", {}).get("cost") + model_dimensions: tuple[int, int] | None = None + try: + from PIL import Image + with Image.open(io.BytesIO(gen_bytes)) as im: + model_dimensions = im.size + except Exception: + pass + + if restore_res and orig_width is not None and orig_height is not None: + fmt = "JPEG" if media_type == "image/jpeg" else "PNG" + gen_bytes = restore_resolution_bytes( + gen_bytes, orig_width, orig_height, fmt=fmt + ) + + width: int | None = orig_width if (restore_res and orig_width is not None) else (model_dimensions[0] if model_dimensions else None) + height: int | None = orig_height if (restore_res and orig_height is not None) else (model_dimensions[1] if model_dimensions else None) + try: + from PIL import Image + with Image.open(io.BytesIO(gen_bytes)) as im: + width, height = im.size + except Exception: + pass + + orig_dimensions = (orig_width, orig_height) if (orig_width is not None and orig_height is not None) else None + + return CleanResult( + image_bytes=gen_bytes, + media_type=media_type, + cost=cost, + width=width, + height=height, + original_dimensions=orig_dimensions, + model_dimensions=model_dimensions, + ) diff --git a/tests/test_cleaner.py b/tests/test_cleaner.py new file mode 100644 index 0000000..460ae80 --- /dev/null +++ b/tests/test_cleaner.py @@ -0,0 +1,287 @@ +import pytest +from cleaner import ( + API_URL, + DEFAULT_MODEL, + BACKGROUND_PRESETS, + EXTENSIONS, + build_prompt, + encode_image, + encode_image_bytes, + restore_resolution, + restore_resolution_bytes, + call_openrouter_images, + clean_garment, + CleanResult, + CleanerError, +) +import io +from PIL import Image + + +def test_constants(): + assert API_URL == "https://openrouter.ai/api/v1/images" + assert "beige" in BACKGROUND_PRESETS + assert "white" in BACKGROUND_PRESETS + assert EXTENSIONS["image/jpeg"] == ".jpg" + assert EXTENSIONS["image/png"] == ".png" + assert EXTENSIONS["image/webp"] == ".webp" + + +def test_build_prompt_presets(): + prompt_beige = build_prompt("beige") + assert BACKGROUND_PRESETS["beige"] in prompt_beige + assert "Remove ALL wrinkles" in prompt_beige + + prompt_white = build_prompt("white") + assert BACKGROUND_PRESETS["white"] in prompt_white + assert "Remove ALL wrinkles" in prompt_white + + # Case insensitivity + assert build_prompt("BEIGE") == prompt_beige + + +def test_build_prompt_custom(): + custom = "Vintage wooden floor with warm sidelight." + prompt_custom = build_prompt(custom) + assert custom in prompt_custom + assert "Remove ALL wrinkles" in prompt_custom + + +def test_encode_image_bytes(): + raw = b"fake-image-bytes" + encoded = encode_image_bytes(raw, "image/jpeg") + assert encoded == "data:image/jpeg;base64,ZmFrZS1pbWFnZS1ieXRlcw==" + + # Default mime + encoded_default = encode_image_bytes(raw) + assert encoded_default.startswith("data:image/jpeg;base64,") + + +def test_encode_image_file(tmp_path): + img_file = tmp_path / "test.png" + img_file.write_bytes(b"png-data") + encoded = encode_image(str(img_file)) + assert encoded == "data:image/png;base64,cG5nLWRhdGE=" + + jpg_file = tmp_path / "test.jpg" + jpg_file.write_bytes(b"jpg-data") + encoded_jpg = encode_image(str(jpg_file)) + assert encoded_jpg == "data:image/jpeg;base64,anBnLWRhdGE=" + + unknown_file = tmp_path / "test.customext" + unknown_file.write_bytes(b"custom-data") + encoded_unknown = encode_image(str(unknown_file)) + assert encoded_unknown.startswith("data:image/png;base64,") + + +def create_test_image(size=(100, 100), fmt="JPEG"): + buf = io.BytesIO() + img = Image.new("RGB", size, color="blue") + img.save(buf, format=fmt) + return buf.getvalue() + + +def test_restore_resolution_bytes_different_dimensions(): + gen_data = create_test_image(size=(100, 100), fmt="JPEG") + orig_width, orig_height = 200, 300 + + restored = restore_resolution_bytes(gen_data, orig_width, orig_height, fmt="JPEG") + assert restored != gen_data + + with Image.open(io.BytesIO(restored)) as im: + assert im.size == (200, 300) + + +def test_restore_resolution_bytes_same_dimensions_noop(): + gen_data = create_test_image(size=(100, 100), fmt="JPEG") + restored = restore_resolution_bytes(gen_data, 100, 100, fmt="JPEG") + assert restored is gen_data # Exact same object/bytes, no re-encoding + + +def test_restore_resolution_files(tmp_path): + orig_file = tmp_path / "orig.jpg" + out_file = tmp_path / "out.jpg" + + orig_img = Image.new("RGB", (300, 400), color="red") + orig_img.save(str(orig_file), format="JPEG") + + gen_img = Image.new("RGB", (150, 200), color="green") + gen_img.save(str(out_file), format="JPEG") + + restore_resolution(str(out_file), str(orig_file)) + + with Image.open(str(out_file)) as im: + assert im.size == (300, 400) + + +def test_restore_resolution_files_same_dimensions(tmp_path): + orig_file = tmp_path / "orig.jpg" + out_file = tmp_path / "out.jpg" + + orig_img = Image.new("RGB", (200, 200), color="red") + orig_img.save(str(orig_file), format="JPEG") + orig_img.save(str(out_file), format="JPEG") + + mtime_before = out_file.stat().st_mtime_ns + restore_resolution(str(out_file), str(orig_file)) + mtime_after = out_file.stat().st_mtime_ns + + # File should not have been overwritten + assert mtime_before == mtime_after + with Image.open(str(out_file)) as im: + assert im.size == (200, 200) + + +def test_call_openrouter_images_success(monkeypatch): + def mock_post(url, headers, json, timeout): + assert url == API_URL + assert headers == {"Authorization": "Bearer test-key"} + assert json["model"] == DEFAULT_MODEL + assert json["prompt"] == "test prompt" + assert json["background"] == "opaque" + assert json["output_format"] == "jpeg" + assert json["input_references"][0]["image_url"]["url"] == "data:image/jpeg;base64,abc" + assert timeout == 300 + + class MockResponse: + status_code = 200 + + def json(self): + return { + "data": [{"b64_json": "ZGF0YQ==", "media_type": "image/jpeg"}], + "usage": {"cost": 0.05}, + } + + return MockResponse() + + import requests + monkeypatch.setattr(requests, "post", mock_post) + + result = call_openrouter_images( + image_url_or_data_uri="data:image/jpeg;base64,abc", + prompt="test prompt", + api_key="test-key", + ) + assert result["data"][0]["b64_json"] == "ZGF0YQ==" + assert result["usage"]["cost"] == 0.05 + + +def test_call_openrouter_images_error_json(monkeypatch): + def mock_post(url, headers, json, timeout): + class MockResponse: + status_code = 401 + text = '{"error": {"message": "Invalid API key"}}' + + def json(self): + return {"error": {"message": "Invalid API key"}} + + return MockResponse() + + import requests + monkeypatch.setattr(requests, "post", mock_post) + + with pytest.raises(CleanerError) as exc_info: + call_openrouter_images( + image_url_or_data_uri="data:image/jpeg;base64,abc", + prompt="test prompt", + api_key="invalid-key", + ) + assert "API error 401: Invalid API key" in str(exc_info.value) + + +def test_call_openrouter_images_error_text(monkeypatch): + def mock_post(url, headers, json, timeout): + class MockResponse: + status_code = 502 + text = "Bad Gateway" + + def json(self): + raise ValueError("Not JSON") + + return MockResponse() + + import requests + monkeypatch.setattr(requests, "post", mock_post) + + with pytest.raises(CleanerError) as exc_info: + call_openrouter_images( + image_url_or_data_uri="data:image/jpeg;base64,abc", + prompt="test prompt", + api_key="test-key", + ) + assert "API error 502: Bad Gateway" in str(exc_info.value) + + +def test_clean_garment_bytes(monkeypatch): + orig_bytes = create_test_image(size=(300, 400), fmt="JPEG") + generated_bytes = create_test_image(size=(150, 200), fmt="JPEG") + import base64 + b64_gen = base64.b64encode(generated_bytes).decode() + + def mock_call_openrouter(image_url_or_data_uri, prompt, api_key, model=DEFAULT_MODEL, api_url=API_URL, timeout=300): + assert image_url_or_data_uri.startswith("data:image/jpeg;base64,") + assert prompt == "test prompt" + assert api_key == "test-key" + return { + "data": [{"b64_json": b64_gen, "media_type": "image/jpeg"}], + "usage": {"cost": 0.02}, + } + + import cleaner + monkeypatch.setattr(cleaner, "call_openrouter_images", mock_call_openrouter) + + # With restore_res=True + res = clean_garment( + image_data=orig_bytes, + prompt="test prompt", + api_key="test-key", + restore_res=True, + ) + assert isinstance(res, CleanResult) + assert res.media_type == "image/jpeg" + assert res.cost == 0.02 + assert res.width == 300 + assert res.height == 400 + with Image.open(io.BytesIO(res.image_bytes)) as im: + assert im.size == (300, 400) + + # With restore_res=False + res_unscaled = clean_garment( + image_data=orig_bytes, + prompt="test prompt", + api_key="test-key", + restore_res=False, + ) + assert res_unscaled.width == 150 + assert res_unscaled.height == 200 + with Image.open(io.BytesIO(res_unscaled.image_bytes)) as im: + assert im.size == (150, 200) + + +def test_clean_garment_file(tmp_path, monkeypatch): + orig_file = tmp_path / "garment.jpg" + orig_img = Image.new("RGB", (250, 350), color="yellow") + orig_img.save(str(orig_file), format="JPEG") + + gen_bytes = create_test_image(size=(100, 100), fmt="JPEG") + import base64 + b64_gen = base64.b64encode(gen_bytes).decode() + + def mock_call_openrouter(image_url_or_data_uri, prompt, api_key, model=DEFAULT_MODEL, api_url=API_URL, timeout=300): + return { + "data": [{"b64_json": b64_gen, "media_type": "image/jpeg"}], + "usage": {"cost": 0.03}, + } + + import cleaner + monkeypatch.setattr(cleaner, "call_openrouter_images", mock_call_openrouter) + + res = clean_garment( + image_data=str(orig_file), + prompt="test prompt", + api_key="test-key", + restore_res=True, + ) + assert res.width == 250 + assert res.height == 350 + assert res.cost == 0.03 diff --git a/tests/test_unwrap_clothes.py b/tests/test_unwrap_clothes.py new file mode 100644 index 0000000..6e4c4b6 --- /dev/null +++ b/tests/test_unwrap_clothes.py @@ -0,0 +1,130 @@ +import io +import subprocess +import sys + +from PIL import Image +import pytest + +import unwrap_clothes + + +def test_reexported_symbols(): + # Ensure backwards compatibility for any caller importing from unwrap_clothes + assert hasattr(unwrap_clothes, "API_URL") + assert hasattr(unwrap_clothes, "DEFAULT_MODEL") + assert hasattr(unwrap_clothes, "BACKGROUND_PRESETS") + assert hasattr(unwrap_clothes, "EXTENSIONS") + assert hasattr(unwrap_clothes, "build_prompt") + assert hasattr(unwrap_clothes, "encode_image") + assert hasattr(unwrap_clothes, "restore_resolution") + + +def test_cli_help(): + result = subprocess.run( + [sys.executable, "unwrap_clothes.py", "--help"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "Unwrinkle clothing photos and unify backgrounds via OpenRouter." in result.stdout + assert "--bg" in result.stdout + assert "--model" in result.stdout + assert "--api-key" in result.stdout + assert "--no-restore-res" in result.stdout + + +def test_main_file_not_found(monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["unwrap_clothes.py", "non_existent_file.jpg", "--api-key", "key"]) + with pytest.raises(SystemExit) as exc: + unwrap_clothes.main() + assert "Error: file not found: non_existent_file.jpg" in str(exc.value) + + +def test_main_missing_api_key(tmp_path, monkeypatch, capsys): + img = tmp_path / "img.jpg" + img.write_bytes(b"data") + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr(sys, "argv", ["unwrap_clothes.py", str(img)]) + with pytest.raises(SystemExit) as exc: + unwrap_clothes.main() + assert "missing API key" in str(exc.value) + + +def test_main_success_flow(tmp_path, monkeypatch, capsys): + # Setup test image + src_file = tmp_path / "source.jpg" + im = Image.new("RGB", (200, 300), color="blue") + im.save(str(src_file), format="JPEG") + + out_file = tmp_path / "custom_out.jpg" + + from cleaner import CleanResult + + cleaned_img = Image.new("RGB", (200, 300), color="white") + buf = io.BytesIO() + cleaned_img.save(buf, format="JPEG") + fake_result = CleanResult( + image_bytes=buf.getvalue(), + media_type="image/jpeg", + cost=0.04, + width=200, + height=300, + ) + + clean_garment_called = {} + + def mock_clean_garment(image_data, prompt, api_key, model, restore_res): + clean_garment_called["image_data"] = image_data + clean_garment_called["prompt"] = prompt + clean_garment_called["api_key"] = api_key + clean_garment_called["model"] = model + clean_garment_called["restore_res"] = restore_res + return fake_result + + monkeypatch.setattr("unwrap_clothes.clean_garment", mock_clean_garment) + monkeypatch.setattr( + sys, + "argv", + [ + "unwrap_clothes.py", + str(src_file), + "-o", + str(out_file), + "--bg", + "beige", + "--api-key", + "test-token", + ], + ) + + unwrap_clothes.main() + + assert clean_garment_called["api_key"] == "test-token" + assert clean_garment_called["restore_res"] is True + assert out_file.exists() + assert out_file.read_bytes() == fake_result.image_bytes + + captured = capsys.readouterr() + assert f"Submitting to {unwrap_clothes.DEFAULT_MODEL}..." in captured.out + assert f"Saved: {out_file} (cost $0.04)" in captured.out + + +def test_main_api_error_handling(tmp_path, monkeypatch): + src_file = tmp_path / "source.jpg" + src_file.write_bytes(b"dummy") + + from cleaner import CleanerError + + def mock_clean_garment(*args, **kwargs): + raise CleanerError("API error 400: Invalid image format") + + monkeypatch.setattr("unwrap_clothes.clean_garment", mock_clean_garment) + monkeypatch.setattr( + sys, + "argv", + ["unwrap_clothes.py", str(src_file), "--api-key", "token"], + ) + + with pytest.raises(SystemExit) as exc: + unwrap_clothes.main() + assert "API error 400: Invalid image format" in str(exc.value) diff --git a/unwrap_clothes.py b/unwrap_clothes.py index 76cf91c..5098ab2 100755 --- a/unwrap_clothes.py +++ b/unwrap_clothes.py @@ -6,64 +6,40 @@ and replacing the background with a studio backdrop. """ import argparse -import base64 -import mimetypes import os import sys -import requests +from cleaner import ( + API_URL, + BACKGROUND_PRESETS, + CleanerError, + CleanResult, + DEFAULT_MODEL, + EXTENSIONS, + build_prompt, + clean_garment, + encode_image, + encode_image_bytes, + restore_resolution, + restore_resolution_bytes, +) -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}") +__all__ = [ + "API_URL", + "DEFAULT_MODEL", + "BACKGROUND_PRESETS", + "EXTENSIONS", + "build_prompt", + "encode_image", + "encode_image_bytes", + "restore_resolution", + "restore_resolution_bytes", + "clean_garment", + "CleanResult", + "CleanerError", + "parse_args", + "main", +] def parse_args() -> argparse.Namespace: @@ -113,44 +89,35 @@ def main() -> None: 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") + try: + result = clean_garment( + image_data=args.image, + prompt=prompt, + api_key=args.api_key, + model=args.model, + restore_res=not args.no_restore_res, + ) + except CleanerError as e: + sys.exit(str(e)) + ext = EXTENSIONS.get(result.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"])) + f.write(result.image_bytes) - if not args.no_restore_res: - restore_resolution(out, args.image) + if ( + not args.no_restore_res + and result.model_dimensions is not None + and result.original_dimensions is not None + and result.model_dimensions != result.original_dimensions + ): + print(f"Restored resolution: {result.model_dimensions} -> {result.original_dimensions}") - cost = result.get("usage", {}).get("cost") + cost = result.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 + main()