monarch-art-generation
Use when generating Ironvellum (formerly Monarch, D:/Monarch) app artwork with tools/art.py, when an art batch stops with QUOTA_EXHAUSTED, when generated art shows a lighter box or sketched frame on t
SKILL.mdskills/managed/monarch-art-generation/SKILL.md
GitHubMonarch art generation
Artwork lives in app/src/main/res/drawable-nodpi/art_*.png (empty states:
art_empty_*.png) and is generated by tools/art.py — gemini-3.1-flash-image
through omp's google-antigravity provider. Auth comes from
omp token google-antigravity; no secret lives in the repo. The Gemini body must
be nested under "request".
The transport is dead until art.py is repointed. art.py still sends every
request to headroom (ENDPOINT = "http://127.0.0.1:8787/v1internal:streamGenerateContent?alt=sse"),
and headroom was removed from this machine on 2026-09-23, so every run fails with
a connection error. Fix it in the Monarch repo by pointing ENDPOINT at Cloud
Code directly (omp's own antigravity provider uses
https://daily-cloudcode-pa.googleapis.com), then replace this paragraph with
the new transport. Never restart headroom to make art.py work.
Generate
python tools/art.py --style ink --alpha --size 512 "<subject only>" -o .tmp/art_<name>.png
--styleis a contract (ink,line,woodcut). Never bake style words into the prompt; add or edit a style in the script instead, so screens cannot drift.--alphais required for anything sitting on a panel.- Prompt the SUBJECT only, e.g.
"a closed leather ledger with a ribbon marker". - Size the prompt to the render size. Crests render at 34dp in the player sigil: ask for "bold thick strokes, massive simple silhouette, no fine detail, no frame, no border". Fine detail is mush at that size.
A run takes ~15s and prints chroma / contrast / backdrop / film checks.
Batches and the quota. The image quota dies mid-batch with QUOTA_EXHAUSTED
from cloudcode-pa.googleapis.com (~9–10 images per reset observed). A shell
loop loses its place, so use the resumable runner:
python tools/art_batches/run.py tools/art_batches/<batch>.txt [outdir]
Batch line format id|subject; the shared style suffix lives in a # SUFFIX:
comment so each prompt stays in version control beside its art. The runner skips
ids whose PNG already exists and stops with a named reason, so a rerun after the
reset finishes the set instead of redrawing it.
Trap 1: the paper film
--alpha keys by density (ink_to_alpha): ink darkness becomes opacity, and
blank paper is not perfectly white, so it lands at alpha 3–24 instead of 0 —
measured on shipped art at 78–91% of the canvas (one piece: a light wash of
RGB 232,232,228 at alpha 7–17). Invisible when you view the PNG alone; on the
app's near-black panel (Abyss = 0xFF0C0C0B) it is a clearly visible lighter
rectangle the exact size of the image. Three pieces shipped that way and the
owner caught it on a real phone ("I can see the bg of it against the dark bg").
cut_paper_film() now removes it at source inside postprocess(): a soft knee
(alpha ≤ 20 → 0, ramp to 40, unchanged above), chosen from the histogram's clean
gap at alpha 25–40 between film and real strokes, so the drawing's feathered
edges survive. backdrop_audit reports (N% ink, M% film).
Verify every generated asset independently:
from PIL import Image
im = Image.open(path).convert("RGBA")
h = im.split()[3].histogram(); tot = sum(h)
print(f"zero={100*h[0]/tot:.1f}% film3-24={100*sum(h[3:25])/tot:.1f}% ink>=41={100*sum(h[41:])/tot:.1f}%")
- film > 5% → the wash is still there, whatever the tool reported.
- Healthy output: film ≈ 0–3%, zero ≈ 75%+, ink 5–25%.
To rescue already-shipped art, apply the same LUT to the PNG in place, then confirm the corner-alpha mean is ~0 and the stroke pixel count (alpha ≥ 41) is unchanged:
LOW, HIGH = 20, 40 # cut_paper_film() defaults
lut = [0 if v <= LOW else (v if v >= HIGH else round(HIGH*(v-LOW)/(HIGH-LOW))) for v in range(256)]
im.putalpha(im.split()[3].point(lut)); im.save(path)
Trap 2: the model draws its own frame
Generated pieces routinely include a faint sketched rectangle in the margin — the same "box against the dark background" by another route. Keep "no frame, no border" in the prompt, and still crop ~7% off each edge before shipping, then re-check the histogram:
m = int(min(w, h) * 0.07); im = im.crop((m, m, w - m, h - m)).resize((512, 512))
Do not trust backdrop_audit alone
It originally checked only corner alpha (> 32) and "opaque" coverage (alpha > 180
over > 55% of the canvas), so a canvas entirely at alpha 3–24 passed as
"transparent". It now also fails above 5% film — but the histogram above is the
check that settles it. Judge on a dark contact sheet, not a white viewer:
composite onto (12,12,11) before deciding.
Wire and verify
- Never half-wire a catalogue. 4 drawn crests beside 6 procedural ones looks
worse than either set. Generate the full set, then wire. If the quota dies
part-way, leave the set unwired and record its status in the batch file's
# STATUS:header. - Empty states take
Image(painterResource(R.drawable.art_empty_*), contentDescription = null, modifier = Modifier.size(...).alpha(0.55f))at an explicit.size(...)—fillMaxWidth+heightInlets the intrinsic size win and renders it postage-stamp small. Imports usually missing:androidx.compose.foundation.Image,ui.res.painterResource,ui.draw.alpha,foundation.layout.size,com.ironvellum.app.R(the app's package). - Look at it on a device — the film was invisible in every static check and
obvious on screen, and the emulator and phone render ink differently. Pin
instrumented Gradle runs with
ANDROID_SERIAL=emulator-5554: the suite callspm clearand deletes sessions, and the owner's S25 Ultra (R5GL14GXV3J) holds real training data.