Signed-off-by: princepride <wangzhipeng628@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
193 lines
6.5 KiB
Python
193 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Qwen-Image-Edit OpenAI-compatible chat client for image editing.
|
|
|
|
Usage:
|
|
python openai_chat_client.py --input qwen_image_output.png --prompt "Convert to watercolor style" --output output.png
|
|
python openai_chat_client.py --input input.png --prompt "Convert to oil painting" --seed 42
|
|
python openai_chat_client.py --input input1.png input2.png --prompt "Combine these images into a single scene"
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
from PIL import Image
|
|
|
|
|
|
def _encode_image_as_data_url(input_path: Path) -> str:
|
|
image_bytes = input_path.read_bytes()
|
|
try:
|
|
img = Image.open(BytesIO(image_bytes))
|
|
mime_type = f"image/{img.format.lower()}" if img.format else "image/png"
|
|
except Exception:
|
|
mime_type = "image/png"
|
|
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
|
|
return f"data:{mime_type};base64,{image_b64}"
|
|
|
|
|
|
def edit_image(
|
|
input_image: str | Path | list[str | Path],
|
|
prompt: str,
|
|
server_url: str = "http://localhost:8092",
|
|
height: int | None = None,
|
|
width: int | None = None,
|
|
steps: int | None = None,
|
|
guidance_scale: float | None = None,
|
|
seed: int | None = None,
|
|
negative_prompt: str | None = None,
|
|
extra_body: dict | None = None,
|
|
) -> bytes | None:
|
|
"""Edit an image using the chat completions API.
|
|
|
|
Args:
|
|
input_image: Path(s) to input image(s). For multi-image editing, pass multiple paths.
|
|
prompt: Text description of the edit
|
|
server_url: Server URL
|
|
height: Output image height in pixels
|
|
width: Output image width in pixels
|
|
steps: Number of inference steps
|
|
guidance_scale: CFG guidance scale
|
|
seed: Random seed
|
|
negative_prompt: Negative prompt
|
|
extra_body: Additional model-specific params (e.g. cfg_text_scale for BAGEL)
|
|
|
|
Returns:
|
|
Edited image bytes or None if failed
|
|
"""
|
|
input_images = input_image if isinstance(input_image, list) else [input_image]
|
|
input_paths = [Path(p) for p in input_images]
|
|
for p in input_paths:
|
|
if not p.exists():
|
|
print(f"Error: Input image not found: {p}")
|
|
return None
|
|
|
|
# Build user message with text and image
|
|
content: list[dict[str, object]] = [{"type": "text", "text": prompt}]
|
|
for p in input_paths:
|
|
content.append({"type": "image_url", "image_url": {"url": _encode_image_as_data_url(p)}})
|
|
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": content,
|
|
}
|
|
]
|
|
|
|
# Build extra_body with generation parameters
|
|
merged_extra_body: dict[str, object] = {}
|
|
if height is not None:
|
|
merged_extra_body["height"] = height
|
|
if width is not None:
|
|
merged_extra_body["width"] = width
|
|
if steps is not None:
|
|
merged_extra_body["num_inference_steps"] = steps
|
|
if guidance_scale is not None:
|
|
merged_extra_body["guidance_scale"] = guidance_scale
|
|
if seed is not None:
|
|
merged_extra_body["seed"] = seed
|
|
if negative_prompt:
|
|
merged_extra_body["negative_prompt"] = negative_prompt
|
|
if extra_body:
|
|
merged_extra_body.update(extra_body)
|
|
|
|
# Build request payload
|
|
payload: dict[str, object] = {"messages": messages}
|
|
if merged_extra_body:
|
|
payload["extra_body"] = merged_extra_body
|
|
|
|
# Send request
|
|
try:
|
|
response = requests.post(
|
|
f"{server_url}/v1/chat/completions",
|
|
headers={"Content-Type": "application/json"},
|
|
json=payload,
|
|
timeout=300,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
# Extract image from response
|
|
content = data["choices"][0]["message"]["content"]
|
|
if isinstance(content, list) and len(content) > 0:
|
|
image_url = content[0].get("image_url", {}).get("url", "")
|
|
if image_url.startswith("data:image"):
|
|
_, b64_data = image_url.split(",", 1)
|
|
return base64.b64decode(b64_data)
|
|
|
|
print(f"Unexpected response format: {content}")
|
|
return None
|
|
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
return None
|
|
|
|
|
|
def parse_extra_body(value: str) -> dict:
|
|
"""Parse a JSON string into a dict for --extra-body."""
|
|
import json
|
|
|
|
try:
|
|
obj = json.loads(value)
|
|
except json.JSONDecodeError as e:
|
|
raise argparse.ArgumentTypeError(f"--extra-body must be valid JSON: {e}") from e
|
|
if not isinstance(obj, dict):
|
|
raise argparse.ArgumentTypeError("--extra-body must be a JSON object")
|
|
return obj
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Qwen-Image-Edit chat client")
|
|
parser.add_argument("--input", "-i", required=True, nargs="+", help="Input image path(s)")
|
|
parser.add_argument("--prompt", "-p", required=True, help="Edit prompt")
|
|
parser.add_argument("--output", "-o", default="output.png", help="Output file")
|
|
parser.add_argument("--server", "-s", default="http://localhost:8092", help="Server URL")
|
|
parser.add_argument("--height", type=int, default=1024, help="Output image height")
|
|
parser.add_argument("--width", type=int, default=1024, help="Output image width")
|
|
parser.add_argument("--steps", type=int, default=50, help="Inference steps")
|
|
parser.add_argument("--guidance", type=float, default=7.5, help="Guidance scale")
|
|
parser.add_argument("--seed", type=int, default=0, help="Random seed")
|
|
parser.add_argument("--negative", help="Negative prompt")
|
|
parser.add_argument(
|
|
"--extra-body",
|
|
type=parse_extra_body,
|
|
default=None,
|
|
help="JSON object merged into request extra_body, e.g. '{\"cfg_text_scale\": 4.0}'.",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if len(args.input) == 1:
|
|
print(f"Input: {args.input[0]}")
|
|
else:
|
|
print(f"Inputs ({len(args.input)}): {', '.join(args.input)}")
|
|
print(f"Prompt: {args.prompt}")
|
|
|
|
image_bytes = edit_image(
|
|
input_image=args.input,
|
|
prompt=args.prompt,
|
|
server_url=args.server,
|
|
height=args.height,
|
|
width=args.width,
|
|
steps=args.steps,
|
|
guidance_scale=args.guidance,
|
|
seed=args.seed,
|
|
negative_prompt=args.negative,
|
|
extra_body=args.extra_body,
|
|
)
|
|
|
|
if image_bytes:
|
|
output_path = Path(args.output)
|
|
output_path.write_bytes(image_bytes)
|
|
print(f"Image saved to: {output_path}")
|
|
print(f"Size: {len(image_bytes) / 1024:.1f} KB")
|
|
else:
|
|
print("Failed to edit image")
|
|
exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|