image-lab / scripts /build_library.py
Magnus Persson
Add the ideas popup with cached example images; drop the format picker
0016651
Raw History Blame Contribute Delete
6.98 kB
"""Make the cached example images for the ideas library (config/prompt_library.yaml).
uv run python -m scripts.build_library --dry-run # what would be made, and the cost
uv run python -m scripts.build_library # make the missing images
uv run python -m scripts.build_library --only fox-snow --force
Uses the real image provider, so it needs the image backend's key (HF_TOKEN) and OPENAI_API_KEY in
the environment or the .env file, and costs about one image per missing prompt. Each prompt (in
every language) and each image goes through the same safety checks as a visitor's Create. Images
are square, made with the default Create model and no style, saved as webp in assets/library/.
The run ends by writing config/prompt_library.lock.json, which ties each image to the texts it was
made for; the app hides any prompt that no longer matches it. Look at the contact sheet it prints,
delete any image you dislike and run again (a new one is made), then commit the images and the
lock file. Running it with nothing missing only refreshes the lock file and the sheet.
"""
from __future__ import annotations
import argparse
import asyncio
import io
import os
import sys
import tempfile
from pathlib import Path
from PIL import Image, ImageDraw
from src.config import Settings, load_env_file, load_settings
from src.errors import AppError, SafetyRefusalError
from src.logging_setup import configure_logging
from src.providers.base import ImageRequest
from src.providers.factory import build_image, build_moderator, build_text
from src.services.library import (
LOCK_FILE,
LibraryPrompt,
PromptLibrary,
load_library,
write_lock,
)
from src.services.prompts import compose_prompt
from src.services.safety import SafetyService
CONCURRENCY = 3
WEBP_QUALITY = 85
SHEET_COLUMNS = 6
SHEET_CELL = 256
def to_webp(png: bytes) -> bytes:
buffer = io.BytesIO()
Image.open(io.BytesIO(png)).convert("RGB").save(buffer, format="WEBP", quality=WEBP_QUALITY)
return buffer.getvalue()
def contact_sheet(library: PromptLibrary) -> Path | None:
prompts = [p for p in library.prompts() if library.image_path(p).is_file()]
if not prompts:
return None
rows = -(-len(prompts) // SHEET_COLUMNS)
sheet = Image.new("RGB", (SHEET_COLUMNS * SHEET_CELL, rows * SHEET_CELL), "white")
draw = ImageDraw.Draw(sheet)
for index, prompt in enumerate(prompts):
with Image.open(library.image_path(prompt)) as image:
cell = image.convert("RGB").resize((SHEET_CELL, SHEET_CELL))
x, y = (index % SHEET_COLUMNS) * SHEET_CELL, (index // SHEET_COLUMNS) * SHEET_CELL
sheet.paste(cell, (x, y))
draw.rectangle((x, y, x + SHEET_CELL, y + 14), fill="black")
draw.text((x + 3, y + 1), prompt.id, fill="white")
with tempfile.NamedTemporaryFile(prefix="library_sheet_", suffix=".png", delete=False) as file:
sheet.save(file, format="PNG")
return Path(file.name)
async def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--force", action="store_true", help="remake images that already exist")
parser.add_argument("--only", action="append", default=[], help="a prompt id (repeatable)")
args = parser.parse_args()
sys.stdout.reconfigure(encoding="utf-8")
configure_logging()
load_env_file()
settings = load_settings()
library = load_library(settings)
if unknown := set(args.only) - {p.id for p in library.prompts()}:
print(f"Unknown prompt id: {', '.join(sorted(unknown))}")
return 2
todo = [
p
for p in library.prompts()
if (not args.only or p.id in args.only)
and (args.force or not library.image_path(p).is_file())
]
model_key = settings.models.defaults.create_model
cost = settings.models.image_models[model_key].est_cost_usd
total = len(library.prompts())
print(
f"{len(todo)} of {total} images to make with '{model_key}', about ${len(todo) * cost:.2f}"
)
if args.dry_run:
return 0
bad = 0
if todo:
if settings.development_mode:
print("Development mode uses fakes; unset DEVELOPMENT_MODE to make real images.")
return 2
key = "HF_TOKEN" if settings.config.image_backend == "hf" else "FAL_KEY"
if missing := [n for n in (key, "OPENAI_API_KEY") if not os.environ.get(n)]:
print(f"Missing: {', '.join(missing)} (set it in the shell or the .env file).")
return 2
results = await make_images(settings, library, todo, model_key)
for prompt_id, outcome in results:
print(f"{prompt_id:22} {outcome}")
bad = sum(not outcome.startswith("ok") for _, outcome in results)
print(f"{len(results) - bad} made, {bad} not made")
write_lock(library.build_lock())
print(f"Wrote {LOCK_FILE.name}: commit it with the images once you have reviewed them.")
if sheet := contact_sheet(library):
print(f"Contact sheet for review: {sheet}")
return 1 if bad else 0
async def make_images(
settings: Settings, library: PromptLibrary, todo: list[LibraryPrompt], model_key: str
) -> list[tuple[str, str]]:
image_provider = build_image(settings)
safety = SafetyService(build_moderator(settings), build_text(settings), settings.config.safety)
library.image_dir.mkdir(parents=True, exist_ok=True)
gate = asyncio.Semaphore(CONCURRENCY)
async def make(prompt: LibraryPrompt) -> tuple[str, str]:
async with gate:
try:
# Every language the visitor can see is checked, not only the one the image uses.
for lang, text in prompt.text.items():
await safety.check_prompt(compose_prompt(text), lang)
result = await image_provider.generate(
ImageRequest(
prompt=compose_prompt(prompt.image_text()),
model_key=model_key,
aspect="square",
)
)
await safety.check_image(result.image)
target = library.image_path(prompt)
partial = target.with_suffix(".tmp")
partial.write_bytes(to_webp(result.image))
os.replace(partial, target)
except SafetyRefusalError as refusal:
return prompt.id, f"REFUSED ({refusal.code}): reword this prompt"
except AppError as error:
return prompt.id, f"FAILED ({type(error).__name__}): run again"
except Exception as error:
return prompt.id, f"FAILED ({type(error).__name__}): run again"
return prompt.id, f"ok, {result.seconds:.1f} s"
return await asyncio.gather(*(make(p) for p in todo))
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))