[FEATURE] /v1/images/edit interface (#1101)

Signed-off-by: dengyunyang <584797741@qq.com>
This commit is contained in:
dengyunyang authored and GitHub committed 2026-01-31 14:13:36 +08:00
1 parent 722a69e8d1
commit 70a5de9dd4
6 files changed
+975 -141

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ nav:
- Serving:
- OpenAI-Compatible API:
- Image Generation: serving/image_generation_api.md
- Image Edit: serving/image_edit_api.md
- Examples:
- examples/README.md
- Offline Inference:
+205
View File
@@ -0,0 +1,205 @@
# Image Edit API
vLLM-Omni provides an OpenAI DALL-E compatible API for image edit using diffusion models.
Each server instance runs a single model (specified at startup via `vllm serve <model> --omni`).
## Quick Start
### Start the Server
For example...
```bash
# Qwen-Image
vllm serve Qwen/Qwen-Image-Edit-2511 --omni --port 8000
### Generate Images
**Using curl:**
```bash
curl -s -D >(grep -i x-request-id >&2) \
-o >(jq -r '.data[0].b64_json' | base64 --decode > gift-basket.png) \
-X POST "http://localhost:8000/v1/images/edits" \
-F "model=xxx" \
-F "image=@./xx.png" \
-F "prompt='this bear is wearing sportwear. holding a basketball, and bending one leg.'" \
-F "size=1024x1024" \
-F "output_format=png"
```
**Using OpenAI SDK:**
```python
import base64
from openai import OpenAI
from pathlib import Path
client = OpenAI(
api_key="None",
base_url="http://localhost:8000/v1"
)
input_image_url = "https://vllm-public-assets.s3.us-west-2.amazonaws.com/omni-assets/qwen-bear.png"
result = client.images.edit(
image=[],
model="Qwen-Image-Edit-2511",
prompt="Change the bears in the two input images into walking together.",
size='512x512',
stream=False,
output_format='jpeg',
# url格式
extra_body={
"url": [input_image_url1,input_image_url],
"num_inference_steps": 50,
"guidance_scale": 1,
"seed": 777,
}
)
image_base64 = result.data[0].b64_json
image_bytes = base64.b64decode(image_base64)
# Save the image to a file
with open("edit_out_http.jpeg", "wb") as f:
f.write(image_bytes)
```
## API Reference
### Endpoint
```
POST /v1/images/edits
Content-Type: multipart/form-data
```
### Request Parameters
#### OpenAI Standard Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt` | string | **required** | A text description of the desired image |
| `model` | string | server's model | Model to use (optional, should match server if specified) |
| `image` | string or array | **required** | The image(s) to edit. |
| `n` | integer | 1 | Number of images to generate (1-10) |
| `size` | string | "auto" | Image dimensions in WxH format (e.g., "1024x1024", "512x512"), when set to auto, it decide size from first input image. |
| `response_format` | string | "b64_json" | Response format (only "b64_json" supported) |
| `user` | string | null | User identifier for tracking |
| `output_format` | string | "png" | The format in which the generated images are returned. Must be one of "png", "jpg", "jpeg", "webp". |
| `output_compression` | integer | 100 | The compression level (0-100%) for the generated images. |
| `background` | string or null | "auto" | Allows to set transparency for the background of the generated image(s).
#### vllm-omni Extension Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `url` | string or array | None | The image(s) to edit. |
| `negative_prompt` | string | null | Text describing what to avoid in the image |
| `num_inference_steps` | integer | model defaults | Number of diffusion steps |
| `guidance_scale` | float | model defaults | Classifier-free guidance scale (typically 0.0-20.0) |
| `true_cfg_scale` | float | model defaults | True CFG scale (model-specific parameter, may be ignored if not supported) |
| `seed` | integer | null | Random seed for reproducibility |
### Response Format
```json
{
"created": 1701234567,
"data": [
{
"b64_json": "<base64-encoded PNG>",
"url": null,
"revised_prompt": null
}
],
"output_format": null,
"size": null,
}
```
## Examples
### Multiple Images input
```bash
curl -s -D >(grep -i x-request-id >&2) \
-o >(jq -r '.data[0].b64_json' | base64 --decode > gift-basket.png) \
-X POST "http://localhost:8000/v1/images/edits" \
-F "model=xxx" \
-F "image=@xx.png" \
-F "image=@xx.png"
-F "prompt='this bear is wearing sportwear. holding a basketball, and bending one leg.'" \
-F "size=1024x1024" \
-F "output_format=png"
```
## Parameter Handling
The API passes parameters directly to the diffusion pipeline without model-specific transformation:
- **Default values**: When parameters are not specified, the underlying model uses its own defaults
- **Pass-through design**: User-provided values are forwarded directly to the diffusion engine
- **Minimal validation**: Only basic type checking and range validation at the API level
### Parameter Compatibility
The API passes parameters directly to the diffusion pipeline without model-specific validation.
- Unsupported parameters may be silently ignored by the model
- Incompatible values will result in errors from the underlying pipeline
- Recommended values vary by model - consult model documentation
**Best Practice:** Start with the model's recommended parameters, then adjust based on your needs.
## Error Responses
### 400 Bad Request
Invalid parameters (e.g., model mismatch):
```json
{
"detail": "Invalid size format: '1024x'. Expected format: 'WIDTHxHEIGHT' (e.g., '1024x1024')."
}
```
### 422 Unprocessable Entity
Validation errors (missing required fields):
```json
{
"detail": "Field 'image' or 'url' is required"
}
```
## Troubleshooting
### Server Not Running
```bash
# Check if server is responding
curl -X http://localhost:8000/v1/images/edit \
-F "prompt='test'"
```
### Out of Memory
If you encounter OOM errors:
1. Reduce image size: `"size": "512x512"`
2. Reduce inference steps: `"num_inference_steps": 25`
## Development
Enable debug logging to see prompts and generation details:
```bash
vllm serve Qwen/Qwen-Image-Edit-2511 --omni \
--uvicorn-log-level debug
```
@@ -9,6 +9,7 @@ OpenAI-compatible async text-to-image generation API endpoints in api_server.py.
import base64
import io
from argparse import Namespace
from unittest.mock import AsyncMock, Mock
import pytest
@@ -115,9 +116,11 @@ class FakeAsyncOmni:
self.stage_list = ["llm", "diffusion"]
self.default_sampling_params_list = [SamplingParams(temperature=0.1), OmniDiffusionSamplingParams()]
self.captured_sampling_params_list = None
self.captured_prompt = None
async def generate(self, prompt, request_id, sampling_params_list):
self.captured_sampling_params_list = sampling_params_list
self.captured_prompt = prompt
images = [Image.new("RGB", (64, 64), color="green")]
yield MockGenerationResult(images)
@@ -133,6 +136,8 @@ def mock_async_diffusion():
# Return n PIL images wrapped in result object
print("!!!!!!!!!!!!!!!!!!!!! kwargs", kwargs)
n = kwargs["sampling_params_list"][0].num_outputs_per_prompt
mock.captured_sampling_params_list = kwargs["sampling_params_list"]
mock.captured_prompt = kwargs["prompt"]
images = [Image.new("RGB", (64, 64), color="blue") for _ in range(n)]
return MockGenerationResult(images)
@@ -155,6 +160,10 @@ def test_client(mock_async_diffusion):
app.state.diffusion_engine = mock_async_diffusion # Also set for health endpoint
app.state.stage_configs = [{"stage_type": "diffusion"}]
app.state.diffusion_model_name = "Qwen/Qwen-Image" # For models endpoint
app.state.args = Namespace(
default_sampling_params='{"0": {"num_inference_steps":4, "guidance_scale":7.5}}',
max_generated_image_size=4096, # 64*64
)
return TestClient(app)
@@ -171,7 +180,10 @@ def async_omni_test_client():
app.state.engine_client = FakeAsyncOmni()
app.state.stage_configs = [{"stage_type": "llm"}, {"stage_type": "diffusion"}]
app.state.args = Namespace(
default_sampling_params='{"1": {"num_inference_steps":4, "guidance_scale":7.5}}',
max_generated_image_size=4096, # 64*64
)
return TestClient(app)
@@ -526,3 +538,279 @@ def test_model_field_omitted_works(test_client):
},
)
assert response.status_code == 200
def make_test_image_bytes(size=(64, 64)) -> bytes:
img = Image.new(
"RGB",
size,
)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def test_image_edit_images_processing(async_omni_test_client):
img_bytes_1 = make_test_image_bytes((16, 16))
img_bytes_2 = make_test_image_bytes((32, 32))
# uploadfile with image key
response = async_omni_test_client.post(
"/v1/images/edits",
files=[
("image", img_bytes_1),
("image", img_bytes_2),
],
data={"prompt": "hello world."},
)
assert response.status_code == 200
engine = async_omni_test_client.app.state.engine_client
captured_prompt = engine.captured_prompt
processed_images = captured_prompt["multi_modal_data"]["image"]
assert len(processed_images) == 2
assert isinstance(processed_images[0], Image.Image)
assert isinstance(processed_images[1], Image.Image)
assert processed_images[0].size == (16, 16)
assert processed_images[1].size == (32, 32)
# uploadfile with image[] key
response = async_omni_test_client.post(
"/v1/images/edits",
files=[
("image[]", img_bytes_2),
("image[]", img_bytes_1),
],
data={"prompt": "hello world."},
)
assert response.status_code == 200
engine = async_omni_test_client.app.state.engine_client
captured_prompt = engine.captured_prompt
processed_images = captured_prompt["multi_modal_data"]["image"]
assert len(processed_images) == 2
assert isinstance(processed_images[0], Image.Image)
assert isinstance(processed_images[1], Image.Image)
assert processed_images[0].size == (32, 32)
assert processed_images[1].size == (16, 16)
# base64 url
buf1 = io.BytesIO()
img1 = Image.new("RGB", (16, 16))
img1.save(buf1, format="PNG")
b64_1 = "data:image/png;base64," + base64.b64encode(buf1.getvalue()).decode()
buf2 = io.BytesIO()
img2 = Image.new("RGB", (24, 24))
img2.save(buf2, format="PNG")
b64_2 = "data:image/png;base64," + base64.b64encode(buf2.getvalue()).decode()
response = async_omni_test_client.post(
"/v1/images/edits",
data={
"prompt": "hello from base64",
"url": [b64_1, b64_2],
},
)
assert response.status_code == 200
processed_images = engine.captured_prompt["multi_modal_data"]["image"]
assert len(processed_images) == 2
assert isinstance(processed_images[0], Image.Image)
assert isinstance(processed_images[1], Image.Image)
assert processed_images[0].size == (16, 16)
assert processed_images[1].size == (24, 24)
def test_image_edit_parameter_pass(async_omni_test_client):
img_bytes_1 = make_test_image_bytes((16, 16))
# uploadfile with image key
response = async_omni_test_client.post(
"/v1/images/edits",
files=[("image", img_bytes_1)],
data={
"prompt": "hello world.",
"size": "16x24",
"output_format": "jpeg",
"num_inference_steps": 20,
"guidance_scale": 8.0,
"seed": 1234,
"negative_prompt": "negative",
"n": 2,
},
)
assert response.status_code == 200
engine = async_omni_test_client.app.state.engine_client
captured_prompt = engine.captured_prompt
captured_sampling_params = engine.captured_sampling_params_list[-1]
assert captured_prompt["prompt"] == "hello world."
assert captured_prompt["negative_prompt"] == "negative"
assert captured_sampling_params.num_inference_steps == 20
assert captured_sampling_params.guidance_scale == 8.0
assert captured_sampling_params.seed == 1234
assert captured_sampling_params.num_outputs_per_prompt == 2
assert captured_sampling_params.width == 16
assert captured_sampling_params.height == 24
data = response.json()
# All images should be valid
for img_data in data["data"]:
assert "b64_json" in img_data
img_bytes = base64.b64decode(img_data["b64_json"])
img = Image.open(io.BytesIO(img_bytes))
assert img.format.lower() == "jpeg"
assert data["output_format"] == "jpeg"
assert data["size"] == "16x24"
def test_image_edit_parameter_default(async_omni_test_client):
img_bytes_1 = make_test_image_bytes((24, 16))
# uploadfile with image key
response = async_omni_test_client.post(
"/v1/images/edits",
files=[("image", img_bytes_1)],
data={
"prompt": "hello world.",
"size": "auto",
},
)
assert response.status_code == 200
engine = async_omni_test_client.app.state.engine_client
captured_sampling_params = engine.captured_sampling_params_list[-1]
assert captured_sampling_params.width == 24
assert captured_sampling_params.height == 16
assert captured_sampling_params.num_outputs_per_prompt == 1
assert captured_sampling_params.num_inference_steps == 4
assert captured_sampling_params.guidance_scale == 7.5
response = async_omni_test_client.post(
"/v1/images/edits",
files=[("image", img_bytes_1)],
data={
"prompt": "hello world.",
"size": "96x96",
},
)
assert response.status_code == 400
def test_image_edit_parameter_default_single_stage(test_client):
img_bytes_1 = make_test_image_bytes((24, 16))
# uploadfile with image key
response = test_client.post(
"/v1/images/edits",
files=[("image", img_bytes_1)],
data={
"prompt": "hello world.",
},
)
assert response.status_code == 200
engine = test_client.app.state.engine_client
captured_sampling_params = engine.captured_sampling_params_list[0]
assert captured_sampling_params.width == 24
assert captured_sampling_params.height == 16
assert captured_sampling_params.num_outputs_per_prompt == 1
assert captured_sampling_params.num_inference_steps == 4
assert captured_sampling_params.guidance_scale == 7.5
response = test_client.post(
"/v1/images/edits",
files=[("image", img_bytes_1)],
data={
"prompt": "hello world.",
"size": "96x96",
},
)
assert response.status_code == 400
def test_image_edit_compression_jpeg(test_client):
img_bytes_1 = make_test_image_bytes((16, 16))
# uploadfile with image key
response = test_client.post(
"/v1/images/edits",
files=[("image", img_bytes_1)],
data={"prompt": "hello world.", "output_format": "jpeg", "output_compression": 100},
)
assert response.status_code == 200
data = response.json()
img_bytes_100 = base64.b64decode(data["data"][0]["b64_json"])
img = Image.open(io.BytesIO(img_bytes_100))
assert img.format.lower() == "jpeg"
response = test_client.post(
"/v1/images/edits",
files=[("image", img_bytes_1)],
data={
"prompt": "hello world.",
"output_format": "jpeg",
"output_compression": 50,
},
)
assert response.status_code == 200
data = response.json()
img_bytes_50 = base64.b64decode(data["data"][0]["b64_json"])
response = test_client.post(
"/v1/images/edits",
files=[("image", img_bytes_1)],
data={
"prompt": "hello world.",
"output_format": "jpeg",
"output_compression": 10,
},
)
assert response.status_code == 200
data = response.json()
img_bytes_10 = base64.b64decode(data["data"][0]["b64_json"])
assert len(img_bytes_10) < len(img_bytes_50)
assert len(img_bytes_50) < len(img_bytes_100)
def test_image_edit_compression_png(async_omni_test_client):
img_bytes_1 = make_test_image_bytes((16, 16))
# uploadfile with image key
response = async_omni_test_client.post(
"/v1/images/edits",
files=[("image", img_bytes_1)],
data={"prompt": "hello world.", "output_format": "PNG", "output_compression": 100},
)
assert response.status_code == 200
data = response.json()
img_bytes_100 = base64.b64decode(data["data"][0]["b64_json"])
img = Image.open(io.BytesIO(img_bytes_100))
assert img.format.lower() == "png"
response = async_omni_test_client.post(
"/v1/images/edits",
files=[("image", img_bytes_1)],
data={
"prompt": "hello world.",
"output_format": "PNG",
"output_compression": 50,
},
)
assert response.status_code == 200
data = response.json()
img_bytes_50 = base64.b64decode(data["data"][0]["b64_json"])
response = async_omni_test_client.post(
"/v1/images/edits",
files=[("image", img_bytes_1)],
data={
"prompt": "hello world.",
"output_format": "PNG",
"output_compression": 10,
},
)
assert response.status_code == 200
data = response.json()
img_bytes_10 = base64.b64decode(data["data"][0]["b64_json"])
assert len(img_bytes_10) < len(img_bytes_50)
assert len(img_bytes_50) < len(img_bytes_100)
+16
View File
@@ -232,6 +232,22 @@ class OmniServeCommand(CLISubcommand):
help="Number of devices for CFG parallel computation for diffusion models. "
"Equivalent to setting DiffusionParallelConfig.cfg_parallel_size.",
)
# Default sampling parameters
omni_config_group.add_argument(
"--default-sampling-params",
type=str,
help="Json str for Default sampling parameters, \n"
'Structure: {"<stage_id>": {<sampling_param>: value, ...}, ...}\n'
'e.g., \'{"0": {"num_inference_steps":50, "guidance_scale":1}}\'. '
"Currently only supports diffusion models.",
)
# Diffusion model mixed precision
omni_config_group.add_argument(
"--max-generated-image-size",
type=float,
help="The max size of generate image (height * width).",
)
return serve_parser
+462 -140
View File
@@ -1,5 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import base64
import io
import json
import multiprocessing
import multiprocessing.forkserver as forkserver
import os
@@ -12,11 +15,13 @@ from argparse import Namespace
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from http import HTTPStatus
from typing import Any, cast
from typing import Annotated, Any, cast
import httpx
import vllm.envs as envs
from fastapi import Depends, HTTPException, Request
from fastapi import Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from PIL import Image
from starlette.datastructures import State
from starlette.routing import Route
from vllm import SamplingParams
@@ -890,6 +895,251 @@ async def generate_images(request: ImageGenerationRequest, raw_request: Request)
Raises:
HTTPException: For validation errors, missing engine, or generation failures
"""
# Get engine client (AsyncOmni) from app state
engine_client, model_name, stage_types = _get_engine_and_model(raw_request)
# Validate model field (warn if mismatch, don't error)
if request.model is not None and request.model != model_name:
logger.warning(
f"Model mismatch: request specifies '{request.model}' but "
f"server is running '{model_name}'. Using server model."
)
try:
# Build params - pass through user values directly
prompt: OmniTextPrompt = {"prompt": request.prompt}
if request.negative_prompt is not None:
prompt["negative_prompt"] = request.negative_prompt
gen_params = OmniDiffusionSamplingParams(num_outputs_per_prompt=request.n)
# Parse per-request LoRA (compatible with chat's extra_body.lora shape).
lora_request, lora_scale = _parse_lora_request(request.lora)
_update_if_not_none(gen_params, "lora_request", lora_request)
_update_if_not_none(gen_params, "lora_scale", lora_scale)
# Parse and add size if provided
width, height = None, None
if request.size:
width, height = parse_size(request.size)
size_str = f"{width}x{height}"
else:
size_str = "model default"
_update_if_not_none(gen_params, "width", width)
_update_if_not_none(gen_params, "height", height)
# 3.3 Add optional parameters ONLY if provided
_update_if_not_none(gen_params, "num_inference_steps", request.num_inference_steps)
_update_if_not_none(gen_params, "guidance_scale", request.guidance_scale)
_update_if_not_none(gen_params, "true_cfg_scale", request.true_cfg_scale)
# If seed is not provided, generate a random one to ensure
# a proper generator is initialized in the backend.
# This fixes issues where using the default global generator
# might produce blurry images in some environments.
_update_if_not_none(gen_params, "seed", random.randint(0, 2**32 - 1) if request.seed is None else request.seed)
request_id = f"img_gen_{uuid.uuid4().hex}"
logger.info(f"Generating {request.n} image(s) {size_str}")
# Generate images using AsyncOmni (multi-stage mode)
result = await _generate_with_async_omni(
engine_client=engine_client,
gen_params=gen_params,
stage_types=stage_types,
prompt=prompt,
request_id=request_id,
)
if result is None:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
detail="No output generated from multi-stage pipeline.",
)
# Extract images from result
images = _extract_images_from_result(result)
logger.info(f"Successfully generated {len(images)} image(s)")
# Encode images to base64
image_data = [ImageData(b64_json=encode_image_base64(img), revised_prompt=None) for img in images]
return ImageGenerationResponse(
created=int(time.time()),
data=image_data,
)
except HTTPException:
# Re-raise HTTPExceptions as-is
raise
except ValueError as e:
logger.error(f"Validation error: {e}")
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST.value, detail=str(e))
except Exception as e:
logger.exception(f"Image generation failed: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Image generation failed: {str(e)}"
)
@router.post(
"/v1/images/edits",
responses={
HTTPStatus.OK.value: {"model": ImageGenerationResponse},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.SERVICE_UNAVAILABLE.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
async def edit_images(
raw_request: Request,
image: list[UploadFile] | None = File(None),
image_array: list[UploadFile] | None = File(None, alias="image[]"),
url: list[str] | None = Form(None),
url_array: list[str] | None = Form(None, alias="url[]"),
prompt: str = Form(...),
model: str = Form(None),
n: int = Form(1),
size: str = Form("auto"),
response_format: str = Form("b64_json"),
output_format: str | None = Form("png"),
background: str | None = Form("auto"),
output_compression: Annotated[int, Form(ge=0, le=100)] = 100,
user: str | None = Form(None), # unused now
# vllm-omni extensions for diffusion control
negative_prompt: str | None = Form(None),
num_inference_steps: int | None = Form(None),
guidance_scale: float | None = Form(None),
true_cfg_scale: float | None = Form(None),
seed: int | None = Form(None),
# vllm-omni extension for per-request LoRA.
lora: str | None = Form(None), # Json string
) -> ImageGenerationResponse:
"""
OpenAI-compatible image edit endpoint.
"""
# 1. get engine and model
engine_client, model_name, stage_types = _get_engine_and_model(raw_request)
if model is not None and model != model_name:
logger.warning(
f"Model mismatch: request specifies '{model}' but server is running '{model_name}'. Using server model."
)
# 2. get output format & compression
output_format = _choose_output_format(output_format, background)
if response_format != "b64_json":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail="Only response_format 'b64_json' is supported now.",
)
try:
# 2. Build prompt & images params
prompt: OmniTextPrompt = {"prompt": prompt}
if negative_prompt is not None:
prompt["negative_prompt"] = negative_prompt
input_images_list = []
images = image or image_array
urls = url or url_array
if images:
input_images_list.extend(images)
if urls:
input_images_list.extend(urls)
if not input_images_list:
raise HTTPException(status_code=422, detail="Field 'image' or 'url' is required")
pil_images = await _load_input_images(input_images_list)
prompt["multi_modal_data"] = {}
prompt["multi_modal_data"]["image"] = pil_images
# 3 Build sample params
gen_params = OmniDiffusionSamplingParams()
# 3.0 Init with system default values
app_state_args = getattr(raw_request.app.state, "args", None)
default_sample_param = getattr(app_state_args, "default_sampling_params", None)
# Currently only have one diffusion stage
diffusion_stage_id = [i for i, t in enumerate(stage_types) if t == "diffusion"][0]
apply_stage_default_sampling_params(
default_sample_param,
gen_params,
str(diffusion_stage_id),
)
_update_if_not_none(gen_params, "num_outputs_per_prompt", n)
# 3.1 Parse per-request LoRA (compatible with chat's extra_body.lora shape).
lora_dict = _get_lora_from_json_str(lora)
lora_request, lora_scale = _parse_lora_request(lora_dict)
_update_if_not_none(gen_params, "lora_request", lora_request)
_update_if_not_none(gen_params, "lora_scale", lora_scale)
# 3.2 Parse and add size if provided
max_generated_image_size = getattr(app_state_args, "max_generated_image_size", None)
width, height = None, None
if size.lower() == "auto":
width, height = pil_images[0].size # Use first image size
else:
width, height = parse_size(size)
if max_generated_image_size is not None and (width * height > max_generated_image_size):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail=f"Requested image size {width}x{height} exceeds the maximum allowed "
f"size of {max_generated_image_size} pixels.",
)
size_str = f"{width}x{height}"
_update_if_not_none(gen_params, "width", width)
_update_if_not_none(gen_params, "height", height)
# 3.3 Add optional parameters ONLY if provided
_update_if_not_none(gen_params, "num_inference_steps", num_inference_steps)
_update_if_not_none(gen_params, "guidance_scale", guidance_scale)
_update_if_not_none(gen_params, "true_cfg_scale", true_cfg_scale)
# If seed is not provided, generate a random one to ensure
# a proper generator is initialized in the backend.
# This fixes issues where using the default global generator
# might produce blurry images in some environments.
_update_if_not_none(gen_params, "seed", seed or random.randint(0, 2**32 - 1))
# 4. Generate images using AsyncOmni (multi-stage mode)
request_id = f"img_edit_{int(time.time())}"
logger.info(f"Generating {n} image(s) {size_str}")
result = await _generate_with_async_omni(
engine_client=engine_client,
gen_params=gen_params,
stage_types=stage_types,
prompt=prompt,
request_id=request_id,
)
# 5. Extract images from result
images = _extract_images_from_result(result)
logger.info(f"Successfully generated {len(images)} image(s)")
# Encode images to base64
image_data = [
ImageData(
b64_json=_encode_image_base64_with_compression(
img, format=output_format, output_compression=output_compression
),
revised_prompt=None,
)
for img in images
]
return ImageGenerationResponse(
created=int(time.time()),
data=image_data,
output_format=output_format,
size=size_str,
)
except HTTPException:
# Re-raise HTTPExceptions as-is
raise
except ValueError as e:
logger.error(f"Validation error: {e}")
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST.value, detail=str(e))
except Exception as e:
logger.exception(f"Image edit failed: {e}")
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Image edit failed: {str(e)}")
def _get_engine_and_model(raw_request: Request):
# Get engine client (AsyncOmni) from app state
engine_client: EngineClient | AsyncOmni | None = getattr(raw_request.app.state, "engine_client", None)
if engine_client is None or not hasattr(engine_client, "stage_list"):
@@ -942,157 +1192,229 @@ async def generate_images(request: ImageGenerationRequest, raw_request: Request)
else:
model_name = "unknown"
# Validate model field (warn if mismatch, don't error)
if request.model is not None and request.model != model_name:
logger.warning(
f"Model mismatch: request specifies '{request.model}' but "
f"server is running '{model_name}'. Using server model."
)
return engine_client, model_name, stage_types
def _get_lora_from_json_str(lora_body):
if lora_body is None:
return None
try:
# Build params - pass through user values directly
prompt: OmniTextPrompt = {"prompt": request.prompt}
gen_params = OmniDiffusionSamplingParams(num_outputs_per_prompt=request.n)
lora_dict = json.loads(lora_body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid LoRA JSON string")
# Parse per-request LoRA (compatible with chat's extra_body.lora shape).
if request.lora is not None:
if not isinstance(request.lora, dict):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail="Invalid lora field: expected an object.",
)
lora_body = request.lora
lora_name = lora_body.get("name") or lora_body.get("lora_name") or lora_body.get("adapter")
lora_path = (
lora_body.get("local_path")
or lora_body.get("path")
or lora_body.get("lora_path")
or lora_body.get("lora_local_path")
)
lora_scale = lora_body.get("scale")
if lora_scale is None:
lora_scale = lora_body.get("lora_scale")
lora_int_id = lora_body.get("int_id")
if lora_int_id is None:
lora_int_id = lora_body.get("lora_int_id")
if lora_int_id is None and lora_path:
lora_int_id = stable_lora_int_id(str(lora_path))
if not isinstance(lora_dict, dict):
raise HTTPException(status_code=400, detail="LoRA must be a JSON object")
if not lora_name or not lora_path:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail="Invalid lora object: both name and path are required.",
)
return lora_dict
gen_params.lora_request = LoRARequest(str(lora_name), int(lora_int_id), str(lora_path))
if lora_scale is not None:
gen_params.lora_scale = float(lora_scale)
# Parse and add size if provided
if request.size:
width, height = parse_size(request.size)
gen_params.height = height
gen_params.width = width
size_str = f"{width}x{height}"
else:
size_str = "model default"
# Add optional parameters ONLY if provided
if request.num_inference_steps is not None:
gen_params.num_inference_steps = request.num_inference_steps
if request.negative_prompt is not None:
prompt["negative_prompt"] = request.negative_prompt
if request.guidance_scale is not None:
gen_params.guidance_scale = request.guidance_scale
if request.true_cfg_scale is not None:
gen_params.true_cfg_scale = request.true_cfg_scale
if request.seed is not None:
gen_params.seed = request.seed
else:
# If seed is not provided, generate a random one to ensure
# a proper generator is initialized in the backend.
# This fixes issues where using the default global generator
# might produce blurry images in some environments.
gen_params.seed = random.randint(0, 2**32 - 1)
request_id = f"img_gen_{uuid.uuid4().hex}"
logger.info(f"Generating {request.n} image(s) {size_str}")
# Generate images using AsyncOmni (multi-stage mode)
engine_client = cast(AsyncOmni, engine_client)
result = None
stage_list = getattr(engine_client, "stage_list", None)
if isinstance(stage_list, list):
default_params_list: list[OmniSamplingParams] | None = getattr(
engine_client, "default_sampling_params_list", None
)
if not isinstance(default_params_list, list):
default_params_list = [
OmniDiffusionSamplingParams() if st == "diffusion" else SamplingParams() for st in stage_types
]
else:
default_params_list = list(default_params_list)
if len(default_params_list) != len(stage_types):
default_params_list = (
default_params_list
+ [OmniDiffusionSamplingParams() if st == "diffusion" else SamplingParams() for st in stage_types]
)[: len(stage_types)]
sampling_params_list: list[OmniSamplingParams] = []
for idx, stage_type in enumerate(stage_types):
if stage_type == "diffusion":
sampling_params_list.append(gen_params)
else:
base_params = default_params_list[idx]
sampling_params_list.append(base_params)
async for output in engine_client.generate(
prompt=prompt,
request_id=request_id,
sampling_params_list=sampling_params_list,
):
result = output
else:
result = await engine_client.generate(
prompt=prompt, request_id=request_id, sampling_params_list=[gen_params]
)
if result is None:
def _parse_lora_request(lora_body: dict[str, Any]):
if lora_body is not None:
if not isinstance(lora_body, dict):
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
detail="No output generated from multi-stage pipeline.",
status_code=HTTPStatus.BAD_REQUEST.value,
detail="Invalid lora field: expected an object.",
)
lora_name = lora_body.get("name") or lora_body.get("lora_name") or lora_body.get("adapter")
lora_path = (
lora_body.get("local_path")
or lora_body.get("path")
or lora_body.get("lora_path")
or lora_body.get("lora_local_path")
)
lora_scale = lora_body.get("scale")
if lora_scale is None:
lora_scale = lora_body.get("lora_scale")
lora_int_id = lora_body.get("int_id")
if lora_int_id is None:
lora_int_id = lora_body.get("lora_int_id")
if lora_int_id is None and lora_path:
lora_int_id = stable_lora_int_id(str(lora_path))
if not lora_name or not lora_path:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail="Invalid lora object: both name and path are required.",
)
# Extract images from result
images = []
if hasattr(result, "images") and result.images:
images = result.images
elif hasattr(result, "request_output"):
request_output = result.request_output
if isinstance(request_output, dict) and request_output.get("images"):
images = request_output["images"]
elif hasattr(request_output, "images") and request_output.images:
images = request_output.images
return LoRARequest(str(lora_name), int(lora_int_id), str(lora_path)), lora_scale
return None, None
logger.info(f"Successfully generated {len(images)} image(s)")
# Encode images to base64
image_data = [ImageData(b64_json=encode_image_base64(img), revised_prompt=None) for img in images]
async def _generate_with_async_omni(
engine_client: AsyncOmni | Any,
gen_params: Any,
stage_types: list[str],
**kwargs,
):
engine_client = cast(AsyncOmni, engine_client)
result = None
stage_list = getattr(engine_client, "stage_list", None)
if isinstance(stage_list, list):
default_params_list: list[OmniSamplingParams] | None = getattr(
engine_client, "default_sampling_params_list", None
)
if not isinstance(default_params_list, list):
default_params_list = [
OmniDiffusionSamplingParams() if st == "diffusion" else SamplingParams() for st in stage_types
]
else:
default_params_list = list(default_params_list)
if len(default_params_list) != len(stage_types):
default_params_list = (
default_params_list
+ [OmniDiffusionSamplingParams() if st == "diffusion" else SamplingParams() for st in stage_types]
)[: len(stage_types)]
return ImageGenerationResponse(
created=int(time.time()),
data=image_data,
sampling_params_list: list[OmniSamplingParams] = []
for idx, stage_type in enumerate(stage_types):
if stage_type == "diffusion":
sampling_params_list.append(gen_params)
else:
base_params = default_params_list[idx]
sampling_params_list.append(base_params)
async for output in engine_client.generate(
sampling_params_list=sampling_params_list,
**kwargs,
):
result = output
else:
result = await engine_client.generate(
sampling_params_list=[gen_params],
**kwargs,
)
except HTTPException:
# Re-raise HTTPExceptions as-is
raise
except ValueError as e:
logger.error(f"Validation error: {e}")
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST.value, detail=str(e))
except Exception as e:
logger.exception(f"Image generation failed: {e}")
if result is None:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Image generation failed: {str(e)}"
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
detail="No output generated from multi-stage pipeline.",
)
return result
def _update_if_not_none(object: any, key: str, val: any) -> None:
if val is not None:
setattr(object, key, val)
def _extract_images_from_result(result: Any) -> list[Any]:
images = []
if hasattr(result, "images") and result.images:
images = result.images
elif hasattr(result, "request_output"):
request_output = result.request_output
if isinstance(request_output, dict) and request_output.get("images"):
images = request_output["images"]
elif hasattr(request_output, "images") and request_output.images:
images = request_output.images
return images
async def _load_input_images(
inputs: list[str],
) -> list[Image.Image]:
"""
convert to PIL.Image.Image list
"""
if isinstance(inputs, str):
inputs = [inputs]
images: list[Image.Image] = []
for inp in inputs:
# 1. URL + base64
if isinstance(inp, str) and inp.startswith("data:image"):
try:
_, b64_data = inp.split(",", 1)
image_bytes = base64.b64decode(b64_data)
img = Image.open(io.BytesIO(image_bytes))
images.append(img)
except Exception as e:
raise ValueError(f"Invalid base64 image: {e}")
# 2. URL
elif isinstance(inp, str) and inp.startswith("http"):
async with httpx.AsyncClient(timeout=60) as client:
try:
resp = await client.get(inp)
resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content))
images.append(img)
except Exception as e:
raise ValueError(f"Failed to download image from URL {inp}: {e}")
# 3. UploadFile
elif hasattr(inp, "file"):
try:
img_data = await inp.read()
img = Image.open(io.BytesIO(img_data))
images.append(img)
except Exception as e:
raise ValueError(f"Failed to open uploaded file: {e}")
else:
raise ValueError(f"Unsupported input: {inp}")
if not images:
raise ValueError("No valid input images found")
return images
def _choose_output_format(output_format: str | None, background: str | None) -> str:
# Normalize and choose extension
fmt = (output_format or "").lower()
if fmt in {"jpg", "png", "webp", "jpeg"}:
return fmt
# If transparency requested, prefer png
if (background or "auto").lower() == "transparent":
return "png"
# Default
return "jpeg"
def _encode_image_base64_with_compression(
image: Image.Image, format: str = "png", output_compression: int = 100
) -> str:
"""Encode PIL Image to base64 PNG string.
Args:
image: PIL Image object
format: Output image format (e.g., "PNG", "JPEG", "WEBP")
output_compression: Compression level (0-100%), 100 for best quality
Returns:
Base64-encoded image as string
"""
buffer = io.BytesIO()
save_kwargs = {}
if format in ("jpg", "jpeg", "webp"):
save_kwargs["quality"] = output_compression
elif format == "png":
save_kwargs["compress_level"] = max(0, min(9, 9 - output_compression // 11)) # Map 0-100 to 9-0
image.save(buffer, format=format, **save_kwargs)
buffer.seek(0)
return base64.b64encode(buffer.read()).decode("utf-8")
def apply_stage_default_sampling_params(
default_params_json: str | None,
sampling_params: any,
stage_key: str,
) -> None:
"""
Update a stage's sampling parameters with vLLM-Omni defaults.
Args:
default_params_json: JSON string of stage-keyed default parameters
sampling_params: The sampling parameters object to update
stage_key: The stage ID/key in the pipeline
"""
if default_params_json is not None:
default_params_dict = json.loads(default_params_json)
if stage_key in default_params_dict:
stage_defaults = default_params_dict[stage_key]
for param_name, param_value in stage_defaults.items():
if hasattr(sampling_params, param_name):
setattr(sampling_params, param_name, param_value)
@@ -123,3 +123,5 @@ class ImageGenerationResponse(BaseModel):
created: int = Field(..., description="Unix timestamp of when the generation completed")
data: list[ImageData] = Field(..., description="Array of generated images")
output_format: str = Field(None, description="The output format of the image generation")
size: str = Field(None, description="The size of the image generated")