How Helen Gets Made A making-of, not the live show Watch her live

Workshop · the AI models · 3D and image generation · the Tripo pipeline

The Tripo pipeline: one photo, four invented views, one mesh

Eight API tasks, 340 credits, five files on the live site. The settings, the responses and the timings below are copied from the task JSON the scripts save beside every model.

What happens to the 61 MB file that comes out is on the shippable-mesh page.

Helen, a dilute tortoiseshell calico, the real cat every generated model and picture on these sites is measured against
The September photograph, 1050 × 1380, is the only input to the cat on the court. The other three sides were invented by the multiview task for ten credits.

Eight Tripo tasks were run for this project, all through the API from scripts in the site repository, and each one wrote its response JSON next to the file it produced. That JSON is the only cost record there is, so the table below is built from it rather than from memory. The plain-language version of what a task does is on the section entry page; what happens to the file afterwards is the shippable-mesh page.

Every Tripo task, from the saved task JSON (times UTC)
WhenTaskInputSettingsCreditsWall timeBecame
2026-08-17 01:29image_to_modelalbum frame 065.jpgstandard / standard, face_limit 50 000303 m 29 shelen-sitting-065.glb, 2.16 MB, superseded
2026-08-17 02:08multiview_to_modeltwo real photos (front 020, left 016), back and right emptystandard / standard, 50 000301 m 53 shelen-standing.glb, the fallback pose
2026-08-17 11:08image_to_modelfront photo, tongue outstandard / standard, 50 000302 m 12 shelen-sit-std.glb, the court's cat until September
2026-08-17 11:18image_to_modelsame photodetailed / detailed, 120 000603 m 43 shelen-sit.glb, 6.3 MB, not served
2026-08-17 17:50image_to_model + promptcropped Charles Morse portraitdetailed / detailed, 80 000604 m 39 scharles-morse-bust.glb
2026-08-17 18:04image_to_model + promptcropped Helen Morse portraitdetailed / detailed, 80 000602 m 17 shelen-morse-bust.glb
2026-09-02 14:48generate_multiview_imageone clean front photo, 1050×1380model_version default10under 30 sfour 1024² views, kept in the repo
2026-09-02 14:49multiview_to_modelthe four views, original_task_iddetailed / detailed, texture_alignment original_image60about 3 minhelen-sit-v2.glb, the court's cat

Total: 340 credits over eight tasks; 70 of them for the cat that is on the court now. The wall times are completed_at minus created_at where the API recorded both; the September pair records only create_time, and the file timestamps put the model on disk three minutes after the view task was posted. The whole September run, from posting the view task to the commit that shipped the packed model, took sixteen minutes on the clock: the task was created at 14:48:53 UTC and the commit is stamped 15:05:02.

The September path, as the script runs it

Two tasks. The first turns the photograph into four synthesised views; the second builds the mesh from those views. The script is scripts/tripo_helen_v2.py; the abridgement below drops the multipart encoder and the download helper and keeps every request, parameter and poll interval exactly as they run. The script reads the key from the environment and never prints it.

scripts/tripo_helen_v2.py, abridged
"""Sitting Helen, second pass (2026-09): one clean front photo -> Tripo multiview images
-> multiview_to_model, detailed geometry + detailed PBR textures.

Key: TRIPO_API_KEY env var. The value is never printed.

    python scripts/tripo_helen_v2.py            multiview pipeline (default)
    python scripts/tripo_helen_v2.py --single   plain image_to_model from the same photo
"""
import json, os, sys, time, urllib.request
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
PHOTO = ROOT / "public" / "models" / "helen-sit-front-2026-09.png"
TAG = "single" if "--single" in sys.argv else "mv"
OUT = ROOT / "public" / "models" / f"helen-sit-v2-{TAG}.glb"
LOG = ROOT / "public" / "models" / f"tripo-sit-v2-{TAG}.json"
BASE = "https://api.tripo3d.ai/v2/openapi"   # the v3 host no longer answers task endpoints

def request(method, url, key, data=None, content_type=None):
    headers = {"Authorization": f"Bearer {key}"}
    if content_type:
        headers["Content-Type"] = content_type
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    with urllib.request.urlopen(req, timeout=120) as res:
        return res.status, json.loads(res.read().decode() or "{}")

def wait(key, task_id, label, limit=900):
    started = time.time()
    while True:
        time.sleep(5)
        _, body = request("GET", f"{BASE}/task/{task_id}", key)
        data = body.get("data") or {}
        print(f"  {label} {data.get('status')} {data.get('progress')}% {int(time.time() - started)}s", flush=True)
        if data.get("status") == "success":
            return data
        if data.get("status") in {"failed", "cancelled", "error", "banned", "expired", "unknown"}:
            sys.exit(f"{label} {data.get('status')}")
        if time.time() - started > limit:
            sys.exit(f"{label} timeout")

key = os.environ["TRIPO_API_KEY"]                       # export TRIPO_API_KEY=<YOUR_API_KEY>
payload, ctype = multipart(PHOTO)                       # one multipart/form-data field named "file"
_, body = request("POST", f"{BASE}/upload", key, payload, ctype)
token = body["data"]["image_token"]

quality = {
    "model_version": "v3.1-20260211",
    "texture": True,
    "pbr": True,
    "texture_quality": "detailed",
    "geometry_quality": "detailed",
    "texture_alignment": "original_image",   # texture from the photo, not from the synthesised views
    "orientation": "align_image",            # face the way the photo faces
    "enable_image_autofix": True,
}

# task 1: one photo -> four synthesised views
_, resp = request("POST", f"{BASE}/task", key,
    json.dumps({"type": "generate_multiview_image", "file": {"type": "png", "file_token": token}}).encode(),
    "application/json")
mv_id = resp["data"]["task_id"]
mv = wait(key, mv_id, "multiview", 600)
for view in ("front", "left", "back", "right"):           # keep the guess on the record
    download(mv["output"]["generate_multiview_image"][f"{view}_view_url"],
             ROOT / "public" / "models" / f"helen-sit-v2-view-{view}.png")

# task 2: the four views -> a PBR GLB
gen = {"type": "multiview_to_model", "original_task_id": mv_id, **quality}
_, resp = request("POST", f"{BASE}/task", key, json.dumps(gen).encode(), "application/json")
data = wait(key, resp["data"]["task_id"], "model")
LOG.write_text(json.dumps(data, indent=2))
download(data["output"]["pbr_model"], OUT)
print("saved", OUT, OUT.stat().st_size, "credits", data.get("consumed_credit"))

Three parameters carry the weight. texture_alignment: "original_image" tells the model task to texture the mesh from the photograph rather than from the four views it was built from; the views are a more saturated, more orange cat than she is, and without this setting that is the cat you get. orientation: "align_image" means the mesh faces the way the photo faces, which still leaves the fit code turning it a quarter turn, but predictably. original_task_id in the second task is what links it to the first; you do not re-upload the views.

The same four requests, without the script:

The raw calls
# 1. upload the photo; the response carries data.image_token
curl -s -X POST https://api.tripo3d.ai/v2/openapi/upload \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -F "file=@public/models/helen-sit-front-2026-09.png"

# 2. four views from one photo (10 credits on this account)
curl -s -X POST https://api.tripo3d.ai/v2/openapi/task \
  -H "Authorization: Bearer <YOUR_API_KEY>" -H "Content-Type: application/json" \
  -d '{"type":"generate_multiview_image","file":{"type":"png","file_token":"<IMAGE_TOKEN>"}}'

# 3. poll until data.status == "success"; the four view URLs are under data.output.generate_multiview_image
curl -s https://api.tripo3d.ai/v2/openapi/task/<MV_TASK_ID> -H "Authorization: Bearer <YOUR_API_KEY>"

# 4. the views become a mesh (60 credits); note original_task_id, not files
curl -s -X POST https://api.tripo3d.ai/v2/openapi/task \
  -H "Authorization: Bearer <YOUR_API_KEY>" -H "Content-Type: application/json" \
  -d '{"type":"multiview_to_model","original_task_id":"<MV_TASK_ID>","model_version":"v3.1-20260211",
       "texture":true,"pbr":true,"texture_quality":"detailed","geometry_quality":"detailed",
       "texture_alignment":"original_image","orientation":"align_image","enable_image_autofix":true}'

# 5. poll again, then download data.output.pbr_model (a signed URL; it expires)

And what the second task wrote back, with the signed URLs and account keys replaced. consumed_credit is the field the cost table is built from; the August-era responses spell it credits_consumed and carry created_at / completed_at instead of create_time, which is why the script tries both names.

public/models/tripo-sit-v2-mv.json
{
  "task_id": "<TASK_ID>",
  "type": "multiview_to_model",
  "status": "success",
  "input": {
    "model_version": "v3.1-20260211",
    "files": [ {"object": {"bucket": "tripo-data", "key": ".../multiview_0_<MV_TASK_ID>.jpeg"}},
               {"object": {"bucket": "tripo-data", "key": ".../multiview_1_<MV_TASK_ID>.jpeg"}},
               {"object": {"bucket": "tripo-data", "key": ".../multiview_2_<MV_TASK_ID>.jpeg"}},
               {"object": {"bucket": "tripo-data", "key": ".../multiview_3_<MV_TASK_ID>.jpeg"}} ],
    "texture": true,
    "pbr": true,
    "texture_quality": "detailed",
    "texture_alignment": "original_image",
    "orientation": "align_image",
    "original_task_id": "<MV_TASK_ID>",
    "export_uv": true,
    "geometry_quality": "detailed"
  },
  "output": { "pbr_model": "https://tripo-data.../tripo_pbr_model_<TASK_ID>.glb?<SIGNED>", "rendered_image": "..." },
  "progress": 100,
  "create_time": 1788360561,
  "consumed_credit": 60
}

What came back

The generator was asked for "detailed" and meant it. The file it returned, helen-sit-v2-mv.glb, is 61,438,164 bytes: 1,988,734 triangles over 1,077,779 vertices, no compression extensions of any kind, and three 4096 × 4096 JPEG textures inside the binary, 1.84 MB for base colour, 816 kB for metallic-roughness and 350 kB for the normal map. The August single-image models were far lighter, 48,000 to 80,000 triangles and 1024² textures, because they were asked for standard quality with a face limit; the busts at 80,000 faces came out at 3.8 and 4.4 MB. The reduction from there to the sizes the court serves is the next page's subject.

The four views are the part worth opening before you spend the sixty credits. They are saved as helen-sit-v2-view-{front,left,back,right}.png. The front is a clean cut-out of the photo on white. The left, back and right are the model's invention: plausible, symmetrical, a fuller and brighter coat than hers, and a tail curled the way it happened to be in the one photo. If the invented sides are wrong in a way you care about, this is the cheap place to stop.

The busts: prompting a mesh generator, and what it ignored

The two Morse busts used the older single-image endpoint with a text prompt alongside the picture. The prompt asked, at some length, for a bust.

scripts/tripo_busts.py, the request body
body = {
    "input": token,
    "model": "v3.1-20260211",
    "texture": True, "pbr": True,
    "texture_quality": "detailed", "geometry_quality": "detailed",
    "face_limit": 80000,
    "orientation": "align_image",
    "enable_image_autofix": True,
    "prompt": (
        "Classical museum portrait BUST of this exact woman. Head, neck, and upper chest only. "
        "Smooth rounded truncation at the chest like a Roman bust. No arms, no hands, no waist, "
        "no legs, no skirt, no full body, no pedestal. Polychrome painted sculpture, sepia skin, "
        "Gibson Girl updo, pearl choker, lace collar. Not white marble. Facing the viewer."
    ),
}

The source portraits were first cropped to head-and-collar by scripts/crop_morse_bust_refs.py, which pads each crop onto a 900 × 1080 canvas of flat parchment colour so there is no photographed body for the model to extend. One of the two came back roughly bust-shaped, a near-cubic bounding box that the court keeps 92 percent of. The other came back as a standing figure with a skirt, exactly what the prompt had listed as forbidden, and the court keeps 34 percent of its height and clips the rest below the pedestal cap. Same endpoint, same settings, same prompt, different outcome. The text prompt on an image-to-model task is a suggestion.

The August experiments

Before the September cat there were four attempts at Helen in one day. A frame from the reference album gave a sitting cat that was replaced within hours. A multiview task fed with two real photographs, front and left, with the back and right slots deliberately left empty for the model to fill, produced the standing cat that is still the fallback pose today.

scripts/tripo_helen_multiview.py, the slot map
# Front = 020 (standing, looking at camera). Left = 016 (full standing profile).
# No usable back or right in the album - omit those slots so Tripo fills them.
VIEWS = {
    "front": ALBUM / "020.jpg",
    "left": ALBUM / "016.jpg",
    "back": None,
    "right": None,
}

Then the sitting photo was run twice: once at standard quality with a 50,000-face limit (30 credits, the file that became helen-sit-std.glb) and once at detailed quality with 120,000 faces (60 credits, 6.3 MB raw). The detailed one was wired up as a high-quality variant for wide screens and never served; its URL is in the code and returns 404 on the live site, and the loader falls through to the standard file as designed. The photo both were made from has her tongue out. Nobody noticed until the model was on the court.

Sixty-one megabytes is where the next page starts. What the 340 credits bought against the alternative is on the cost page, and the code that positions the result in the room is on the court build page.