diff --git a/CHANGELOG.md b/CHANGELOG.md index 7213bb9..b227c90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +#### đź‘— Virtual try-on — OutfitAnyone-Plus + Photoroom +- **Alibaba OutfitAnyone-Plus** (`OutfitAnyonePlusAdapter` / CLI `--model outfitanyone-plus`): first-party DashScope `aitryon-plus` + - Dedicated person + flat-lay try-on (top, bottoms, combo, dress). **Not** Qwen-Image composition + - Auth: Beijing-region `DASHSCOPE_API_KEY`. Async create + poll. Local files uploaded to temp OSS + - MCP tool `vton_outfitanyone_plus` + - Docs: `docs/docs/api-reference/outfitanyone-plus.md` +- **Photoroom Virtual Try-On / Virtual Model** (`PhotoroomVTONAdapter` / CLI `--model photoroom-vton` and `photoroom-virtual-model`) + - First-party Image Editing API `POST /v2/edit`. Shopper try-on **or** flat-lay → on-model + - Auth: `PHOTOROOM_API_KEY` (`sandbox_` prefix for watermarked tests) + - MCP tools `vton_photoroom_vton`, `vton_photoroom_virtual_model` + - Docs: `docs/docs/api-reference/photoroom.md` + #### đź‘— Virtual try-on — Google Vertex (`virtual-try-on-001`) - **Google Virtual Try-On** (`GoogleVTONAdapter` / CLI `--model google-vton`): first-party Vertex / Gemini Enterprise `recontext_image` - Dedicated person + product try-on; **not** Nano Banana and **not** `GEMINI_API_KEY` diff --git a/README.md b/README.md index 55626ba..0d93f8f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ API tutorials, configuration, examples, and agent guides live there — not in t | Category | Highlights | |---|---| -| **Virtual try-on** | FLUX VTO, **Google Vertex VTO**, Nova Canvas, Kling AI, Segmind, Pruna P-Image-Try-On, FASHN, Nano Banana 2 Lite, **Qwen-Image** (API + local), **Muse Image** (composition) | +| **Virtual try-on** | FLUX VTO, **Google Vertex VTO**, **OutfitAnyone-Plus**, **Photoroom** (try-on + virtual model), Nova Canvas, Kling AI, Segmind, Pruna P-Image-Try-On, FASHN, Nano Banana 2 Lite, **Qwen-Image** (API + local), **Muse Image** (composition) | | **Generate / edit** | Nano Banana family, FLUX.2, GPT Image, Luma Photon, Seedream 5.0 Pro, Ideogram 4.0, Grok Imagine Image, Pruna P-Image / **P-Image-Ideogram** / Edit / Upscale, **Qwen-Image** (API + local), **Muse Image**; local FLUX.2-dev Turbo | | **Understand** | Kimi K2.6 / K2.7 Code / K3 (API), Kimi-VL & LLaVA-NeXT (local), **Qwen3.8-Max** (API) + **Qwen3.8-27B** (local) | | **Video** | Veo, Sora, Luma Ray 2 + Ray 3.2, Seedance 2.5, Kling 3.0 / Omni / Turbo, Grok Imagine Video 1.5, Gemini Omni Flash, Pruna P-Video / Replace / Avatar / Animate, **LTX-2.5** (API + local), **Hailuo 2.3**, **MiniMax H3** (API + local), **Wan** (API + local 2.2), **Runway Gen-4.5** | diff --git a/ROADMAP.md b/ROADMAP.md index 3eaa0d0..1fc3809 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -179,7 +179,7 @@ tryon/ **High-value next:** LoRA recipe hardening, Fashion Bench metrics, first local VTON adapter, Try-On QA or Fine-Tune Coach workflow. -**Vendor / model queue** (not a v0.1.0 commitment): [docs/docs/community/integrate-next.md](docs/docs/community/integrate-next.md) — NVIDIA Wave 1 and Google Vertex VTO shipped; next is OutfitAnyone-Plus and one local VTON weight, then new services (LipSync, TRELLIS). +**Vendor / model queue** (not a v0.1.0 commitment): [docs/docs/community/integrate-next.md](docs/docs/community/integrate-next.md) — NVIDIA Wave 1, Google Vertex VTO, OutfitAnyone-Plus, and Photoroom shipped; next is one local VTON weight, then new services (LipSync, TRELLIS). See [Contributing](CONTRIBUTING.md), [new-model checklist](docs/docs/advanced/new-model-checklist.md), and [VISION.md](VISION.md). diff --git a/SECURITY.md b/SECURITY.md index a01d12a..8361bd6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -60,6 +60,8 @@ SEGMIND_API_KEY GEMINI_API_KEY GOOGLE_CLOUD_PROJECT GOOGLE_APPLICATION_CREDENTIALS +DASHSCOPE_API_KEY +PHOTOROOM_API_KEY BFL_API_KEY LUMA_AI_API_KEY OPENAI_API_KEY diff --git a/docs/docs/agents/planner-agent.md b/docs/docs/agents/planner-agent.md index b5643a0..4279f8b 100644 --- a/docs/docs/agents/planner-agent.md +++ b/docs/docs/agents/planner-agent.md @@ -41,7 +41,7 @@ Each capability has a **default model**. The planner uses that default unless th | Video generation | `video` | `video-generate` / `sora` | | Background remove | `bg_remove` | `bg-remove` / `ben2` | -- **User named a model** (for example `wan-3.0`, `google-vton`, `flux2-pro`, `p-image-ideogram`, `nemotron-omni`, `cosmos3`) → that registry id **only**, even if it lives in another capability. An unknown name is **not** replaced by the default; the planner asks you to pick a real id. `p-image-ideogram` pins Pruna’s model; a bare `ideogram` still pins Ideogram 4.0. `cosmos3-reasoner` pins the VLM, not the Generator. `google-vton` / `virtual-try-on-001` pin Vertex dedicated try-on, not Nano Banana. +- **User named a model** (for example `wan-3.0`, `google-vton`, `outfitanyone-plus`, `photoroom-vton`, `flux2-pro`, `p-image-ideogram`, `nemotron-omni`, `cosmos3`) → that registry id **only**, even if it lives in another capability. An unknown name is **not** replaced by the default; the planner asks you to pick a real id. `p-image-ideogram` pins Pruna’s model; a bare `ideogram` still pins Ideogram 4.0. `cosmos3-reasoner` pins the VLM, not the Generator. `google-vton` / `virtual-try-on-001` pin Vertex dedicated try-on, not Nano Banana. `aitryon-plus` / `outfitanyone-plus` pin OutfitAnyone-Plus, not Qwen-Image. `photoroom virtual model` pins catalog on-model; a bare `photoroom` pins shopper try-on. - **No model named** → the default in the table. - The classifier must leave `model` empty unless the user named one. A leaked default in `plan.model` is ignored unless that id also appears in the prompt. diff --git a/docs/docs/api-reference/outfitanyone-plus.md b/docs/docs/api-reference/outfitanyone-plus.md new file mode 100644 index 0000000..ce5319c --- /dev/null +++ b/docs/docs/api-reference/outfitanyone-plus.md @@ -0,0 +1,80 @@ +--- +sidebar_position: 9 +title: OutfitAnyone-Plus +description: Alibaba DashScope aitryon-plus — dedicated person + garment try-on (Beijing region) +--- + +# OutfitAnyone-Plus (`aitryon-plus`) + +First-party Alibaba Cloud Model Studio dedicated try-on. Person photo + flat-lay garment(s) → one try-on still. Supports a single top (random bottoms), single bottoms (random top), top+bottoms combo, or a dress/jumpsuit on `top_garment_url`. + +This is **not** Qwen-Image composition (`--model qwen-image`). Same `DASHSCOPE_API_KEY` family, but the published API is **China (Beijing) only**. + +| CLI `--model` | MCP tool | Adapter | Upstream id | +|---|---|---|---| +| `outfitanyone-plus` | `vton_outfitanyone_plus` | `OutfitAnyonePlusAdapter` | `aitryon-plus` | + +Official docs: [aitryon-plus API](https://www.alibabacloud.com/help/en/model-studio/aitryon-plus-api) + +## Auth + +A Beijing-region Model Studio key. + +```bash +export DASHSCOPE_API_KEY=sk-... +# Optional host (default is China DashScope): +# export OUTFITANYONE_BASE_URL=https://dashscope.aliyuncs.com/api/v1 +# Workspace: +# export OUTFITANYONE_BASE_URL=https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1 +``` + +International DashScope keys used for Qwen-Image / Wan will **not** unlock this model. Create a Beijing key from [Model Studio](https://www.alibabacloud.com/help/en/model-studio/get-api-key). + +## Inputs + +- Person: one complete, full-body, front-facing subject. 5 KB–5 MB, 150–4096 px, JPG/PNG/BMP/HEIC. +- Garment: flat-lay, one item, clean background. Same size limits. +- At least one of top (or dress) / bottoms. +- Public HTTP(S) URLs are passed through. Local files, PIL images, and bytes are uploaded to DashScope temporary OSS (48h) and sent as `oss://` with `X-DashScope-OssResourceResolve: enable`. + +Keep-original-bottoms (or top) is a two-step vendor flow using companion `aitryon-parsing-v1`. OpenTryOn does not wrap that parsing API yet — pass the segmented URL as `--bottom-garment-image` (or `--garment-image`) yourself. + +## CLI + +```bash +opentryon vton --model outfitanyone-plus \ + --person-image person.jpg \ + --garment-image top.jpeg + +opentryon vton --model outfitanyone-plus \ + --person-image person.jpg \ + --garment-image top.jpeg \ + --bottom-garment-image pants.jpeg \ + --resolution -1 + +opentryon vton --model outfitanyone-plus \ + --person-image person.jpg \ + --garment-image dress.jpg \ + --dry-run +``` + +`--resolution -1` (default) matches the person image; `1024` is 576Ă—1024; `1280` is 720Ă—1280. `--no-restore-face` generates a random face. + +## Python + +```python +from tryon.api import OutfitAnyonePlusAdapter + +adapter = OutfitAnyonePlusAdapter() # DASHSCOPE_API_KEY +images = adapter.generate_and_decode( + person="person.jpg", + garment="top.jpeg", + restore_face=True, + resolution=-1, +) +images[0].save("worn.png") +``` + +## Planner / Studio + +Name **`outfitanyone-plus`**, **`aitryon-plus`**, or **OutfitAnyone** in chat to pin this model. The VTON default stays `kling-ai`. After MCP restart, Connect lists it under **Alibaba DashScope** (`DASHSCOPE_API_KEY`). diff --git a/docs/docs/api-reference/overview.md b/docs/docs/api-reference/overview.md index 4ee2ee9..ce4694a 100644 --- a/docs/docs/api-reference/overview.md +++ b/docs/docs/api-reference/overview.md @@ -223,6 +223,56 @@ See [Google Virtual Try-On](google-vton) for auth, CLI, and MCP. --- +### `OutfitAnyonePlusAdapter` + +Dedicated Alibaba DashScope try-on (`aitryon-plus`). Beijing-region key. **Not** Qwen-Image composition. + +```python +from tryon.api import OutfitAnyonePlusAdapter + +adapter = OutfitAnyonePlusAdapter() # DASHSCOPE_API_KEY (Beijing) + +images = adapter.generate_and_decode( + person="person.jpg", + garment="top.jpeg", +) +``` + +**Parameters:** +- `api_key` (str, optional): Defaults to `DASHSCOPE_API_KEY` +- `base_url` (str, optional): Defaults to `OUTFITANYONE_BASE_URL` or China DashScope + +**Methods:** +- `generate_and_decode(person, garment, top_garment=..., bottom_garment=..., restore_face=True, resolution=-1)` + +See [OutfitAnyone-Plus](outfitanyone-plus) for auth, CLI, and MCP. + +--- + +### `PhotoroomVTONAdapter` + +Photoroom Image Editing API — shopper Virtual Try-On and catalog Virtual Model (`POST /v2/edit`). + +```python +from tryon.api import PhotoroomVTONAdapter + +adapter = PhotoroomVTONAdapter() # PHOTOROOM_API_KEY +worn = adapter.generate_and_decode(person="selfie.jpg", garment="dress.jpg") +catalog = adapter.generate_virtual_model(garment="flatlay.jpg", preset_model="avery") +``` + +**Parameters:** +- `api_key` (str, optional): Defaults to `PHOTOROOM_API_KEY` +- `base_url` (str, optional): Defaults to `PHOTOROOM_BASE_URL` or `https://image-api.photoroom.com` + +**Methods:** +- `generate_and_decode(person, garment, mode="try-on", ...)` +- `generate_virtual_model(garment, ...)` + +See [Photoroom](photoroom) for auth, CLI, and MCP. + +--- + ### `AmazonNovaCanvasVTONAdapter` Adapter for Amazon Nova Canvas Virtual Try-On through AWS Bedrock. diff --git a/docs/docs/api-reference/photoroom.md b/docs/docs/api-reference/photoroom.md new file mode 100644 index 0000000..da78a7e --- /dev/null +++ b/docs/docs/api-reference/photoroom.md @@ -0,0 +1,76 @@ +--- +sidebar_position: 10 +title: Photoroom Virtual Try-On +description: Photoroom Image Editing API — shopper Virtual Try-On and catalog Virtual Model +--- + +# Photoroom Virtual Try-On / Virtual Model + +First-party [Photoroom Image Editing API](https://docs.photoroom.com/image-editing-api-plus-plan/virtual-try-on) (`POST /v2/edit`). One adapter, two registry ids: + +| Job | CLI `--model` | MCP tool | Person photo | +|---|---|---|---| +| Shopper fitting room | `photoroom-vton` | `vton_photoroom_vton` | Required (custom model) | +| Catalog on-model | `photoroom-virtual-model` | `vton_photoroom_virtual_model` | Optional; otherwise a preset (`avery` default) | + +Product pages: [Virtual Try-On](https://www.photoroom.com/tools/virtual-try-on), [Virtual Model](https://www.photoroom.com/tools/virtual-model). API: [Virtual Try-On](https://docs.photoroom.com/image-editing-api-plus-plan/virtual-try-on), [Virtual Model](https://docs.photoroom.com/image-editing-api-plus-plan/virtual-model). + +Both set `virtualModel.mode=ai.auto`. Try-on sends the shopper as `virtualModel.model.custom`; Virtual Model uses `virtualModel.model.preset.name` unless you pass a custom model photo. + +## Auth + +```bash +export PHOTOROOM_API_KEY=your_key +# Watermarked tests: +# export PHOTOROOM_API_KEY=sandbox_your_key +# or: export PHOTOROOM_SANDBOX=1 +``` + +Get a key at [app.photoroom.com/api](https://app.photoroom.com/api). Plus / Enterprise plan — Virtual Model and Virtual Try-On are Image Editing API features. + +## CLI + +```bash +# Shopper try-on +opentryon vton --model photoroom-vton \ + --person-image selfie.jpg \ + --garment-image dress.jpg \ + --pose standing \ + --scene studio + +# Catalog: flat-lay → on-model (no shopper photo) +opentryon vton --model photoroom-virtual-model \ + --garment-image flatlay.jpg \ + --preset-model avery \ + --scene street \ + --pose standing \ + --dry-run +``` + +`--remove-background` is off by default so the generated scene is kept (`referenceBox=originalImage`). Optional `--prompt` (e.g. `street style`), `--scene-image`, and `--additional-product-images`. + +Preset models include `avery`, `sam`, `taylor`, `kendall`, `jordan`, `jackson`, `ava`, and others listed in the [Virtual Model docs](https://docs.photoroom.com/image-editing-api-plus-plan/virtual-model). Output size default `PORTRAIT_HD_3_2`. 2K / 4K output is an Enterprise add-on on Photoroom’s side. + +## Python + +```python +from tryon.api import PhotoroomVTONAdapter + +adapter = PhotoroomVTONAdapter() # PHOTOROOM_API_KEY + +worn = adapter.generate_and_decode( + person="selfie.jpg", + garment="dress.jpg", + mode="try-on", + pose="standing", +) +catalog = adapter.generate_virtual_model( + garment="flatlay.jpg", + preset_model="avery", + scene="street", +) +``` + +## Planner / Studio + +Name **`photoroom-vton`** or **Photoroom** to pin shopper try-on. Name **`photoroom-virtual-model`** for catalog on-model. After MCP restart, Connect lists **Photoroom** (`PHOTOROOM_API_KEY`). The VTON default stays `kling-ai`. diff --git a/docs/docs/api-reference/qwen-image.md b/docs/docs/api-reference/qwen-image.md index 232aaf4..455a267 100644 --- a/docs/docs/api-reference/qwen-image.md +++ b/docs/docs/api-reference/qwen-image.md @@ -21,6 +21,10 @@ is Alibaba's hosted image model on DashScope / Model Studio. OpenTryOn integrates it via `QwenImageAdapter` for **text-to-image**, **image editing (I2I, 1–3 refs)**, and **virtual try-on** (person + garment composition). +This is **composition** I2I, not Alibaba's dedicated try-on. Dedicated +OutfitAnyone-Plus is `--model outfitanyone-plus` (`aitryon-plus`, Beijing +region). See [OutfitAnyone-Plus](outfitanyone-plus). + Qwen3.8 (understand) and Qwen-Image (generate / edit / vton) share `DASHSCOPE_API_KEY` but use different endpoints: diff --git a/docs/docs/community/integrate-next.md b/docs/docs/community/integrate-next.md index 7e59550..4cdb21e 100644 --- a/docs/docs/community/integrate-next.md +++ b/docs/docs/community/integrate-next.md @@ -14,7 +14,7 @@ Living **candidate queue** for new adapters. This is not a commitment and it is | [`ROADMAP.md`](https://github.com/tryonlabs/opentryon/blob/main/ROADMAP.md) | Product slices (train / eval / one local VTON / agents) | | [`.cursor/skills/integrate-model/`](https://github.com/tryonlabs/opentryon/tree/main/.cursor/skills/integrate-model) | How to integrate once you pick a row | -**Surveyed:** 29 August 2026 · Sources: [build.nvidia.com/models](https://build.nvidia.com/models), [Google `virtual-try-on-001`](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/imagen/virtual-try-on-001), [Alibaba OutfitAnyone-Plus](https://www.alibabacloud.com/help/en/model-studio/aitryon-plus-api), [CatVTON](https://github.com/Zheng-Chong/CatVTON/), [Leffa](https://github.com/franciszzj/Leffa), [Nemotron](https://www.nvidia.com/en-us/ai-data-science/foundation-models/nemotron/), [Cosmos 3](https://docs.nvidia.com/cosmos/latest/cosmos3/index.html). +**Surveyed:** 29 August 2026 · Sources: [build.nvidia.com/models](https://build.nvidia.com/models), [Google `virtual-try-on-001`](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/imagen/virtual-try-on-001), [Alibaba OutfitAnyone-Plus](https://www.alibabacloud.com/help/en/model-studio/aitryon-plus-api), [Photoroom Virtual Try-On](https://docs.photoroom.com/image-editing-api-plus-plan/virtual-try-on), [CatVTON](https://github.com/Zheng-Chong/CatVTON/), [Leffa](https://github.com/franciszzj/Leffa), [Nemotron](https://www.nvidia.com/en-us/ai-data-science/foundation-models/nemotron/), [Cosmos 3](https://docs.nvidia.com/cosmos/latest/cosmos3/index.html). **How to use:** pick a `next` row → follow the [integrate-model skill](https://github.com/tryonlabs/opentryon/blob/main/.cursor/skills/integrate-model/SKILL.md) (Path A first-party API, Path B local). After ship, move the row to **Shipped** and bump the date. @@ -75,6 +75,8 @@ NVIDIA has **no** dedicated VTON NIM. Product roadmap Slice D is still: pick **o |---|---|---|---| | `flux-vto` | Black Forest Labs | Dedicated VTON API | First-party FLUX VTO | | `google-vton` | Google Cloud Vertex | Dedicated VTON API | `virtual-try-on-001`; ADC + `GOOGLE_CLOUD_PROJECT`, not `GEMINI_API_KEY` | +| `outfitanyone-plus` | Alibaba Cloud Model Studio | Dedicated VTON API | `aitryon-plus`; Beijing-region `DASHSCOPE_API_KEY`, not Qwen-Image compose | +| `photoroom-vton` / `photoroom-virtual-model` | Photoroom | Dedicated VTON + catalog API | Image Editing `/v2/edit`; shopper try-on **or** flat-lay → on-model | | `nova-canvas` | Amazon Bedrock | Dedicated VTON API | Garment classes incl. footwear | | `kling-ai` | Kuaishou (Kling / Kolors) | Dedicated VTON API | First-party Kolors v1 / v1.5 | | `fashn-tryon-max` / `fashn-tryon-v1.6` | FASHN | Dedicated VTON API | Fashion suite; v1.6 is the fast e-comm path | @@ -91,8 +93,8 @@ Prefer first-party APIs. Related catalog jobs (product→model, model-swap, pars | Candidate | Vendor durability | Task | Suggested id | Why | Status | |---|---|---|---|---|---| | **Google `virtual-try-on-001`** | Google Cloud (GA 20 Jan 2026; listed discontinue 20 Jan 2027) | Shopper / catalog image VTON | `google-vton` | Dedicated Vertex / Gemini Enterprise predict API. Person + product image, 1–4 samples, C2PA watermark. Auth is **ADC / GCP project**, not `GEMINI_API_KEY`. Distinct from Nano Banana compose. [Docs](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/imagen/virtual-try-on-001) | shipped | -| **Alibaba OutfitAnyone-Plus (`aitryon-plus`)** | Alibaba Cloud Model Studio | Image VTON + combo top/bottom | `outfitanyone` | Dedicated DashScope try-on (async). Top, bottoms, dress, face restore, parsing companion `aitryon-parsing-v1`. Same company as Qwen; **Beijing-region key**, not the Qwen-Image compose path. [Docs](https://www.alibabacloud.com/help/en/model-studio/aitryon-plus-api) | `next` | -| **Photoroom Virtual Try-On / Virtual Model** | Photoroom (widely used e-comm photo API) | Fitting room **or** garment→lifestyle model | `photoroom-vton` | API-first catalog/shopper flows. Virtual Model is “flat-lay in, on-model out” (no person photo). Complements dedicated person+SKU VTON. [Product](https://www.photoroom.com/tools/virtual-try-on) | `next` | +| **Alibaba OutfitAnyone-Plus (`aitryon-plus`)** | Alibaba Cloud Model Studio | Image VTON + combo top/bottom | `outfitanyone-plus` | Dedicated DashScope try-on (async). Top, bottoms, dress, face restore, parsing companion `aitryon-parsing-v1`. Same company as Qwen; **Beijing-region key**, not the Qwen-Image compose path. [Docs](https://www.alibabacloud.com/help/en/model-studio/aitryon-plus-api) | shipped | +| **Photoroom Virtual Try-On / Virtual Model** | Photoroom (widely used e-comm photo API) | Fitting room **or** garment→lifestyle model | `photoroom-vton` / `photoroom-virtual-model` | API-first catalog/shopper flows. Virtual Model is “flat-lay in, on-model out” (no person photo). Complements dedicated person+SKU VTON. [API](https://docs.photoroom.com/image-editing-api-plus-plan/virtual-try-on) · [Product](https://www.photoroom.com/tools/virtual-model) | shipped | | Pixelcut Try-On | Pixelcut (e-comm photo; Shopify-heavy) | Image VTON + garment transfer | `pixelcut-vton` | REST `/v1/try-on`; upper/lower/full. Smaller than Google/Alibaba. [API](https://www.pixelcut.ai/api/try-on) | `watch` | | Fitroom | Specialist startup | Combo top+bottom in one request | `fitroom` | Strong e-comm DX; weaker long-term vendor signal. | `watch` | | Claid | Specialist | Catalog try-on | — | Photo-API vendor; overlap with Photoroom. | `watch` | @@ -157,7 +159,7 @@ Pick **one** for v0.1.0 Slice D (`tryon.models` + `opentryon[local]`). Many rese ### Virtual try-on -NVIDIA / Nemotron has no VTON NIM. Cloud dedicated VTON is already broad (FLUX VTO, **Google Vertex**, Amazon Nova, Kling, FASHN, Pruna). Next **APIs:** Alibaba `aitryon-plus`, Photoroom. Next **local weights:** Leffa (MIT, commercial-friendlier) or CatVTON (Slice D efficiency pick, CC BY-NC-SA). Full tables: Wave 2 above. +NVIDIA / Nemotron has no VTON NIM. Cloud dedicated VTON is already broad (FLUX VTO, **Google Vertex**, **OutfitAnyone-Plus**, **Photoroom**, Amazon Nova, Kling, FASHN, Pruna). Next **local weights:** Leffa (MIT, commercial-friendlier) or CatVTON (Slice D efficiency pick, CC BY-NC-SA). Full tables: Wave 2 above. ### Text-to-video @@ -206,18 +208,17 @@ Biology (AlphaFold, Evo2), CFD, weather, routing, chip sim, protein design — o 1. ~~`nemotron-omni` / `cosmos3` / `cosmos3-reasoner`~~ **shipped** (Path A, 29 Aug 2026). 2. ~~`google-vton`~~ **shipped** (Path A Vertex `virtual-try-on-001`, 29 Aug 2026). -3. **VTON Path A (when asked):** `outfitanyone` — dedicated DashScope try-on, not composition I2I. +3. ~~`outfitanyone-plus` / `photoroom-vton` / `photoroom-virtual-model`~~ **shipped** (Path A, 29 Aug 2026). 4. **VTON Path B (v0.1.0 Slice D):** pick **one** — `leffa` if commercial D2C self-host matters (MIT); `catvton` if the goal is <8GB OSS demos (CC BY-NC-SA). Do not ship both in the first local VTON slice. -5. Optional catalog/on-model: Photoroom Virtual Model if a customer asks for flat-lay → lifestyle without a shopper photo. -6. **New services** only after one local VTON: LipSync (A2V), TRELLIS (3D). -7. **SANA-Sprint** if we want a fast local T2I that is not another FLUX/Qwen clone. -8. Optional: `nemotron-omni-local` if someone will run 30B-A3B. +5. **New services** only after one local VTON: LipSync (A2V), TRELLIS (3D). +6. **SANA-Sprint** if we want a fast local T2I that is not another FLUX/Qwen clone. +7. Optional: `nemotron-omni-local` if someone will run 30B-A3B. --- ## Shipped (do not re-add) -Invoke-layer highlights already in the registry: FLUX.2 (+ Turbo local), Nano Banana family, GPT Image, Muse Image, Ideogram 4.0, P-Image-Ideogram, Qwen-Image API+local, Veo, Sora, LTX-2.5, Hailuo 2.3, MiniMax H3, Wan, Runway Gen-4.5, **Nemotron Omni**, **Cosmos 3 Reasoner**, **Cosmos 3 Generator**, Kimi K2.6/K2.7/K3, Qwen3.8, BEN2, dedicated cloud VTON (`flux-vto`, `google-vton`, `nova-canvas`, `kling-ai`, FASHN, `p-image-tryon`, Segmind) plus composition try-on (`nano-banana-2-lite`, `qwen-image`, `muse-image`). Full table: CLI `--help` / registry. **No dedicated local VTON weights yet** — see Wave 2 Path B. +Invoke-layer highlights already in the registry: FLUX.2 (+ Turbo local), Nano Banana family, GPT Image, Muse Image, Ideogram 4.0, P-Image-Ideogram, Qwen-Image API+local, Veo, Sora, LTX-2.5, Hailuo 2.3, MiniMax H3, Wan, Runway Gen-4.5, **Nemotron Omni**, **Cosmos 3 Reasoner**, **Cosmos 3 Generator**, Kimi K2.6/K2.7/K3, Qwen3.8, BEN2, dedicated cloud VTON (`flux-vto`, `google-vton`, `outfitanyone-plus`, `photoroom-vton`, `photoroom-virtual-model`, `nova-canvas`, `kling-ai`, FASHN, `p-image-tryon`, Segmind) plus composition try-on (`nano-banana-2-lite`, `qwen-image`, `muse-image`). Full table: CLI `--help` / registry. **No dedicated local VTON weights yet** — see Wave 2 Path B. --- diff --git a/docs/docs/getting-started/cli.md b/docs/docs/getting-started/cli.md index 72f4c38..9c7e723 100644 --- a/docs/docs/getting-started/cli.md +++ b/docs/docs/getting-started/cli.md @@ -28,7 +28,7 @@ opentryon --model [params...] | Service | What it does | Models | |---|---|---| -| `vton` | Virtual try-on: compose a garment onto a person image | `flux-vto`, `google-vton`, `nova-canvas`, `kling-ai`, `segmind`, `p-image-tryon`, `fashn-tryon-max`, `fashn-tryon-v1.6`, `nano-banana-2-lite`, `qwen-image`, `qwen-image-local`, `muse-image` | +| `vton` | Virtual try-on: compose a garment onto a person image | `flux-vto`, `google-vton`, `outfitanyone-plus`, `photoroom-vton`, `photoroom-virtual-model`, `nova-canvas`, `kling-ai`, `segmind`, `p-image-tryon`, `fashn-tryon-max`, `fashn-tryon-v1.6`, `nano-banana-2-lite`, `qwen-image`, `qwen-image-local`, `muse-image` | | `generate` | Text-to-image generation | `nano-banana`, `nano-banana-pro`, `nano-banana-2`, `flux2-pro`, `flux2-flex`, `flux2-turbo` (local), `gpt-image`, `luma-image`, `seedream`, `ideogram`, `grok-imagine-image`, `p-image`, `p-image-ideogram`, `qwen-image`, `qwen-image-local` (local), `muse-image` | | `edit` | Image editing (image + instruction → image) | `nano-banana`, `nano-banana-pro`, `nano-banana-2`, `flux2-pro`, `flux2-flex`, `flux2-turbo` (local), `gpt-image`, `seedream`, `p-image-edit`, `p-image-upscale`, `qwen-image`, `qwen-image-local` (local), `muse-image` | | `understand` | Image/video understanding | `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-vl` (local), `qwen3.8-max`, `qwen3.8` (local), `nemotron-omni`, `cosmos3-reasoner`, `llava-next` (local) | @@ -61,6 +61,16 @@ opentryon vton --model flux-vto \ opentryon vton --model google-vton \ --person-image model.png --garment-image garment.png +# Alibaba OutfitAnyone-Plus (Beijing DASHSCOPE_API_KEY; not Qwen-Image) +opentryon vton --model outfitanyone-plus \ + --person-image model.png --garment-image garment.png + +# Photoroom shopper try-on / catalog virtual model +opentryon vton --model photoroom-vton \ + --person-image selfie.jpg --garment-image dress.jpg +opentryon vton --model photoroom-virtual-model \ + --garment-image flatlay.jpg --preset-model avery + # Text-to-image opentryon generate --model nano-banana-pro \ --prompt "A fashion model wearing elegant evening wear" --resolution 4K @@ -189,6 +199,8 @@ stack trace: - [Kimi-VL open-weight local model](../local-models/kimi-vl.md) - [Qwen3.8-Max understanding](../api-reference/qwen3.8.md) - [Qwen-Image generation, edit, and try-on](../api-reference/qwen-image.md) +- [OutfitAnyone-Plus dedicated try-on](../api-reference/outfitanyone-plus.md) +- [Photoroom Virtual Try-On / Virtual Model](../api-reference/photoroom.md) - [Qwen-Image open-weight local model](../local-models/qwen-image.md) - [Qwen3.8 open-weight local model](../local-models/qwen3.8.md) - [Adding a new model to the CLI](../advanced/new-model-checklist.md) diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index efba5c2..80ef881 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -50,9 +50,12 @@ BFL_API_KEY=your_bfl_api_key # Moonshot AI (Kimi K2.6 / K2.7 Code multimodal understanding) MOONSHOT_API_KEY=your_moonshot_api_key -# Alibaba DashScope (Wan video, Qwen3.8-Max understand, Qwen-Image generate/edit/VTON) +# Alibaba DashScope (Wan, Qwen3.8-Max, Qwen-Image, OutfitAnyone-Plus) DASHSCOPE_API_KEY=your_dashscope_api_key +# Photoroom Virtual Try-On + Virtual Model +PHOTOROOM_API_KEY=your_photoroom_api_key + # MiniMax Hailuo 2.3 + MiniMax H3 video (same key; H3 uses V2) MINIMAX_API_KEY=your_minimax_api_key @@ -162,7 +165,7 @@ from tryon.datasets import FashionMNIST # Optional: QWEN_IMAGE_BASE_URL for Qwen-Image T2I / I2I / VTON ``` - Same key covers `understand --model qwen3.8-max`, `generate|edit|vton --model qwen-image`, and `video-generate --model wan-api` / `wan-3.0`. + Same key covers `understand --model qwen3.8-max`, `generate|edit|vton --model qwen-image`, `video-generate --model wan-api` / `wan-3.0`, and **Beijing-region** `vton --model outfitanyone-plus` (`aitryon-plus`). International keys used for Qwen/Wan do not unlock OutfitAnyone-Plus. Local open-weight twin (`pip install opentryon[local]`, CUDA, recent Diffusers): @@ -177,6 +180,16 @@ from tryon.datasets import FashionMNIST CLI: `opentryon generate|edit|vton --model qwen-image-local`. See [Qwen-Image local](../local-models/qwen-image.md). +### Photoroom (Virtual Try-On / Virtual Model) + +1. Activate the API at [app.photoroom.com/api](https://app.photoroom.com/api) +2. Add to `.env`: `PHOTOROOM_API_KEY=your_key` +3. Optional watermarked tests: prefix the key with `sandbox_` or set `PHOTOROOM_SANDBOX=1` + + Covers `vton --model photoroom-vton` (shopper photo + product) and + `vton --model photoroom-virtual-model` (flat-lay → on-model). Plus / Enterprise + Image Editing API. See [Photoroom](../api-reference/photoroom.md). + ### MiniMax (Hailuo 2.3 + H3) 1. Sign up at [MiniMax Open Platform](https://platform.minimax.io/) diff --git a/docs/docs/getting-started/mcp.md b/docs/docs/getting-started/mcp.md index 06dea81..207a8a3 100644 --- a/docs/docs/getting-started/mcp.md +++ b/docs/docs/getting-started/mcp.md @@ -97,6 +97,27 @@ Dedicated person + product try-on. **Not** `GEMINI_API_KEY` / Nano Banana. See [Google Virtual Try-On](../api-reference/google-vton.md). +## OutfitAnyone-Plus (DashScope, Beijing) + +Dedicated Alibaba try-on. **Not** Qwen-Image composition. Needs a China Beijing-region `DASHSCOPE_API_KEY`. + +| MCP tool | Backend | Needs | +|---|---|---| +| `vton_outfitanyone_plus` | `aitryon-plus` | Beijing `DASHSCOPE_API_KEY` | + +See [OutfitAnyone-Plus](../api-reference/outfitanyone-plus.md). + +## Photoroom (Virtual Try-On / Virtual Model) + +Image Editing API Plus. Same `PHOTOROOM_API_KEY`. Prefix with `sandbox_` for watermarked tests. + +| MCP tool | Backend | Needs | +|---|---|---| +| `vton_photoroom_vton` | Shopper try-on (`virtualModel.model.custom`) | `PHOTOROOM_API_KEY` | +| `vton_photoroom_virtual_model` | Catalog on-model (preset or custom) | `PHOTOROOM_API_KEY` | + +See [Photoroom](../api-reference/photoroom.md). + ## Muse Image tools (generate / edit / VTON) First-party Meta Model API (`MODEL_API_KEY`). **No local twin.** Muse Video is not on the API yet. diff --git a/docs/docs/intro.md b/docs/docs/intro.md index ea006d9..eab3db3 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -76,7 +76,7 @@ Plus preprocessing, datasets, Gradio demos, and TryOnDiffusion research code. - **Model integration guidelines** for Path A (API) vs Path B (local) ### Virtual try-on -Cloud adapters including FLUX VTO, **Google Vertex Virtual Try-On**, Nova Canvas, Kling AI, Segmind, **Pruna P-Image-Try-On**, **FASHN**, Nano Banana 2 Lite composition, **Qwen-Image** (API + local), and **Muse Image** composition. +Cloud adapters including FLUX VTO, **Google Vertex Virtual Try-On**, **OutfitAnyone-Plus**, **Photoroom** (try-on + virtual model), Nova Canvas, Kling AI, Segmind, **Pruna P-Image-Try-On**, **FASHN**, Nano Banana 2 Lite composition, **Qwen-Image** (API + local), and **Muse Image** composition. ### Image generate / edit Nano Banana family, FLUX.2, GPT Image, Luma Photon, **Seedream 5.0 Pro**, **Ideogram 4.0**, **Grok Imagine Image**, **Pruna P-Image / P-Image-Ideogram / Edit / Upscale**, **Qwen-Image** (DashScope 3.0 + local 2512/Edit-2511), **Muse Image**, plus local FLUX.2-dev Turbo. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index ca9ac44..a38d596 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -58,6 +58,8 @@ const sidebars: SidebarsConfig = { 'api-reference/diffusion', 'api-reference/kling-ai', 'api-reference/google-vton', + 'api-reference/outfitanyone-plus', + 'api-reference/photoroom', 'api-reference/nova-canvas', 'api-reference/segmind', 'api-reference/pruna', diff --git a/env.template b/env.template index b3add28..e8a4ea3 100644 --- a/env.template +++ b/env.template @@ -156,7 +156,11 @@ MODEL_API_KEY=your_meta_model_api_key_here # opentryon video-generate --model wan-3.0 # opentryon understand --model qwen3.8-max # opentryon generate|edit|vton --model qwen-image +# opentryon vton --model outfitanyone-plus (Beijing-region key) DASHSCOPE_API_KEY=your_dashscope_api_key_here +# OutfitAnyone-Plus (aitryon-plus) is China Beijing only — not the intl Qwen/Wan host. +# OUTFITANYONE_BASE_URL=https://dashscope.aliyuncs.com/api/v1 +# OUTFITANYONE_BASE_URL=https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1 # WAN_API_KEY=... # alias # International default: # WAN_API_BASE_URL=https://dashscope-intl.aliyuncs.com/api/v1 @@ -171,6 +175,14 @@ DASHSCOPE_API_KEY=your_dashscope_api_key_here # QWEN_IMAGE_BASE_URL=https://dashscope-intl.aliyuncs.com/api/v1 # China: QWEN_IMAGE_BASE_URL=https://dashscope.aliyuncs.com/api/v1 +# Photoroom Virtual Try-On + Virtual Model (Image Editing API Plus) +# Get API key: https://app.photoroom.com/api +# Required for: opentryon vton --model photoroom-vton +# opentryon vton --model photoroom-virtual-model +PHOTOROOM_API_KEY=your_photoroom_api_key_here +# Prefix sandbox_ for watermarked tests, or: +# PHOTOROOM_SANDBOX=1 + # Wan 2.2 local open weights (Diffusers) # Required for: opentryon video-generate --model wan-2.2 # Wan 3.0 has no official open weights; self-host stays on 2.2. diff --git a/mcp-server/README.md b/mcp-server/README.md index 4ece630..e1fab13 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -126,6 +126,9 @@ Every tool returns a structured dict: `{"success": true/false, ...}` -- never ra |---|---|---| | `vton_flux_vto` | Black Forest Labs FLUX VTO | `BFL_API_KEY` | | `vton_google_vton` | Google Vertex Virtual Try-On (`virtual-try-on-001`) | `GOOGLE_CLOUD_PROJECT` + ADC | +| `vton_outfitanyone_plus` | Alibaba OutfitAnyone-Plus (`aitryon-plus`) | Beijing `DASHSCOPE_API_KEY` | +| `vton_photoroom_vton` | Photoroom Virtual Try-On | `PHOTOROOM_API_KEY` | +| `vton_photoroom_virtual_model` | Photoroom Virtual Model (flat-lay → on-model) | `PHOTOROOM_API_KEY` | | `vton_nova_canvas` | Amazon Nova Canvas | `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | | `vton_kling_ai` | Kling AI (Kolors Virtual Try-On) | `KLING_AI_API_KEY` / `KLING_AI_SECRET_KEY` | | `vton_segmind` | Segmind Try-On Diffusion | `SEGMIND_API_KEY` | diff --git a/mcp-server/config.py b/mcp-server/config.py index 4f3d52c..7da0348 100644 --- a/mcp-server/config.py +++ b/mcp-server/config.py @@ -73,6 +73,15 @@ "Also used by Studio chat when OPENTRYON_AGENT_LLM_PROVIDER=google. " "Does not unlock Vertex Virtual Try-On (google-vton).", ), + ( + "photoroom", + "Photoroom", + "https://app.photoroom.com/api", + ("PHOTOROOM_API_KEY",), + "Image Editing API Plus. Unlocks photoroom-vton (shopper try-on) and " + "photoroom-virtual-model (flat-lay → on-model). Prefix the key with " + "sandbox_ for watermarked tests.", + ), ( "vertex", "Google Vertex Virtual Try-On", @@ -132,7 +141,8 @@ "Alibaba DashScope", "https://www.alibabacloud.com/help/en/model-studio/get-api-key", ("DASHSCOPE_API_KEY",), - "", + "Same key for Qwen3.8-Max, Qwen-Image, and Wan. OutfitAnyone-Plus " + "(outfitanyone-plus / aitryon-plus) needs a China Beijing-region key.", ), ( "aws", diff --git a/mcp-server/test_server.py b/mcp-server/test_server.py index dcd4a5d..020eed0 100644 --- a/mcp-server/test_server.py +++ b/mcp-server/test_server.py @@ -92,6 +92,9 @@ async def check_dry_run_calls() -> None: ("vton_nano_banana_2_lite", {"person": "p.jpg", "garment": "g.jpg", "dry_run": True}, False), ("vton_fashn_tryon_max", {"model_image": "p.jpg", "product_image": "g.jpg", "dry_run": True}, False), ("vton_fashn_tryon_v1_6", {"model_image": "p.jpg", "product_image": "g.jpg", "dry_run": True}, False), + ("vton_outfitanyone_plus", {"person": "p.jpg", "garment": "g.jpg", "dry_run": True}, False), + ("vton_photoroom_vton", {"person": "p.jpg", "garment": "g.jpg", "dry_run": True}, False), + ("vton_photoroom_virtual_model", {"garment": "g.jpg", "dry_run": True}, False), ("generate_nano_banana", {"prompt": "a red dress", "dry_run": True}, False), ("generate_nano_banana_2_lite", {"prompt": "a red dress", "dry_run": True}, False), ("edit_nano_banana_2_lite", {"image": "p.jpg", "prompt": "make it blue", "dry_run": True}, False), @@ -246,6 +249,20 @@ async def check_list_and_set_api_keys() -> None: u["service"] == "vton" and u["model"] == "google-vton" for u in by_id["vertex"]["unlocks"] ) + assert "photoroom" in by_id + assert by_id["photoroom"]["vars"][0]["name"] == "PHOTOROOM_API_KEY" + assert any( + u["service"] == "vton" and u["model"] == "photoroom-vton" + for u in by_id["photoroom"]["unlocks"] + ) + assert any( + u["service"] == "vton" and u["model"] == "photoroom-virtual-model" + for u in by_id["photoroom"]["unlocks"] + ) + assert any( + u["service"] == "vton" and u["model"] == "outfitanyone-plus" + for u in by_id["dashscope"]["unlocks"] + ) assert any( u["service"] == "understand" and u["model"] == "nemotron-omni" for u in by_id["nvidia"]["unlocks"] @@ -259,6 +276,7 @@ async def check_list_and_set_api_keys() -> None: assert "NVIDIA_API_KEY" in allowed assert "GOOGLE_CLOUD_PROJECT" in allowed assert "GOOGLE_CLOUD_LOCATION" in allowed + assert "PHOTOROOM_API_KEY" in allowed assert "META_MODEL_API_KEY" in allowed and "MUSE_API_KEY" in allowed gemini = next(p for p in providers if p["id"] == "gemini") assert gemini["vars"][0]["name"] == "GEMINI_API_KEY" diff --git a/tests/test_cli.py b/tests/test_cli.py index 8aaee45..0ca666e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -191,6 +191,56 @@ def check_google_vton_dry_run(): print("\u2713 vton google-vton --dry-run resolves the expected call") +def check_outfitanyone_plus_dry_run(): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + code = cli_main([ + "vton", "--model", "outfitanyone-plus", + "--person-image", "data/model-1.jpg", + "--garment-image", "data/garment.png", + "--resolution", "1024", + "--dry-run", + ]) + printed = buf.getvalue() + print(printed, end="") + assert code == 0, printed + assert "OutfitAnyonePlusAdapter" in printed and "generate_and_decode" in printed, printed + assert "'resolution': 1024" in printed, printed + print("\u2713 vton outfitanyone-plus --dry-run resolves the expected call") + + +def check_photoroom_dry_runs(): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + code = cli_main([ + "vton", "--model", "photoroom-vton", + "--person-image", "data/model-1.jpg", + "--garment-image", "data/garment.png", + "--pose", "standing", + "--dry-run", + ]) + printed = buf.getvalue() + print(printed, end="") + assert code == 0, printed + assert "PhotoroomVTONAdapter" in printed and "generate_and_decode" in printed, printed + assert "'mode': 'try-on'" in printed, printed + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + code = cli_main([ + "vton", "--model", "photoroom-virtual-model", + "--garment-image", "data/garment.png", + "--preset-model", "avery", + "--dry-run", + ]) + printed = buf.getvalue() + print(printed, end="") + assert code == 0, printed + assert "PhotoroomVTONAdapter" in printed and "generate_virtual_model" in printed, printed + assert "'preset_model': 'avery'" in printed, printed + print("\u2713 vton photoroom-vton / photoroom-virtual-model --dry-run resolve the expected calls") + + def check_gemini_omni_dry_runs(): cases = [ ( @@ -646,6 +696,8 @@ def check_new_media_models_dry_runs(): check_nano_banana_2_lite_dry_runs() check_fashn_dry_runs() check_google_vton_dry_run() + check_outfitanyone_plus_dry_run() + check_photoroom_dry_runs() check_gemini_omni_dry_runs() check_new_media_models_dry_runs() check_kimi_dry_runs() diff --git a/tests/test_planner_agent.py b/tests/test_planner_agent.py index 39d568d..5ccb896 100644 --- a/tests/test_planner_agent.py +++ b/tests/test_planner_agent.py @@ -153,6 +153,43 @@ def check_named_model_google_vton_dry_run(): print("\u2713 named-model chat dry-runs google-vton and pins virtual-try-on-001") +def check_named_model_outfitanyone_and_photoroom_dry_run(): + agent = PlannerAgent( + classifier=lambda **kwargs: Plan(intent="vton", task=kwargs["prompt"], reason="try-on") + ) + result = agent.run( + "Try the garment using aitryon-plus", + person_image="person.jpg", + garment_image="shirt.jpg", + dry_run=True, + ) + assert result["success"] is True + assert result["service"] == "vton" + assert result["model"] == "outfitanyone-plus" + assert "OutfitAnyonePlusAdapter" in (result.get("call") or "") + + result = agent.run( + "Dress this SKU with photoroom-vton", + person_image="person.jpg", + garment_image="shirt.jpg", + dry_run=True, + ) + assert result["success"] is True + assert result["model"] == "photoroom-vton" + assert "PhotoroomVTONAdapter" in (result.get("call") or "") + + from tryon.agents.planner.bind import match_named_model, slice_for_intent + + vton = slice_for_intent("vton") + plus = match_named_model("use outfitanyone-plus on this look", vton) + assert plus is not None and plus.model == "outfitanyone-plus" + vm = match_named_model("use photoroom virtual model for this flat lay", vton) + assert vm is not None and vm.model == "photoroom-virtual-model" + bare = match_named_model("use photoroom on this look", vton) + assert bare is not None and bare.model == "photoroom-vton" + print("\u2713 named-model chat dry-runs outfitanyone-plus / photoroom and pins virtual-model") + + def check_out_of_scope_does_not_delegate(): agent = PlannerAgent( classifier=lambda **kwargs: Plan(intent="out_of_scope", reason="not fashion") @@ -552,6 +589,7 @@ def main(): check_named_model_wan_30_dry_run() check_named_model_nvidia_nim_dry_run() check_named_model_google_vton_dry_run() + check_named_model_outfitanyone_and_photoroom_dry_run() check_out_of_scope_does_not_delegate() check_help_answers_without_specialist() check_normalize_help_markdown() diff --git a/tryon/agents/planner/bind.py b/tryon/agents/planner/bind.py index ad04e0e..8ecabf3 100644 --- a/tryon/agents/planner/bind.py +++ b/tryon/agents/planner/bind.py @@ -59,6 +59,20 @@ ("vertex vton", "google-vton", "vton"), ("google vton", "google-vton", "vton"), ("google-vton", "google-vton", "vton"), + ("photoroom virtual model", "photoroom-virtual-model", "vton"), + ("photoroom-virtual-model", "photoroom-virtual-model", "vton"), + ("photoroom virtual try-on", "photoroom-vton", "vton"), + ("photoroom-vton", "photoroom-vton", "vton"), + ("photoroom try-on", "photoroom-vton", "vton"), + ("photoroom vton", "photoroom-vton", "vton"), + ("photoroom", "photoroom-vton", "vton"), + ("outfitanyone-plus", "outfitanyone-plus", "vton"), + ("outfit anyone plus", "outfitanyone-plus", "vton"), + ("aitryon-plus", "outfitanyone-plus", "vton"), + ("aitryon plus", "outfitanyone-plus", "vton"), + ("outfitanyone", "outfitanyone-plus", "vton"), + ("alibaba try-on", "outfitanyone-plus", "vton"), + ("alibaba vton", "outfitanyone-plus", "vton"), ("fashn", "fashn-tryon-max", "vton"), ("flux vto", "flux-vto", "vton"), ("nova canvas", "nova-canvas", "vton"), diff --git a/tryon/api/__init__.py b/tryon/api/__init__.py index 8587d1a..c6cf97b 100644 --- a/tryon/api/__init__.py +++ b/tryon/api/__init__.py @@ -23,6 +23,8 @@ "PImageTryOnAdapter": ".vton", "FashnVTONAdapter": ".vton", "GoogleVTONAdapter": ".vton", + "OutfitAnyonePlusAdapter": ".vton", + "PhotoroomVTONAdapter": ".vton", "PImageAdapter": ".pruna", "PImageEditAdapter": ".pruna", "PImageIdeogramAdapter": ".pruna", diff --git a/tryon/api/vton/__init__.py b/tryon/api/vton/__init__.py index 3668c3b..8aaf124 100644 --- a/tryon/api/vton/__init__.py +++ b/tryon/api/vton/__init__.py @@ -11,10 +11,14 @@ from .p_image_tryon import PImageTryOnAdapter from .fashn import FashnVTONAdapter from .google_vton import GoogleVTONAdapter +from .outfitanyone import OutfitAnyonePlusAdapter +from .photoroom import PhotoroomVTONAdapter __all__ = [ "FluxVTONAdapter", "PImageTryOnAdapter", "FashnVTONAdapter", "GoogleVTONAdapter", + "OutfitAnyonePlusAdapter", + "PhotoroomVTONAdapter", ] diff --git a/tryon/api/vton/outfitanyone.py b/tryon/api/vton/outfitanyone.py new file mode 100644 index 0000000..2c420a2 --- /dev/null +++ b/tryon/api/vton/outfitanyone.py @@ -0,0 +1,364 @@ +"""Alibaba OutfitAnyone-Plus (``aitryon-plus``) virtual try-on. + +First-party DashScope / Model Studio dedicated try-on. This is **not** +Qwen-Image composition I2I (``--model qwen-image``). + +Official docs: + https://www.alibabacloud.com/help/en/model-studio/aitryon-plus-api + +The published API is **China (Beijing) only**. Use a Beijing-region +DashScope key. Async: ``POST .../image-synthesis`` with +``X-DashScope-Async: enable``, then poll ``GET /tasks/{task_id}``. + +Images must be public HTTP(S) URLs. Local files, PIL images, and raw +bytes are uploaded to DashScope temporary OSS (48h) and passed as +``oss://`` URLs with ``X-DashScope-OssResourceResolve: enable``. + +Env: + DASHSCOPE_API_KEY (required) + OUTFITANYONE_BASE_URL / AITRYON_PLUS_BASE_URL — default + https://dashscope.aliyuncs.com/api/v1 + Workspace: https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1 + +Example: + >>> from tryon.api.vton.outfitanyone import OutfitAnyonePlusAdapter + >>> adapter = OutfitAnyonePlusAdapter() + >>> images = adapter.generate_and_decode( + ... person="https://example.com/person.png", + ... garment="https://example.com/top.jpeg", + ... ) + >>> images[0].save("worn.png") +""" + +from __future__ import annotations + +import io +import os +import tempfile +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +import requests +from PIL import Image as PILImage + +ImageInput = Union[str, Path, io.BytesIO, bytes, PILImage.Image] + +DEFAULT_MODEL = "aitryon-plus" +DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/api/v1" +DEFAULT_UPLOAD_BASE = "https://dashscope.aliyuncs.com/api/v1" +SYNTHESIS_PATH = "/services/aigc/image2image/image-synthesis" +MIN_IMAGE_BYTES = 5 * 1024 +MAX_IMAGE_BYTES = 5 * 1024 * 1024 +VALID_RESOLUTIONS = {-1, 1024, 1280} +TERMINAL_OK = {"SUCCEEDED", "SUCCESS"} +TERMINAL_FAIL = {"FAILED", "CANCELED", "CANCELLED", "UNKNOWN"} + + +class OutfitAnyonePlusAdapter: + """DashScope OutfitAnyone-Plus adapter (``aitryon-plus``).""" + + def __init__( + self, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + model: Optional[str] = None, + poll_interval: float = 3.0, + timeout: float = 300.0, + ): + """ + Args: + api_key: DashScope / Model Studio key. Defaults to + ``DASHSCOPE_API_KEY``. + base_url: DashScope ``/api/v1`` host. Defaults to + ``OUTFITANYONE_BASE_URL`` / ``AITRYON_PLUS_BASE_URL``, + then the China Beijing endpoint. + model: Upstream model id. Defaults to ``aitryon-plus``. + poll_interval: Seconds between task polls. + timeout: Max seconds to wait for a task. + """ + self.api_key = api_key or os.getenv("DASHSCOPE_API_KEY") + if not self.api_key: + raise ValueError( + "DashScope API key is required for OutfitAnyone-Plus. " + "Set DASHSCOPE_API_KEY (China / Beijing Model Studio key). " + "This is not Qwen-Image composition — see " + "https://www.alibabacloud.com/help/en/model-studio/aitryon-plus-api" + ) + self.base_url = ( + base_url + or os.getenv("OUTFITANYONE_BASE_URL") + or os.getenv("AITRYON_PLUS_BASE_URL") + or DEFAULT_BASE_URL + ).rstrip("/") + self.upload_base = ( + os.getenv("DASHSCOPE_UPLOAD_URL") or DEFAULT_UPLOAD_BASE + ).rstrip("/") + self.model = model or DEFAULT_MODEL + self.poll_interval = float(poll_interval) + self.timeout = float(timeout) + + def _headers(self, *, async_enable: bool = False, resolve_oss: bool = False) -> Dict[str, str]: + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + if async_enable: + headers["X-DashScope-Async"] = "enable" + if resolve_oss: + headers["X-DashScope-OssResourceResolve"] = "enable" + return headers + + def _load_bytes(self, image_input: ImageInput) -> tuple[bytes, str]: + if isinstance(image_input, PILImage.Image): + return self._pil_to_png_or_jpeg(image_input) + if isinstance(image_input, (bytes, bytearray)): + return self._as_png_or_jpeg(bytes(image_input)) + if hasattr(image_input, "read"): + image_input.seek(0) + data = image_input.read() + image_input.seek(0) + return self._as_png_or_jpeg(data) + if isinstance(image_input, Path): + image_input = str(image_input) + if isinstance(image_input, str): + if image_input.startswith(("http://", "https://")): + response = requests.get(image_input, timeout=60) + response.raise_for_status() + return self._as_png_or_jpeg(response.content) + with open(image_input, "rb") as fh: + return self._as_png_or_jpeg(fh.read()) + raise ValueError( + "Invalid image input: must be a file path, URL, PIL Image, " + "bytes, or file-like object." + ) + + @staticmethod + def _pil_to_png_or_jpeg(image: PILImage.Image) -> tuple[bytes, str]: + fmt = (image.format or "PNG").upper() + buf = io.BytesIO() + if fmt in ("JPEG", "JPG"): + image.convert("RGB").save(buf, format="JPEG") + return buf.getvalue(), "image/jpeg" + image.save(buf, format="PNG") + return buf.getvalue(), "image/png" + + @classmethod + def _as_png_or_jpeg(cls, data: bytes) -> tuple[bytes, str]: + if data.startswith(b"\x89PNG"): + return data, "image/png" + if data.startswith(b"\xff\xd8"): + return data, "image/jpeg" + return cls._pil_to_png_or_jpeg(PILImage.open(io.BytesIO(data))) + + def _to_url(self, image_input: ImageInput, label: str) -> str: + """Return an HTTP(S) or oss:// URL the synthesis API can fetch.""" + if isinstance(image_input, str) and image_input.startswith( + ("http://", "https://", "oss://") + ): + return image_input + data, mime = self._load_bytes(image_input) + if not (MIN_IMAGE_BYTES <= len(data) <= MAX_IMAGE_BYTES): + raise ValueError( + f"{label} is {len(data):,} bytes; OutfitAnyone-Plus allows " + f"{MIN_IMAGE_BYTES:,}–{MAX_IMAGE_BYTES:,} bytes (5 KB–5 MB)." + ) + ext = ".png" if mime == "image/png" else ".jpg" + fd, path = tempfile.mkstemp(suffix=ext, prefix=f"aitryon-{label}-") + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + return self._upload_temp(path) + finally: + try: + os.unlink(path) + except OSError: + pass + + def _upload_temp(self, file_path: str) -> str: + """Upload to DashScope temporary OSS and return an ``oss://`` URL.""" + policy_url = f"{self.upload_base}/uploads" + policy_resp = requests.get( + policy_url, + headers={"Authorization": f"Bearer {self.api_key}"}, + params={"action": "getPolicy", "model": self.model}, + timeout=60, + ) + if policy_resp.status_code >= 400: + raise ValueError( + "OutfitAnyone-Plus needs a public HTTP(S) image URL, or a " + "Beijing-region DashScope key so local files can be uploaded. " + f"Upload policy failed ({policy_resp.status_code}): {policy_resp.text}" + ) + policy = (policy_resp.json() or {}).get("data") or {} + file_name = Path(file_path).name + upload_dir = policy.get("upload_dir") + if not upload_dir or not policy.get("upload_host"): + raise ValueError( + f"DashScope upload policy missing fields: {policy_resp.text}" + ) + key = f"{upload_dir}/{file_name}" + with open(file_path, "rb") as fh: + files = { + "OSSAccessKeyId": (None, policy["oss_access_key_id"]), + "Signature": (None, policy["signature"]), + "policy": (None, policy["policy"]), + "x-oss-object-acl": (None, policy["x_oss_object_acl"]), + "x-oss-forbid-overwrite": (None, policy["x_oss_forbid_overwrite"]), + "key": (None, key), + "success_action_status": (None, "200"), + "file": (file_name, fh), + } + upload_resp = requests.post(policy["upload_host"], files=files, timeout=120) + if upload_resp.status_code >= 400: + raise ValueError( + f"DashScope temp upload failed ({upload_resp.status_code}): " + f"{upload_resp.text}" + ) + return f"oss://{key}" + + def _submit(self, payload: Dict[str, Any], resolve_oss: bool) -> str: + url = f"{self.base_url}{SYNTHESIS_PATH}" + resp = requests.post( + url, + headers=self._headers(async_enable=True, resolve_oss=resolve_oss), + json=payload, + timeout=60, + ) + if resp.status_code >= 400: + raise ValueError( + f"OutfitAnyone-Plus create failed ({resp.status_code}): {resp.text}" + ) + data = resp.json() + if data.get("code") and not (data.get("output") or {}).get("task_id"): + raise ValueError( + f"OutfitAnyone-Plus create error: {data.get('code')} " + f"{data.get('message') or data}" + ) + output = data.get("output") or data + task_id = output.get("task_id") or data.get("task_id") + if not task_id: + raise ValueError(f"OutfitAnyone-Plus create missing task_id: {data}") + return str(task_id) + + def _poll(self, task_id: str) -> Dict[str, Any]: + url = f"{self.base_url}/tasks/{task_id}" + deadline = time.time() + self.timeout + while time.time() < deadline: + resp = requests.get(url, headers=self._headers(), timeout=60) + if resp.status_code >= 400: + raise ValueError( + f"OutfitAnyone-Plus poll failed ({resp.status_code}): {resp.text}" + ) + data = resp.json() + output = data.get("output") or data + status = ( + output.get("task_status") + or output.get("status") + or data.get("task_status") + or "" + ).upper() + if status in TERMINAL_OK: + return output + if status in TERMINAL_FAIL: + raise ValueError( + f"OutfitAnyone-Plus task failed: " + f"{output.get('code') or ''} {output.get('message') or output}" + ) + time.sleep(self.poll_interval) + raise TimeoutError( + f"OutfitAnyone-Plus task {task_id} timed out after {self.timeout}s" + ) + + @staticmethod + def _download_image(output: Dict[str, Any]) -> PILImage.Image: + image_url = output.get("image_url") + if not image_url: + results = output.get("results") + if isinstance(results, list) and results: + image_url = (results[0] or {}).get("url") or (results[0] or {}).get( + "image_url" + ) + if not image_url: + raise ValueError( + f"OutfitAnyone-Plus success response missing image_url: {output}" + ) + resp = requests.get(image_url, timeout=120) + if resp.status_code != 200: + raise ValueError( + f"Failed to download OutfitAnyone-Plus result ({resp.status_code})" + ) + return PILImage.open(io.BytesIO(resp.content)) + + def generate_and_decode( + self, + person: Optional[ImageInput] = None, + garment: Optional[ImageInput] = None, + *, + source_image: Optional[ImageInput] = None, + reference_image: Optional[ImageInput] = None, + model_image: Optional[ImageInput] = None, + cloth_image: Optional[ImageInput] = None, + person_image: Optional[ImageInput] = None, + garment_image: Optional[ImageInput] = None, + top_garment: Optional[ImageInput] = None, + bottom_garment: Optional[ImageInput] = None, + restore_face: bool = True, + resolution: int = -1, + **kwargs, + ) -> List[PILImage.Image]: + """Generate a try-on still and return PIL Images. + + Pass a person photo plus at least one garment. A single garment + (or dress/jumpsuit) goes to ``top_garment_url``. Combo outfits + can set both ``top_garment`` and ``bottom_garment``. + + Args: + person / garment: Person and primary garment (path, URL, PIL, bytes). + top_garment / bottom_garment: Explicit top and bottoms URLs/files. + restore_face: Keep the original face (default True). + resolution: ``-1`` (match person), ``1024`` (576x1024), or + ``1280`` (720x1280). + """ + resolved_person = person or source_image or person_image or model_image + resolved_top = ( + top_garment or garment or reference_image or garment_image or cloth_image + ) + resolved_bottom = bottom_garment or kwargs.get("bottom_garment_image") + if resolved_person is None: + raise ValueError( + "Person image is required. Pass person, source_image, " + "person_image, or model_image." + ) + if resolved_top is None and resolved_bottom is None: + raise ValueError( + "At least one garment is required (top/dress via garment or " + "top_garment, and/or bottoms via bottom_garment)." + ) + res = int(resolution if resolution is not None else -1) + if res not in VALID_RESOLUTIONS: + raise ValueError( + f"resolution must be one of {sorted(VALID_RESOLUTIONS)} " + "(-1 matches the person image)." + ) + + person_url = self._to_url(resolved_person, "person") + input_obj: Dict[str, str] = {"person_image_url": person_url} + if resolved_top is not None: + input_obj["top_garment_url"] = self._to_url(resolved_top, "top_garment") + if resolved_bottom is not None: + input_obj["bottom_garment_url"] = self._to_url( + resolved_bottom, "bottom_garment" + ) + resolve_oss = any(value.startswith("oss://") for value in input_obj.values()) + payload = { + "model": self.model, + "input": input_obj, + "parameters": { + "resolution": res, + "restore_face": bool(restore_face), + }, + } + task_id = self._submit(payload, resolve_oss=resolve_oss) + output = self._poll(task_id) + return [self._download_image(output)] diff --git a/tryon/api/vton/photoroom.py b/tryon/api/vton/photoroom.py new file mode 100644 index 0000000..7566a0d --- /dev/null +++ b/tryon/api/vton/photoroom.py @@ -0,0 +1,402 @@ +"""Photoroom Virtual Try-On and Virtual Model. + +First-party Image Editing API (Plus / Enterprise). Same +``POST /v2/edit`` endpoint; the difference is how +``virtualModel.model`` is set: + +- **Virtual Try-On** — garment + shopper photo + (``virtualModel.model.custom.imageFile`` / ``imageUrl``) +- **Virtual Model** — garment only, optional preset or custom model + (``virtualModel.model.preset.name``, default ``avery``) + +Official docs: + https://docs.photoroom.com/image-editing-api-plus-plan/virtual-try-on + https://docs.photoroom.com/image-editing-api-plus-plan/virtual-model + Product: https://www.photoroom.com/tools/virtual-model + +Auth: ``x-api-key`` header. Prefix the key with ``sandbox_`` for +watermarked test calls (or set ``PHOTOROOM_SANDBOX=1``). + +Env: + PHOTOROOM_API_KEY (required) + PHOTOROOM_BASE_URL — default https://image-api.photoroom.com + PHOTOROOM_SANDBOX — if ``1``/``true``, prefix ``sandbox_`` on the key + +Example: + >>> from tryon.api.vton.photoroom import PhotoroomVTONAdapter + >>> adapter = PhotoroomVTONAdapter() + >>> worn = adapter.generate_and_decode(person="selfie.jpg", garment="dress.jpg") + >>> catalog = adapter.generate_virtual_model(garment="flatlay.jpg", preset_model="avery") +""" + +from __future__ import annotations + +import io +import os +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union + +import requests +from PIL import Image as PILImage + +ImageInput = Union[str, Path, io.BytesIO, bytes, PILImage.Image] + +DEFAULT_BASE_URL = "https://image-api.photoroom.com" +EDIT_PATH = "/v2/edit" + +PRESET_MODELS = ( + "avery", + "sam", + "taylor", + "kendall", + "jordan", + "casey", + "maya", + "reece", + "lena", + "julia", + "jackson", + "sophia", + "emma", + "ava", + "zoe", + "fiona", +) +PRESET_SCENES = ( + "random", + "street", + "bedroom", + "sunset", + "factory", + "studio", + "coloredstudio", + "concretestudio", + "beach", + "tropical", + "library", + "forest", + "businessdistrict", + "countryside", + "flowers", + "goldenlight", + "mountain", + "pool", + "latincity", + "cafe", + "asiancity", + "nightlights", + "desert", +) +PRESET_POSES = ( + "random", + "standing", + "34turn", + "powerstance", + "walkingforward", + "handinpocket", + "crossedarms", + "back", + "overtheshoulder", + "seated", + "adjustingclothing", + "playfulspin", +) +OUTPUT_SIZES = ( + "PORTRAIT_HD_16_9", + "PORTRAIT_HD_4_3", + "PORTRAIT_HD_3_2", + "SQUARE_HD", + "LANDSCAPE_HD_3_2", + "LANDSCAPE_HD_4_3", + "LANDSCAPE_HD_16_9", +) +MODES = ("try-on", "virtual-model") + + +class PhotoroomVTONAdapter: + """Photoroom Image Editing API — Virtual Try-On and Virtual Model.""" + + def __init__( + self, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + timeout: float = 180.0, + ): + """ + Args: + api_key: Photoroom API key. Defaults to ``PHOTOROOM_API_KEY``. + base_url: API host. Defaults to ``PHOTOROOM_BASE_URL`` or + ``https://image-api.photoroom.com``. + timeout: HTTP timeout in seconds for the edit call. + """ + raw = api_key or os.getenv("PHOTOROOM_API_KEY") + if not raw: + raise ValueError( + "Photoroom API key is required. Set PHOTOROOM_API_KEY " + "(https://app.photoroom.com/api) or pass api_key. " + "Prefix the key with sandbox_ for watermarked test calls." + ) + sandbox = os.getenv("PHOTOROOM_SANDBOX", "").strip().lower() + if sandbox in {"1", "true", "yes"} and not raw.startswith("sandbox_"): + raw = f"sandbox_{raw}" + self.api_key = raw + self.base_url = ( + base_url or os.getenv("PHOTOROOM_BASE_URL") or DEFAULT_BASE_URL + ).rstrip("/") + self.timeout = float(timeout) + + def _headers(self) -> Dict[str, str]: + return {"x-api-key": self.api_key} + + def _load_bytes(self, image_input: ImageInput) -> tuple[bytes, str]: + if isinstance(image_input, PILImage.Image): + return self._pil_to_png_or_jpeg(image_input) + if isinstance(image_input, (bytes, bytearray)): + return self._as_png_or_jpeg(bytes(image_input)) + if hasattr(image_input, "read"): + image_input.seek(0) + data = image_input.read() + image_input.seek(0) + return self._as_png_or_jpeg(data) + if isinstance(image_input, Path): + image_input = str(image_input) + if isinstance(image_input, str): + if image_input.startswith(("http://", "https://")): + response = requests.get(image_input, timeout=60) + response.raise_for_status() + return self._as_png_or_jpeg(response.content) + with open(image_input, "rb") as fh: + return self._as_png_or_jpeg(fh.read()) + raise ValueError( + "Invalid image input: must be a file path, URL, PIL Image, " + "bytes, or file-like object." + ) + + @staticmethod + def _pil_to_png_or_jpeg(image: PILImage.Image) -> tuple[bytes, str]: + fmt = (image.format or "PNG").upper() + buf = io.BytesIO() + if fmt in ("JPEG", "JPG"): + image.convert("RGB").save(buf, format="JPEG") + return buf.getvalue(), "image/jpeg" + image.save(buf, format="PNG") + return buf.getvalue(), "image/png" + + @classmethod + def _as_png_or_jpeg(cls, data: bytes) -> tuple[bytes, str]: + if data.startswith(b"\x89PNG"): + return data, "image/png" + if data.startswith(b"\xff\xd8"): + return data, "image/jpeg" + return cls._pil_to_png_or_jpeg(PILImage.open(io.BytesIO(data))) + + @staticmethod + def _is_url(value: Any) -> bool: + return isinstance(value, str) and value.startswith(("http://", "https://")) + + def _file_part( + self, image_input: ImageInput, filename: str + ) -> Tuple[str, io.BytesIO, str]: + data, mime = self._load_bytes(image_input) + ext = ".png" if mime == "image/png" else ".jpg" + if not filename.endswith((".png", ".jpg", ".jpeg")): + filename = f"{filename}{ext}" + return filename, io.BytesIO(data), mime + + def _edit( + self, + *, + product: ImageInput, + person: Optional[ImageInput], + preset_model: str, + scene: str, + pose: str, + size: str, + prompt: Optional[str], + scene_image: Optional[ImageInput], + additional_product_images: Optional[Sequence[ImageInput]], + remove_background: bool, + reference_box: str, + ) -> PILImage.Image: + url = f"{self.base_url}{EDIT_PATH}" + data: Dict[str, str] = { + "removeBackground": "true" if remove_background else "false", + "referenceBox": reference_box, + "virtualModel.mode": "ai.auto", + "virtualModel.pose": pose, + "virtualModel.size": size, + } + files: Dict[str, Any] = {} + + if self._is_url(product): + data["imageUrl"] = str(product) + else: + files["imageFile"] = self._file_part(product, "product") + + if person is not None: + if self._is_url(person): + data["virtualModel.model.custom.imageUrl"] = str(person) + else: + files["virtualModel.model.custom.imageFile"] = self._file_part( + person, "person" + ) + else: + data["virtualModel.model.preset.name"] = preset_model + + if scene_image is not None: + if self._is_url(scene_image): + data["virtualModel.scene.custom.imageUrl"] = str(scene_image) + else: + files["virtualModel.scene.custom.imageFile"] = self._file_part( + scene_image, "scene" + ) + else: + data["virtualModel.scene.preset.name"] = scene + + if prompt: + data["virtualModel.prompt"] = prompt + + for index, extra in enumerate(additional_product_images or []): + if extra is None: + continue + if self._is_url(extra): + data[f"virtualModel.additionalProductImages[{index}].imageUrl"] = str( + extra + ) + else: + files[f"virtualModel.additionalProductImages[{index}].imageFile"] = ( + self._file_part(extra, f"extra-{index}") + ) + + resp = requests.post( + url, + headers=self._headers(), + data=data, + files=files or None, + timeout=self.timeout, + ) + if resp.status_code >= 400: + raise ValueError( + f"Photoroom Virtual Model/Try-On failed ({resp.status_code}): " + f"{resp.text[:2000]}" + ) + content_type = (resp.headers.get("Content-Type") or "").lower() + if "image/" not in content_type and not resp.content.startswith( + (b"\x89PNG", b"\xff\xd8") + ): + raise ValueError( + f"Photoroom did not return an image: {resp.text[:2000]}" + ) + return PILImage.open(io.BytesIO(resp.content)) + + def generate_and_decode( + self, + person: Optional[ImageInput] = None, + garment: Optional[ImageInput] = None, + *, + source_image: Optional[ImageInput] = None, + reference_image: Optional[ImageInput] = None, + model_image: Optional[ImageInput] = None, + cloth_image: Optional[ImageInput] = None, + person_image: Optional[ImageInput] = None, + garment_image: Optional[ImageInput] = None, + mode: str = "try-on", + preset_model: str = "avery", + scene: str = "random", + pose: str = "standing", + size: str = "PORTRAIT_HD_3_2", + prompt: Optional[str] = None, + scene_image: Optional[ImageInput] = None, + additional_product_images: Optional[Sequence[ImageInput]] = None, + remove_background: bool = False, + reference_box: str = "originalImage", + **kwargs, + ) -> List[PILImage.Image]: + """Generate a Photoroom try-on or virtual-model still. + + Args: + person / garment: Shopper and product images (path, URL, PIL, bytes). + mode: ``try-on`` (person required) or ``virtual-model`` (garment + required; person is an optional custom model). + preset_model: Photoroom preset when no custom person is passed. + scene / pose / size: Official Virtual Model enums. + prompt: Optional style hint (e.g. ``street style``). + scene_image: Optional custom scene photo. + additional_product_images: Extra angles of the same SKU. + remove_background: Official default is false so the generated + scene is kept (pair with ``referenceBox=originalImage``). + """ + resolved_person = person or source_image or person_image or model_image + resolved_garment = garment or reference_image or garment_image or cloth_image + kind = (mode or "try-on").strip().lower() + if kind not in MODES: + raise ValueError(f"mode must be one of {list(MODES)}") + preset = (preset_model or "avery").strip().lower() + if preset not in PRESET_MODELS: + raise ValueError(f"preset_model must be one of {list(PRESET_MODELS)}") + scene_name = (scene or "random").strip().lower() + if scene_name not in PRESET_SCENES: + raise ValueError(f"scene must be one of {list(PRESET_SCENES)}") + pose_name = (pose or "standing").strip().lower() + if pose_name not in PRESET_POSES: + raise ValueError(f"pose must be one of {list(PRESET_POSES)}") + size_name = (size or "PORTRAIT_HD_3_2").strip().upper() + if size_name not in OUTPUT_SIZES: + raise ValueError(f"size must be one of {list(OUTPUT_SIZES)}") + + extras = additional_product_images or kwargs.get("additional_images") + if isinstance(extras, (str, Path, bytes, bytearray, PILImage.Image)): + extras = [extras] + + if kind == "try-on": + if resolved_person is None: + raise ValueError( + "Person image is required for Photoroom Virtual Try-On. " + "Pass person / person_image / model_image." + ) + if resolved_garment is None: + raise ValueError( + "Garment/product image is required. Pass garment, " + "garment_image, or cloth_image." + ) + product, custom = resolved_garment, resolved_person + else: + # Catalog job: product is the garment. A lone person attachment + # (planner maps a single photo to person) is treated as the SKU. + product = resolved_garment or resolved_person + if product is None: + raise ValueError( + "Garment/product image is required for Photoroom Virtual " + "Model. Pass garment or garment_image." + ) + custom = resolved_person if resolved_garment is not None else None + + image = self._edit( + product=product, + person=custom, + preset_model=preset, + scene=scene_name, + pose=pose_name, + size=size_name, + prompt=prompt, + scene_image=scene_image, + additional_product_images=extras, + remove_background=bool(remove_background), + reference_box=reference_box or "originalImage", + ) + return [image] + + def generate_virtual_model( + self, + garment: Optional[ImageInput] = None, + *, + person: Optional[ImageInput] = None, + **kwargs, + ) -> List[PILImage.Image]: + """Flat-lay / product photo → on-model catalog shot.""" + return self.generate_and_decode( + person=person, + garment=garment, + mode="virtual-model", + **kwargs, + ) diff --git a/tryon/cli/registry.py b/tryon/cli/registry.py index 1e54ac6..0b7d67d 100644 --- a/tryon/cli/registry.py +++ b/tryon/cli/registry.py @@ -87,6 +87,27 @@ def _img(flags, dest, help_, required=False, default=None): _IDEOGRAM_ASPECT_RATIOS = [ "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "10:16", "16:10", "1:3", "3:1", ] +# Keep in sync with tryon.api.vton.photoroom (registry stays import-free). +_PHOTOROOM_PRESETS = [ + "avery", "sam", "taylor", "kendall", "jordan", "casey", "maya", "reece", + "lena", "julia", "jackson", "sophia", "emma", "ava", "zoe", "fiona", +] +_PHOTOROOM_SCENES = [ + "random", "street", "bedroom", "sunset", "factory", "studio", + "coloredstudio", "concretestudio", "beach", "tropical", "library", + "forest", "businessdistrict", "countryside", "flowers", "goldenlight", + "mountain", "pool", "latincity", "cafe", "asiancity", "nightlights", + "desert", +] +_PHOTOROOM_POSES = [ + "random", "standing", "34turn", "powerstance", "walkingforward", + "handinpocket", "crossedarms", "back", "overtheshoulder", "seated", + "adjustingclothing", "playfulspin", +] +_PHOTOROOM_SIZES = [ + "PORTRAIT_HD_16_9", "PORTRAIT_HD_4_3", "PORTRAIT_HD_3_2", "SQUARE_HD", + "LANDSCAPE_HD_3_2", "LANDSCAPE_HD_4_3", "LANDSCAPE_HD_16_9", +] def _gemini_aspect_ratio() -> Arg: @@ -255,6 +276,106 @@ def _qwen_image_local_sample_args(*, t2i: bool) -> List[Arg]: Arg(("--output-gcs-uri",), "output_gcs_uri", help="Optional gs:// prefix to store outputs"), ], ), + "outfitanyone-plus": ModelSpec( + id="outfitanyone-plus", + label="Alibaba OutfitAnyone-Plus (aitryon-plus)", + import_path="tryon.api.vton.outfitanyone", + class_name="OutfitAnyonePlusAdapter", + method="generate_and_decode", + output_kind="images", + env_hint="DASHSCOPE_API_KEY", + notes=( + "Dedicated DashScope try-on (aitryon-plus). Beijing-region key only. " + "Not Qwen-Image composition. Public HTTP(S) URLs, or local files " + "uploaded to DashScope temp OSS. Optional --bottom-garment-image " + "for top+bottoms combo. Dress/jumpsuit: garment only." + ), + args=[ + _img(("--person-image", "--model-image"), "person", + "Full-body front-facing person image (path or public URL)", required=True), + _img(("--garment-image", "--cloth-image", "--top-garment-image"), "garment", + "Flat-lay top or dress (path or public URL)", required=False), + _img(("--bottom-garment-image",), "bottom_garment", + "Optional flat-lay bottoms (path or public URL)"), + Arg(("--no-restore-face",), "restore_face", action="store_false", default=True, + help="Generate a random face (default: keep the original face)"), + Arg(("--resolution",), "resolution", type=int, default=-1, + choices=[-1, 1024, 1280], + help="-1 match person; 1024 = 576x1024; 1280 = 720x1280"), + Arg(("--base-url",), "base_url", target="init", + help="DashScope /api/v1 host (default: China Beijing)"), + ], + ), + "photoroom-vton": ModelSpec( + id="photoroom-vton", + label="Photoroom Virtual Try-On", + import_path="tryon.api.vton.photoroom", + class_name="PhotoroomVTONAdapter", + method="generate_and_decode", + output_kind="images", + env_hint="PHOTOROOM_API_KEY", + notes=( + "Shopper photo + product photo via Image Editing API /v2/edit. " + "Not Virtual Model (flat-lay → generated model). Prefix the key " + "with sandbox_ for watermarked tests." + ), + args=[ + _img(("--person-image", "--model-image"), "person", + "Shopper / custom model photo (path or URL)", required=True), + _img(("--garment-image", "--cloth-image", "--product-image"), "garment", + "Clothing product image (path or URL)", required=True), + Arg(("--scene",), "scene", default="random", + choices=list(_PHOTOROOM_SCENES), help="Preset scene (default random)"), + Arg(("--pose",), "pose", default="standing", + choices=list(_PHOTOROOM_POSES), + help="Pose (docs recommend standing for try-on)"), + Arg(("--size",), "size", default="PORTRAIT_HD_3_2", + choices=list(_PHOTOROOM_SIZES), help="Output aspect / HD size"), + Arg(("--prompt",), "prompt", help="Optional style hint, e.g. street style"), + Arg(("--scene-image",), "scene_image", help="Optional custom scene image"), + Arg(("--additional-product-images",), "additional_product_images", nargs="+", + help="Extra angles of the same product"), + Arg(("--remove-background",), "remove_background", action="store_true", + help="Strip the generated scene (off by default)"), + Arg(("--mode",), "mode", default="try-on", choices=["try-on"], + help="Fixed: shopper Virtual Try-On"), + ], + ), + "photoroom-virtual-model": ModelSpec( + id="photoroom-virtual-model", + label="Photoroom Virtual Model", + import_path="tryon.api.vton.photoroom", + class_name="PhotoroomVTONAdapter", + method="generate_virtual_model", + output_kind="images", + env_hint="PHOTOROOM_API_KEY", + notes=( + "Flat-lay / product photo → on-model catalog shot. No shopper " + "photo required. Optional --person-image uses a custom model " + "instead of --preset-model (default avery)." + ), + args=[ + _img(("--garment-image", "--cloth-image", "--product-image"), "garment", + "Product / flat-lay image (path or URL)", required=True), + _img(("--person-image", "--model-image"), "person", + "Optional custom model photo (overrides --preset-model)"), + Arg(("--preset-model",), "preset_model", default="avery", + choices=list(_PHOTOROOM_PRESETS), + help="Photoroom preset model when no custom person is passed"), + Arg(("--scene",), "scene", default="random", + choices=list(_PHOTOROOM_SCENES), help="Preset scene"), + Arg(("--pose",), "pose", default="standing", + choices=list(_PHOTOROOM_POSES), help="Pose"), + Arg(("--size",), "size", default="PORTRAIT_HD_3_2", + choices=list(_PHOTOROOM_SIZES), help="Output aspect / HD size"), + Arg(("--prompt",), "prompt", help="Optional style hint, e.g. street style"), + Arg(("--scene-image",), "scene_image", help="Optional custom scene image"), + Arg(("--additional-product-images",), "additional_product_images", nargs="+", + help="Extra angles of the same product"), + Arg(("--remove-background",), "remove_background", action="store_true", + help="Strip the generated scene (off by default)"), + ], + ), "nova-canvas": ModelSpec( id="nova-canvas", label="Amazon Nova Canvas",