Component: generation pipeline
Who is this for
Section titled “Who is this for”A developer who has to change how stickers are generated, or who reads a log line such as FIGURE_CUT,
CONSISTENCY_REGEN or QA_IDENTITY_MISMATCH and wants to know which stage produced it.
- One job = one selfie + one outfit → 12 PNG stickers (512 px) → one Telegram sticker set. The orchestration is
bot/worker.py; the building blocks are inbot/services/. - The image model draws sheets (several figures on a green background in one image). Code cuts the sheet into figures, removes the background, adds a white outline and exports. Drawing several figures per request is what keeps the cost and time of a pack low.
- Three switches select the path:
STICKER_RENDER_MODE(compositeorfigure),LIKENESS_MODE(empty,classicorhybrid) andCONSISTENCY_MODE(on or off).LIKENESS_MODEwins overCONSISTENCY_MODE. - Quality control is layered: geometric checks in code (count, edges, straight cuts), identity checks by a cheap vision model, then single-figure regeneration of only the stickers that failed, inside strict count and time budgets.
- Every AI call goes through one function with provider fallback, rate-limit backoff, a per-job cost ceiling
(
JOB_MAX_COST_USD) and a usage record in the database. - A job gets at most 2 attempts (
MAX_ATTEMPTS). “No funds” at the provider and the cost ceiling fail the job at once, without a retry.
Vocabulary
Section titled “Vocabulary”| Term | Meaning |
|---|---|
| Sheet | One generated image holding several figures (or heads) in a grid on a chroma-key green background (#00FF00) |
| Figure | One person drawn chest-up or as a close-up — one future sticker |
| Appearance card (“passport” in the code and logs) | A strict JSON description of the guest’s look (hair, facial hair, skin tone, age band, face box) produced by one vision call from the selfie. Cached in jobs.appearance_json for the lifetime of the job |
| Anchor | A neutral front-facing portrait of the guest as a character in the chosen art style and outfit. Used as the main reference image for all sheets in consistency mode |
| Lock | A block of prompt text that pins a property: IDENTITY LOCK, HARD LOCKS, COLOR LOCK, SKIN LOCK, OUTFIT LOCK |
| ΔE | Colour distance in CIE LAB (CIE76). Used to compare skin and hair tones |
| Die-cut outline | The white sticker border drawn by code around the whole silhouette |
Pipeline overview
Section titled “Pipeline overview”flowchart TB
claim["Worker slot claims a queued job"]
card["Appearance card - one vision call, cached on the job"]
mode{"STICKER_RENDER_MODE"}
comp["Composite - 2 sheets of 6 heads, pasted on a body template"]
crop["Face crop from the selfie"]
lk{"LIKENESS_MODE set"}
cons{"CONSISTENCY_MODE on"}
classic["Classic or hybrid path - selfie is the only face source"]
anchor["Anchor portrait, verified against the selfie"]
consistent["Consistency path - all sheets in parallel with the anchor"]
legacy["Legacy figure path - 2 sheets in sequence"]
cut["Cut sheets into figures, geometric checks"]
post["Colour post-processing and export to 512 px"]
qa["Pack quality check by a vision model"]
regen["Regenerate single figures within budget"]
pack["createNewStickerSet with all 12 stickers"]
deliv["Job moves to delivering, slot is free"]
fallback["Figure sheet never became valid"]
claim --> card --> mode
mode -- "composite" --> comp
mode -- "figure" --> crop --> lk
lk -- "classic or hybrid" --> classic
lk -- "empty" --> cons
cons -- "yes" --> anchor
anchor -- "ok" --> consistent
anchor -- "failed" --> legacy
cons -- "no" --> legacy
classic --> cut
consistent --> cut
legacy --> cut
cut --> post --> qa --> regen --> pack
cut -. "FigureSheetInvalid" .-> fallback
fallback -. "FIGURE_FALLBACK_ENABLED" .-> comp
comp --> pack
pack --> deliv
The mode actually used is written to jobs.render_mode: figure, composite, or composite_fallback (figure was
requested, but the job was finished in composite).
Choosing the path
Section titled “Choosing the path”STICKER_RENDER_MODE |
LIKENESS_MODE |
CONSISTENCY_MODE |
Function in worker.py |
Face source | How sameness across 12 stickers is achieved |
|---|---|---|---|---|---|
composite (code default) |
ignored | ignored | build_stickers (tail part) |
Selfie; sheet 2 also gets sheet 1 | Sheets run in sequence; identity check per sheet |
figure |
classic |
ignored | build_figure_stickers_classic |
Selfie + face crop only; no anchor | Sheet 1 first, the other sheets in parallel with sheet 1 as reference; identity check per sheet against the selfie |
figure |
hybrid |
ignored | build_figure_stickers_classic(hybrid=True) |
Same as classic | By code: the same COLOR LOCK text in every prompt, then skin and hair of every figure are shifted to a pack-wide target, green spill removed; one pack-level check against the selfie |
figure |
empty | 1 (default) |
make_anchor → build_figure_stickers_consistent |
Anchor is image 1, selfie image 2 | Anchor + HARD LOCKS in every prompt; all sheets in parallel; skin normalised to the anchor; one pack-level check against the anchor |
figure |
empty | 0, or the anchor could not be generated |
build_figure_stickers (tail part) |
Selfie + face crop; sheet 2 gets sheet 1 | Two 3×2 sheets in sequence; identity check per sheet |
Notes:
- The code default of
STICKER_RENDER_MODEiscomposite, used when the key is absent. The shippedbot/.env.examplesetsfigure. A key that is present but empty or misspelled is a start-up error, not a silent default. - Why
classicexists (comment block inworker.py): with an anchor, the face is first averaged into a 1K AI portrait and all 12 stickers then copy that portrait, which cost likeness.classicremoves the anchor;hybridkeeps that likeness and restores colour consistency in code instead of through the model. - The art style (
ART_STYLE, stored per job injobs.art_style) only changes prompt text. Known ids:realistic_illustration,comic_popart,stylized_3d,cute_cartoon(default).
Sheet layouts
Section titled “Sheet layouts”SHEET_LAYOUT applies to the consistency and classic/hybrid paths. Sticker number = pose number (1–12); poses and
their emoji come from FIGURE_POSES_SHEET_1 + FIGURE_POSES_SHEET_2 in the catalogue.
| Layout | Sheets | Grid per sheet | Aspect | Sticker numbers per sheet |
|---|---|---|---|---|
3x4 (default) |
3 | 2 × 2 | 1:1 | (1, 2, 4, 5), (3, 7, 6, 10), (8, 9, 11, 12) |
2x6 |
2 | 3 × 2 | FIGURE_SHEET_ASPECT (2:3 or 3:2) |
(1–6), (7–12) |
In every sheet the top row is framed chest-up (hand gestures visible) and the bottom row is a close-up (head and
shoulders): the frame for sticker n is FIGURE_FRAMES[(n − 1) mod 6]. Why 3x4 is the default (comment in
config.py): cells are larger and margins wider, which halved the number of figures touching a sheet edge and the
regenerations they cause.
The composite path and the legacy figure path do not use SHEET_LAYOUT: they always make two sheets (2 × 3 heads,
or 3 × 2 figures).
Sheets are requested at Gemini image size 2K; the anchor and single-figure regenerations at 1K (faster and
cheaper). A blank grid image (the layout guide) is sent with every figure sheet request so the model keeps to the
grid.
Stage by stage
Section titled “Stage by stage”| # | Stage | What happens | Usage stage label | Main code |
|---|---|---|---|---|
| 1 | Pre-checks | Selfie file must exist; the template id must be in the catalogue. Either failure is a PermanentJobError — no retry |
— | build_stickers, resolve_template |
| 2 | Appearance card | One vision call → strict JSON validated by pydantic; one retry; on a second failure the job continues without a card | passport |
appearance.build_card |
| 3 | Face crop | A crop of the selfie around the face box from the card, saved in the session folder. Skipped if the box is missing, tiny or covers the whole frame | — | appearance.face_crop |
| 4 | Anchor (consistency path) | One 1K portrait → verification against the selfie (hair length, hair colour, facial hair, glasses) → at most one redraw listing the mismatches → skin shifted towards the selfie estimate → skin colour measured for the locks | anchor, anchor_verify |
make_anchor, consistency.verify_anchor |
| 5 | Sheets | One image request per sheet with the prompt built from poses, framing, locks, outfit and art style | sheet (figure), heads (composite) |
generate_sheet_figures, ai_pipeline.build_figure_prompt |
| 6 | Cutting | Chroma key on a reduced copy → connected components → one figure per grid cell; merges detached parts, splits glued neighbours, flags figures cut by an edge | — | sticker_builder.figures_from_sheet |
| 7 | Colour post-processing | Consistency: skin of each figure shifted towards the anchor. Hybrid: green spill removed, skin and hair shifted towards the pack target. Classic: nothing | — | skin_tone.normalize, likeness.postprocess |
| 8 | Export | Auto-crop → white die-cut outline → fit into 512 × 512 with a 16 px margin → PNG ≤ 512 KB (palette quantisation if needed) | — | sticker_builder.finish_figure, export_for_telegram |
| 9 | Pack check | Consistency: anchor + 12 stickers as contact grids → JSON list of outliers. Hybrid: the same against the selfie. Classic: done per sheet in stage 5 | qa_pack, qa_sheet |
consistency.check_pack, likeness.check_pack_selfie, appearance.check_sheet |
| 10 | Single-figure regeneration | Only the flagged stickers are redrawn, one request each, in parallel; each result is re-checked before it replaces the original | regen, qa_regen |
_regen_outliers, _regen_one, _regen_one_classic |
| 11 | Sticker set | Name pack<N>_<telegram_user_id>_by_<bot_username>, title built from the guest’s username; all 12 stickers in one createNewStickerSet call, so a set can never be half-built |
— | telegram_stickers.create_pack |
| 12 | Hand-over | Selfie file deleted at once; job → delivering; verify task spawned |
— | _process_one_job, delivery.spawn |
Quality checks
Section titled “Quality checks”Geometric checks (code, free)
Section titled “Geometric checks (code, free)”| Check | Rule | Consequence |
|---|---|---|
| Figure count | Each grid cell must contain exactly one main figure. A second group ≥ 50 % of the main one is a duplicate; a cell whose main group is under 3 % of the median is empty | The sheet is invalid → next sheet attempt. In the consistency and classic/hybrid paths, empty cells are tolerated when at least half the figures were found: those stickers are drawn individually |
| Edge contact | A figure touching the sheet edge is cut off | Flagged for single-figure regeneration (or a sheet retry in the legacy path) |
| Close-up bottom edge | A close-up touching only the bottom edge | Accepted: a flat bust cut that the outline covers (FIGURE_BOTTOM_CUT in the log) |
Torso-only contact (EDGE_ALLOW_TORSO_CROP=1) |
Contact below the chin with no skin at the contact points | Accepted (EDGE_TORSO_OK) |
| Glued neighbours | Two figures drawn touching each other are split at the narrowest place near the cell border | If the split line is long (> 12 % of the border) the figure counts as cut |
Flat side cut (EDGE_FLAT_CUT_CHECK=1) |
A perfectly straight vertical run on the finished sticker’s contour (an arm cut by a neighbour) | Flagged for regeneration; a regenerated figure with the same defect is rejected |
| Residual edge cut | A flagged figure that was not replaced: cut depth is measured | ≤ 5 % of the figure height is covered by the outline (EDGE_PADDED); deeper is logged as EDGE_CUT_LEFT and shipped as is |
Identity checks (vision model, paid)
Section titled “Identity checks (vision model, paid)”| Check | Compares | Decides |
|---|---|---|
Sheet check (appearance.check_sheet) |
Selfie (+ face crop) with one sheet, per figure: hairstyle, facial hair by grade, skin tone, same person; for figures also hands matching the face tone and the same outfit | Pass/fail per figure is computed in code from the model’s JSON (head_failures) |
Anchor verification (consistency.verify_anchor) |
Selfie with the anchor: hair length, hair colour, facial hair, glasses | One anchor redraw at most. Timeout ANCHOR_VERIFY_TIMEOUT_SEC; if the check is unavailable the anchor is used as is |
Pack check by anchor (consistency.check_pack) |
Anchor with all 12 stickers | A list of outlier stickers with reasons |
Pack check by selfie (likeness.check_pack_selfie) |
Selfie with all 12 stickers, and the stickers among themselves | A list of outlier stickers with reasons |
QA_IDENTITY_ENABLED=false switches all vision checks off. A check that is unavailable (provider error) never fails
the job: the pack is accepted as generated.
Limits and budgets
Section titled “Limits and budgets”| Limit | Value | Where | Why |
|---|---|---|---|
| Job attempts | 2 (MAX_ATTEMPTS), 10 s pause before requeue |
worker.py |
A second try covers a provider hiccup; more would multiply cost |
| Head sheet attempts (composite) | 2 (SHEET_ATTEMPTS); best cell is kept across attempts |
worker.py |
— |
| Figure sheet attempts | FIGURE_SHEET_ATTEMPTS (default 3) for an invalid sheet; a valid sheet is regenerated at most once, and only for a failed check or edge contact that the path does not fix per figure |
_sheet_figure_attempts |
A whole-sheet retry costs a full image; single figures are cheaper |
| Figure → composite fallback | Once per job, if FIGURE_FALLBACK_ENABLED=1 |
build_stickers |
The guest still gets a pack; the admin gets an informational notice |
| Appearance card attempts | 2 | appearance.py |
Then continue without a card |
| Anchor redraw | 1 | make_anchor |
— |
| Single-figure regenerations per pack | CONSISTENCY_MAX_REGEN (default 4), one try each, in parallel. Missing figures are outside this cap |
_regen_outliers |
Bounded extra cost and time |
| Time budget per regeneration | Edge-like defects: EDGE_REGEN_TIMEOUT_SEC (35 s). Check outliers: CONSISTENCY_REGEN_BUDGET_SEC (20 s). Missing figures: no extra budget, only the provider timeout |
_regen_outliers |
A visible cut is worth waiting longer for; a missing sticker is mandatory |
| Regeneration order | Missing first, then edge-like, then outliers with the most reasons | _regen_outliers |
The most visible defects are fixed first |
| Missing figure not redrawn | The job fails this attempt with GenerationError and takes the normal retry |
both builders | A pack must have all 12 |
| Parallel image requests | AI_MAX_PARALLEL_IMAGES (default 9) per process, across all slots |
ai_pipeline.image_slot |
Keeps the burst under the provider’s rate limit |
| Parallel sheet cuts | CUT_MAX_PARALLEL (default: CPU count) per process |
limits.slot("cut", …) |
Cutting is CPU- and memory-heavy |
| Provider timeout | AI_TIMEOUT_SEC (default 180) + 15 s safety margin for images; 45 s + 10 s for vision |
ai_pipeline.py |
— |
| Rate-limit backoff | Up to 3 waits on the same provider: 5 s, 15 s, 30 s (or the provider’s hint, capped at 60 s) plus jitter. A daily-quota error is not waited for | with_rate_limit_backoff |
Without it a single 429 would fail the sheet and then the job |
| Cost ceiling per job | JOB_MAX_COST_USD (default 1.5), counted across attempts since the job was created or last retried by an admin; checked before every AI call |
usage.check_budget |
A stuck job cannot burn the budget |
| Skin shift | Applied only beyond a ΔE threshold (CONSISTENCY_SKIN_DE_MAX, default 6); strength CONSISTENCY_SKIN_STRENGTH (0.85); never longer than SKIN_NORM_MAX_DE (ΔE 4) |
skin_tone.normalize |
A larger shift produced visible patches on very light skin |
Provider fallback and failure classes
Section titled “Provider fallback and failure classes”ai_pipeline.generate_head_sheet (images) and ai_pipeline.vision_json (JSON answers) try PRIMARY_AI_PROVIDER
first, then FALLBACK_AI_PROVIDER. A provider without an API key is skipped. The SDKs are synchronous, so each call
runs in a daemon thread: the event loop stays responsive, and a request in flight does not hold up shutdown.
Default models (overridable without a deploy through GEMINI_IMAGE_MODEL, OPENAI_IMAGE_MODEL,
GEMINI_VISION_MODEL, OPENAI_VISION_MODEL): gemini-3.1-flash-image and gpt-image-2 for images,
gemini-3.5-flash-lite and gpt-5-mini for vision.
| Failure | Exception | Effect on the job |
|---|---|---|
| Primary failed, fallback succeeded | — | Job continues; the admin chat gets a fallback alert (once per 10 min per provider pair) |
| All providers failed | GenerationError |
Attempt fails → requeue, or failed after the second attempt |
| A provider answered “no funds” (HTTP 402 or a billing marker in the text) and no provider succeeded | usage.BillingExhausted |
failed immediately; maintenance pause is switched on for new jobs; the guest gets SERVICE_UNAVAILABLE text (the photo is kept so an admin can retry) |
| Job cost reached the ceiling | usage.JobCostExceeded |
failed immediately, no retry |
| Selfie or template file missing, unknown template id | PermanentJobError |
failed immediately, no retry |
| Figure sheet never valid | FigureSheetInvalid |
Composite fallback if enabled, otherwise like GenerationError |
| Process stopped mid-job | CancelledError |
Job returns to queued — see sticker-bot |
Modules
Section titled “Modules”| Module | Responsibility | Source |
|---|---|---|
| Worker | Slots, job lifecycle, path selection, sheet attempt loops, anchor, regeneration orchestration, sticker set creation, error handling and requeue | bot/worker.py |
| AI pipeline | Prompt builders for head sheets and figure sheets, art styles, provider runners, fallback chain, rate-limit backoff, daemon-thread execution, usage recording wrapper | bot/services/ai_pipeline.py |
| Appearance | The appearance card schema and prompt, IDENTITY LOCK text, age lock, face crop, per-sheet identity check and its pass/fail logic |
bot/services/appearance.py |
| Consistency | Anchor prompt, HARD LOCKS text, anchor verification, pack check against the anchor (contact grids) |
bot/services/consistency.py |
| Likeness | Hybrid mode: skin and hair targets (median of the pack, pulled slightly towards the selfie), COLOR LOCK text, hair normalisation, green despill, pack check against the selfie |
bot/services/likeness.py |
| Skin tone | Deterministic skin mask, LAB measurement, ΔE, bounded normalisation of the whole face zone | bot/services/skin_tone.py |
| Sticker builder | Chroma key, sheet cutting for heads and figures, glued-neighbour split, edge and flat-cut detectors, composition on a body template, die-cut outline, 512 px export, layout guide image | bot/services/sticker_builder.py |
| Telegram stickers | Pack name and title rules, createNewStickerSet, readiness check (Bot API count + t.me page twice) |
bot/services/telegram_stickers.py |
| Usage | One record per provider call (including errors), cost estimate from prices in the environment, per-job ceiling, balance estimate, the daily image-request counter in Pacific time | bot/services/usage.py |
| Stage timing | STAGE_TIMING and STAGE_SUMMARY log lines per job attempt; log only, no effect on behaviour, no personal data |
bot/services/stage_timing.py |
| Limits | Named per-process semaphores (images, cut) that log long waits |
bot/services/limits.py |
| Catalogue | Outfit texts, the 12 poses and emoji, frame types per cell, sheet layouts | bot/catalog.py |
Files a job leaves on disk
Section titled “Files a job leaves on disk”All of them live under DATA_DIR and are removed by the TTL cleanup. See
storage and infrastructure for the full
layout.
| Where | What |
|---|---|
sessions/<session_token>/ |
Selfie, face crop, raw sheets, reference copy of sheet 1, anchor (raw and corrected), regenerated figures, stickers/sticker_<n>.png |
debug_sheets/ |
A copy of every figure sheet attempt with a .json cut diagnosis, the anchor copy shown in the admin panel, and one job<N>_consistency.json or job<N>_likeness.json summary |
Where to go next
Section titled “Where to go next”- Flow: generation — the same pipeline as a sequence, with messages to the guest.
- Flow: pack delivery — what happens after
delivering. - Outfits, templates and styles — adding an outfit or an art style.
- Cost — measured cost per pack and per stage.
- Runbook: generation failing — reading the failure classes in production.
Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.