Files
settled-reach/tooling/domains/assets/image.py
T
jpmschweitzerandClaude Opus 5.5 26cc8de7f3 refactor(tooling): T-1290 — the character domain, and six payloads the map misfiled
`reach character {logo, strip-glb, qa, qa-analyze}` replaces make_logo.py,
glb_strip_utility_nodes.py, analyze_captures.py and the run-garment-qa bash
driver. The QA configs and method doc move beside the domain (qa_configs/,
GARMENT_QA.md), and `qa` takes a config name (`reach character qa hoodie_modern`)
or a path.

Parity, from baselines taken before anything moved:

- the logo PNG is byte-identical
- a synthetic GLB with three real utility nodes strips to identical bytes
  (the committed bodies strip 0 nodes, so they proved nothing)
- re-analyzing a cached capture set gives a byte-identical report.json and
  summary

run-garment-qa is rewritten, not wrapped (D-263). Its decisions — which config,
which Godot ($GODOT, then ~/bin/godot4, then PATH), and whether xvfb-run is
needed — are capture_plan(), pinned by tooling/test_character.py without
launching Godot. The bash exit codes are kept: 2 for a missing config, 3 for
no Godot.

The T-1271 domain map was wrong about this domain. Six of its ten files import
bpy: convert_outfit, inspect_glb, check_hair_symmetry, check_icosphere,
render_quaternius_test and test_quaternius_raw. They are Blender payloads and
joined the carve-out as blender_* (41 payloads now). The 22 existing payloads'
docstrings still cited tooling/garment-fit/ from before T-1273; fixed.

Archived, with reasons in tooling/archive/README.md:

- setup_clothing_metadata.py wrote coverage data for five garments that no
  longer exist in the 24-garment wardrobe
- wipe-bodies.sh ran raw DELETEs on systems.db

segment_reference_distribution.md moved to docs/assets/visual/.

Behaviour changes:

- The QA analyzer exited 0 whatever it found, though its own README says
  clip-through "is the real defect and it gates". qa and qa-analyze now exit 1
  on clip-through, and the remedy names --min-pixels (Wave 1/2 were accepted
  at 150). The cached peasant set has 33 failures at the default 8 px.
- glb strip re-reported the same nodes as stripped on every re-run and
  rewrote an unchanged file: it left them as orphans and then found them
  again. Only nodes still linked into the graph count now, and a first pass
  writes the same bytes as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:55:46 +02:00

150 lines
5.3 KiB
Python

"""Gemini image-generation connector — direct API wrapper.
Generates images through Google's gemini-2.5-flash-image model. The key comes
from GEMINI_API_KEY in the environment only (endpoints.get_api_key). **Every
generate call costs real money**; `health` only lists models and is free.
Formerly tooling/db/image_connector.py (T-1290). Unchanged except that
failures raise a ReachError instead of printing `{"ok": false}` and exiting 1,
and a network failure is reported as the network rather than as the API.
The key travels in the URL, so no error message ever includes the URL's query.
"""
from __future__ import annotations
import base64
import json
import os
import urllib.request
from tooling.core import console
from tooling.core.errors import ReachError
from tooling.domains.assets import endpoints
SERVICE = "Gemini API"
MODEL = "gemini-2.5-flash-image"
API = "https://generativelanguage.googleapis.com/v1beta"
DEFAULT_OUTPUT_DIR = os.path.expanduser("~/Pictures/mcp-images")
# Real API values for imageConfig.aspectRatio (not a prompt hint).
# https://ai.google.dev/gemini-api/docs/image-generation
ASPECT_RATIOS = ("1:1", "3:2", "2:3", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9")
MIME_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
def get_api_key() -> str:
return endpoints.get_api_key("GEMINI_API_KEY")
def health() -> dict:
"""Is the Gemini API reachable with the configured key? (Free — lists models.)"""
key = get_api_key()
data = endpoints.call_json(
urllib.request.Request(f"{API}/models?key={key}", method="GET"),
service=SERVICE,
what="the model listing",
timeout=10,
)
models = [
m.get("name", "")
for m in data.get("models", [])
if "imagen" in m.get("name", "").lower() or "flash" in m.get("name", "").lower()
]
return {"ok": True, "api": "gemini", "image_capable_models": models[:5]}
def default_output(prompt: str) -> str:
safe = "".join(c if c.isalnum() or c in "-_ " else "" for c in prompt[:40])
safe = safe.strip().replace(" ", "_").lower()
return os.path.join(DEFAULT_OUTPUT_DIR, f"{safe}.png")
def build_request_body(
prompt: str,
aspect_ratio: str | None = "1:1",
image_size: str | None = None,
input_image: str | None = None,
) -> dict:
"""The generateContent payload — pure, so it can be tested without a call."""
parts = []
if input_image:
if not os.path.isfile(input_image):
raise ReachError(
f"input image not found: {input_image}",
fix="pass --input with an existing .png/.jpg/.webp",
)
with open(input_image, "rb") as f:
data = base64.b64encode(f.read()).decode("utf-8")
mime = MIME_TYPES.get(os.path.splitext(input_image)[1].lower(), "image/png")
parts.append({"inlineData": {"mimeType": mime, "data": data}})
# The size is a best-effort prompt hint only: this model has no resolution
# parameter, unlike the aspect ratio below.
parts.append({"text": prompt + (f" Resolution: {image_size}." if image_size else "")})
generation_config: dict = {"responseModalities": ["TEXT", "IMAGE"]}
if aspect_ratio:
generation_config["imageConfig"] = {"aspectRatio": aspect_ratio}
return {"contents": [{"parts": parts}], "generationConfig": generation_config}
def generate(
prompt: str,
output: str | None = None,
aspect_ratio: str = "1:1",
image_size: str | None = None,
input_image: str | None = None,
) -> dict:
"""Generate one image. COSTS MONEY. Returns the result dict."""
key = get_api_key()
body = build_request_body(prompt, aspect_ratio, image_size, input_image)
output = output or default_output(prompt)
console.event(f"Generating image: {prompt!r}" + (f" (from {input_image})" if input_image else ""))
result = endpoints.call_json(
urllib.request.Request(
f"{API}/models/{MODEL}:generateContent?key={key}",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
method="POST",
),
service=SERVICE,
what="the generation request",
timeout=120,
)
candidates = result.get("candidates", [])
if not candidates:
raise ReachError(
f"Gemini returned no candidates: {json.dumps(result)[:500]}",
fix="the prompt may have been blocked by safety filters — rephrase it and re-run",
)
image_saved = False
text_response = ""
for candidate in candidates:
for part in candidate.get("content", {}).get("parts", []):
if "inlineData" in part:
os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True)
with open(output, "wb") as f:
f.write(base64.b64decode(part["inlineData"]["data"]))
image_saved = True
elif "text" in part:
text_response += part["text"]
if not image_saved:
raise ReachError(
f"Gemini answered with text but no image: {text_response[:500]!r}",
fix="make the prompt ask for an image explicitly, then re-run",
)
return {
"ok": True,
"file": output,
"size_bytes": os.path.getsize(output),
"prompt": prompt,
"aspect_ratio": aspect_ratio,
}