File size: 7,178 Bytes
186ad3c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3b7d0f4
 
 
 
 
 
186ad3c
 
 
 
 
 
 
 
 
 
 
 
3b7d0f4
 
 
 
 
 
 
 
186ad3c
 
 
 
 
3b7d0f4
186ad3c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
"""Compare image models side by side on a few fixed prompts, with latency and a contact sheet.

    uv run python -m scripts.bakeoff t2i black-forest-labs/FLUX.1-schnell Tongyi-MAI/Z-Image-Turbo
    uv run python -m scripts.bakeoff edit black-forest-labs/FLUX.2-klein-4B@fal-ai --source fox-snow

Each model is `huggingface-id@provider` (leave out `@provider` to let HF choose). Needs HF_TOKEN in
the environment or the .env file one folder above the repo, and costs real money (cents per run).
The prompts are fixed and harmless and bypass the app's safety checks, so this is a private
evaluation tool, never part of the app. Models with a non-commercial licence may be tried here for
evaluation only; do not enable them for visitors without permission. Images and the contact
sheet go to a temporary folder; compare actual cost on the Hugging Face billing page afterwards.
"""

from __future__ import annotations

import argparse
import asyncio
import io
import os
import sys
import tempfile
import time
from pathlib import Path

from PIL import Image, ImageDraw

from src.config import load_env_file
from src.errors import AppError
from src.logging_setup import configure_logging
from src.providers.hf_inference import SIZES, HFRunner, safety_extra
from src.services.library import LIBRARY_DIR

TIMEOUT_SECONDS = 90.0
CELL = 320
LABEL_WIDTH = 230
HEADER_HEIGHT = 40

T2I_PROMPTS = {
    "photo": "A candid photo of a home kitchen in the morning, steam rising from a coffee mug, "
    "crumbs on the counter, soft window light",
    "illustration": "A friendly dragon flipping pancakes in a small cottage kitchen, "
    "colorful illustration",
    "text": 'A wooden shop sign that reads "FIKA" above a cosy cafe door, rainy evening, photo',
}
EDIT_INSTRUCTIONS = {
    "evening": "Make it a warm evening scene with golden light. Keep the subject the same.",
    "playful": "Make it more playful and colorful. Keep the subject the same.",
    "painting": "Turn it into an oil painting. Keep the subject and composition the same.",
}

Cell = tuple[float, bytes | None, str]  # seconds, PNG bytes, error name


def parse(spec: str) -> tuple[str, str | None]:
    model, _, provider = spec.partition("@")
    return model, provider or None


async def run_cell(runner: HFRunner, spec: str, method: str, *args, **kwargs) -> Cell:
    model, provider = parse(spec)
    started = time.perf_counter()
    try:
        png = await runner.png(
            f"bakeoff {model}",
            provider,
            method,
            *args,
            model=model,
            extra_body=safety_extra(provider),
            **kwargs,
        )
    except AppError as error:
        return time.perf_counter() - started, None, type(error).__name__
    return time.perf_counter() - started, png, ""


def load_source(name: str) -> bytes:
    path = LIBRARY_DIR / f"{name}.webp"
    if not path.is_file():
        raise SystemExit(
            f"No library image '{name}' (run `git lfs checkout` if the files are pointers)"
        )
    buffer = io.BytesIO()
    Image.open(path).convert("RGB").save(buffer, format="PNG")
    return buffer.getvalue()


def sheet(
    rows: list[str], columns: list[str], cells: dict[tuple[str, str], Cell], source: bytes | None
) -> Image.Image:
    offset = 1 if source else 0
    width = LABEL_WIDTH + (len(columns) + offset) * CELL
    image = Image.new("RGB", (width, HEADER_HEIGHT + len(rows) * CELL), "white")
    draw = ImageDraw.Draw(image)
    if source:
        draw.text((LABEL_WIDTH + 6, 12), "source", fill="black")
    for index, column in enumerate(columns):
        draw.text((LABEL_WIDTH + (index + offset) * CELL + 6, 12), column, fill="black")
    for row_index, row in enumerate(rows):
        top = HEADER_HEIGHT + row_index * CELL
        draw.text((6, top + 6), row, fill="black")
        if source:
            with Image.open(io.BytesIO(source)) as original:
                image.paste(original.convert("RGB").resize((CELL, CELL)), (LABEL_WIDTH, top))
        for index, column in enumerate(columns):
            seconds, png, error = cells[(row, column)]
            left = LABEL_WIDTH + (index + offset) * CELL
            if png:
                with Image.open(io.BytesIO(png)) as result:
                    image.paste(result.convert("RGB").resize((CELL, CELL)), (left, top))
                draw.text((left + 4, top + 4), f"{seconds:.1f} s", fill="yellow")
            else:
                draw.text((left + 4, top + 4), f"failed: {error}", fill="red")
    return image


async def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("mode", choices=["t2i", "edit"])
    parser.add_argument("models", nargs="+", help="huggingface-id@provider")
    parser.add_argument("--source", default="fox-snow", help="library image to edit (edit mode)")
    parser.add_argument(
        "--instruction",
        action="append",
        default=[],
        help="edit instruction to try instead of the defaults (repeatable)",
    )
    args = parser.parse_args()

    sys.stdout.reconfigure(encoding="utf-8")
    configure_logging()
    load_env_file()
    token = os.environ.get("HF_TOKEN")
    if not token:
        print("Missing HF_TOKEN (set it in the shell or the .env file).")
        return 2
    runner = HFRunner(token, timeout=TIMEOUT_SECONDS)

    source = load_source(args.source) if args.mode == "edit" else None
    instructions = (
        {f"#{n}": text for n, text in enumerate(args.instruction, 1)}
        if args.instruction
        else EDIT_INSTRUCTIONS
    )
    for label, text in instructions.items() if args.instruction else []:
        print(f"{label}: {text}")
    columns = list(instructions if source else T2I_PROMPTS)
    cells: dict[tuple[str, str], Cell] = {}
    for spec in args.models:
        for column in columns:
            if source:
                cell = await run_cell(
                    runner, spec, "image_to_image", source, prompt=instructions[column]
                )
            else:
                width, height = SIZES["square"]
                cell = await run_cell(
                    runner,
                    spec,
                    "text_to_image",
                    T2I_PROMPTS[column],
                    width=width,
                    height=height,
                )
            cells[(spec, column)] = cell
            seconds, _, error = cell
            print(f"{spec:55} {column:13} {seconds:5.1f} s  {error or 'ok'}")

    folder = Path(tempfile.mkdtemp(prefix="bakeoff_"))
    for (spec, column), (_, png, _) in cells.items():
        if png:
            safe = spec.replace("/", "_").replace("@", "_at_")
            (folder / f"{safe}__{column}.png").write_bytes(png)
    sheet(args.models, columns, cells, source).save(folder / "sheet.png")
    ok = [seconds for seconds, png, _ in cells.values() if png]
    if ok:
        print(f"\nMedian latency {sorted(ok)[len(ok) // 2]:.1f} s over {len(ok)} images")
    print(f"Images and sheet.png: {folder}")
    return 0 if all(png for _, png, _ in cells.values()) else 1


if __name__ == "__main__":
    raise SystemExit(asyncio.run(main()))