Skip to main content

Try-on pipeline

This is the core of Drape: turning one body photo plus a garment into a render of that garment on that body. The entry point is POST /api/try-on (generate_try_on), with the heavy work in _run_tryon_background.

The full path

Fashn.ai / FLUX.2fal.ai (rembg/SAM)Mongo_run_tryon_backgroundRedisgenerate_try_onAppFashn.ai / FLUX.2fal.ai (rembg/SAM)Mongo_run_tryon_backgroundRedisgenerate_try_onAppalt[cache hit][miss]POST /api/try-on1gate checks (kill switch, rate limits, consent, owner)2cache lookup (key includes body_photo_hash)3job_id "cached" + URL4register job {status, user_id}5dispatch (in-process task or queue)6fetch body_photo_data server-side7route by garment_style8rembg cutout + EVF-SAM mask9reframe + composite on neutral canvas10generate render11rendered image12validate render URL13cache URL (24h) + job result14poll status (ownership-checked)15complete + result URL16

Gate checks

Before any compute, generate_try_on enforces, in order: the RENDER_ENABLED kill switch, an allowlist bypass for testers, the rate limits (below), BIPA consent (403 if consent_given is false), and body-photo ownership — if body_photo_owner_id does not match the authenticated user, the request is blocked with HTTP 409 so a contaminated photo never reaches the AI pipeline.

Model routing

determine_tryon_model picks the provider from the product's garment_style and tags:

yes

no

yes

no

yes

no

garment_style + tags

hoodie?

Fashn tryon-max

style = gpt-image-2?

GPT-Image path

baggy / wide-leg tag?

Fashn

Default: fashn-v1.5

FLUX.2 is effectively dormant

The tech notes describe "baggy/wide-leg → FLUX.2 LoRA", but the live routing sends baggy garments to Fashn tryon-max. A flux2-lora branch exists in the code, but no default rule selects it — a product must be explicitly tagged to reach FLUX.2. Treat FLUX.2 as present-but-inactive until a product is deliberately routed to it. This is flagged on the tech-debt page.

Garment masking & preprocessing

The Fashn path does real image work before the model sees anything:

  1. rembg (fal-ai/imageutils/rembg) removes the background from the body photo, with a retry wrapper.
  2. EVF-SAM (fal-ai/evf-sam) produces upper/lower garment masks; apply_garment_mask fills the existing-garment region with a neutral #EBEBEB so the model has a clean canvas instead of fighting the old garment.
  3. The cutout is reframed so the person fills roughly two-thirds of the canvas, composited onto the neutral background, and uploaded to the private body-photos-cutout bucket as a signed URL.
  4. _call_fashn_tryon runs, capped by a semaphore (MAX_CONCURRENT_RENDERS = 3), with a 180-second timeout and one retry; bottoms fall back from tryon-max to tryon-v1.6.

The garment category is normalized from merchandising terms (tops / bottoms / one-pieces / auto) to what Fashn expects.

Caching

The Redis cache key is composed from the user, product, color, size, the body photo hash, and a hash of the garment URL:

cache_key = generate_cache_key(user_id, product_id, color, size, body_photo_hash, garment_url)

Because body_photo_hash is part of the key, uploading a new body photo auto-busts stale renders with no manual cache clear. A cache hit returns immediately with job_id = "cached" and the stored URL — no polling. Concurrent identical requests are de-duplicated via an inflight: key that returns the existing job id. Valid renders are cached for 24 hours.

Status polling & ownership

`GET /api/try-on/status/{job_id}` polls every 3 seconds up to a 180-second timeout. The Redis job payload stores user_id at creation, and the poll handler verifies the caller owns the job — a mismatch returns 403. This closed the original IDOR where any authenticated user who guessed a job id could read another user's render (derived from their body photo). Missing jobs report as expired.

Rate limiting

A render must pass all of these Redis-backed counters (fail-closed — a Redis outage returns 503, never unlimited):

LimitValue
Daily try-ons per user25
Global daily render cap1000 (env-configurable)
Burst guard5 renders / 10 minutes
Per-garment cooldown8 seconds
Status poll60 / minute

Allowlisted testers bypass the daily, burst, and cooldown limits.

Silent failure modes

Several pipeline steps can return 200 OK while producing a wrong result — these are known and tracked for Sentry wiring:

  • rembg returns None → cutout missing, pipeline continues.
  • body_photo_data was never written → try-on finds no photo.
  • Fashn returns a source/marketing image → treated as a successful render.
  • The analyze-body-photos pipeline throws → measurements are written but the photo is skipped.

is_valid_render guards the obvious cases (a result must be an https:// or data:image/ URL, else the cache entry is deleted and a Sentry warning fires), but semantic wrongness ("this isn't the right garment") is not something the backend can detect. See the tech-debt page for the render quality issues that stem from this.