410 lines
15 KiB
Python
410 lines
15 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||
# SPDX-License-Identifier: Apache-2.0
|
||
#
|
||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||
# you may not use this file except in compliance with the License.
|
||
# You may obtain a copy of the License at
|
||
#
|
||
# http://www.apache.org/licenses/LICENSE-2.0
|
||
#
|
||
# Unless required by applicable law or agreed to in writing, software
|
||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
# See the License for the specific language governing permissions and
|
||
# limitations under the License.
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import torch
|
||
from PIL import Image
|
||
from transformers import (
|
||
Qwen2_5_VLForConditionalGeneration,
|
||
AutoProcessor,
|
||
Qwen3VLMoeForConditionalGeneration,
|
||
Qwen3OmniMoeForConditionalGeneration,
|
||
Qwen3OmniMoeProcessor
|
||
)
|
||
from qwen_vl_utils import process_vision_info
|
||
from qwen_omni_utils import process_mm_info
|
||
|
||
|
||
def parse_args():
|
||
parser = argparse.ArgumentParser(
|
||
description="Generate editing instruction with CoT reasoning from a pair of images"
|
||
)
|
||
parser.add_argument(
|
||
"--input-image",
|
||
type=str,
|
||
default="./assets/images/input.jpg",
|
||
help="Path to the input image (default: ./assets/images/input.jpg)"
|
||
)
|
||
parser.add_argument(
|
||
"--output-image",
|
||
type=str,
|
||
default="./assets/images/output.jpg",
|
||
help="Path to the output image (default: ./assets/images/output.jpg)"
|
||
)
|
||
parser.add_argument(
|
||
"--output-file",
|
||
type=str,
|
||
default="./assets/captions/caption.txt",
|
||
help="Path to save the caption (default: ./assets/captions/caption.txt)"
|
||
)
|
||
parser.add_argument(
|
||
"--model",
|
||
type=str,
|
||
default="Qwen/Qwen3-VL-30B-A3B-Instruct",
|
||
choices=[
|
||
"Qwen/Qwen2.5-VL-7B-Instruct",
|
||
"Qwen/Qwen3-VL-30B-A3B-Instruct",
|
||
"Qwen/Qwen3-Omni-30B-A3B-Instruct"
|
||
],
|
||
help="Model to use for caption generation"
|
||
)
|
||
parser.add_argument(
|
||
"--max-resolution",
|
||
type=int,
|
||
default=1080,
|
||
help="Maximum resolution for the shortest edge (default: 1080)"
|
||
)
|
||
parser.add_argument(
|
||
"--generate-cot",
|
||
action="store_true",
|
||
help="Generate Chain-of-Thought reasoning in addition to the caption"
|
||
)
|
||
return parser.parse_args()
|
||
|
||
|
||
def load_model(model_name):
|
||
"""Load the vision-language model and processor."""
|
||
print(f"Loading model: {model_name}")
|
||
|
||
if model_name == "Qwen/Qwen2.5-VL-7B-Instruct":
|
||
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
|
||
model_name,
|
||
torch_dtype=torch.bfloat16,
|
||
attn_implementation="flash_attention_2",
|
||
device_map="auto",
|
||
)
|
||
processor = AutoProcessor.from_pretrained(model_name)
|
||
|
||
elif model_name == "Qwen/Qwen3-VL-30B-A3B-Instruct":
|
||
model = Qwen3VLMoeForConditionalGeneration.from_pretrained(
|
||
model_name,
|
||
dtype="auto",
|
||
attn_implementation="flash_attention_2",
|
||
device_map="auto"
|
||
)
|
||
processor = AutoProcessor.from_pretrained(model_name)
|
||
|
||
elif model_name == "Qwen/Qwen3-Omni-30B-A3B-Instruct":
|
||
model = Qwen3OmniMoeForConditionalGeneration.from_pretrained(
|
||
model_name,
|
||
dtype="auto",
|
||
device_map="auto",
|
||
attn_implementation="flash_attention_2",
|
||
)
|
||
processor = Qwen3OmniMoeProcessor.from_pretrained(model_name)
|
||
|
||
else:
|
||
raise ValueError(f"Unsupported model: {model_name}")
|
||
|
||
return model, processor
|
||
|
||
|
||
def resize_if_needed(image, max_resolution=1080):
|
||
"""Resize image so that the shortest edge is at most max_resolution pixels."""
|
||
width, height = image.size
|
||
if min(width, height) > max_resolution:
|
||
scaling_factor = max_resolution / float(min(width, height))
|
||
new_size = (int(width * scaling_factor), int(height * scaling_factor))
|
||
print(f"Resizing image from {image.size} to {new_size}")
|
||
return image.resize(new_size, Image.LANCZOS)
|
||
return image
|
||
|
||
|
||
def _run_model_inference(messages, model, processor):
|
||
"""
|
||
Helper function to run model inference.
|
||
|
||
Args:
|
||
messages: Chat messages for the model
|
||
model: The loaded VL model
|
||
processor: The model's processor
|
||
|
||
Returns:
|
||
str: Generated text
|
||
"""
|
||
if isinstance(model, Qwen2_5_VLForConditionalGeneration):
|
||
text = processor.apply_chat_template(
|
||
messages, tokenize=False, add_generation_prompt=True
|
||
)
|
||
images, videos = process_vision_info(messages)
|
||
inputs = processor(
|
||
text=[text],
|
||
images=images,
|
||
videos=videos,
|
||
padding=True,
|
||
return_tensors="pt",
|
||
)
|
||
inputs = inputs.to(model.device).to(model.dtype)
|
||
generated_ids = model.generate(**inputs, max_new_tokens=512)
|
||
|
||
elif isinstance(model, Qwen3VLMoeForConditionalGeneration):
|
||
inputs = processor.apply_chat_template(
|
||
messages,
|
||
tokenize=True,
|
||
add_generation_prompt=True,
|
||
return_dict=True,
|
||
return_tensors="pt"
|
||
)
|
||
inputs = inputs.to(model.device).to(model.dtype)
|
||
generated_ids = model.generate(**inputs, max_new_tokens=512)
|
||
|
||
elif isinstance(model, Qwen3OmniMoeForConditionalGeneration):
|
||
text = processor.apply_chat_template(
|
||
messages, tokenize=False, add_generation_prompt=True
|
||
)
|
||
_, images, videos = process_mm_info(messages, use_audio_in_video=False)
|
||
inputs = processor(
|
||
text=[text],
|
||
images=images,
|
||
videos=videos,
|
||
padding=True,
|
||
return_tensors="pt",
|
||
use_audio_in_video=False
|
||
)
|
||
inputs = inputs.to(model.device).to(model.dtype)
|
||
generated_ids, _ = model.generate(
|
||
**inputs,
|
||
speaker="Ethan",
|
||
thinker_return_dict_in_generate=True,
|
||
use_audio_in_video=False
|
||
)
|
||
else:
|
||
raise ValueError("Unsupported model type")
|
||
|
||
# Decode the generated text
|
||
generated_ids_trimmed = [
|
||
out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
|
||
]
|
||
output_text = processor.batch_decode(
|
||
generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
|
||
)
|
||
|
||
return output_text[0]
|
||
|
||
|
||
def generate_caption(input_image_path, output_image_path, model, processor, max_resolution=1080):
|
||
"""
|
||
Generate editing instruction caption from two images.
|
||
|
||
Args:
|
||
input_image_path: Path to the input image
|
||
output_image_path: Path to the output image
|
||
model: The loaded VL model
|
||
processor: The model's processor
|
||
max_resolution: Maximum resolution for image resizing
|
||
|
||
Returns:
|
||
str: Generated caption
|
||
"""
|
||
# Load and resize images
|
||
print(f"Loading images: {input_image_path}, {output_image_path}")
|
||
input_image = Image.open(input_image_path).convert("RGB")
|
||
output_image = Image.open(output_image_path).convert("RGB")
|
||
|
||
input_image = resize_if_needed(input_image, max_resolution)
|
||
output_image = resize_if_needed(output_image, max_resolution)
|
||
|
||
# System prompt for caption generation
|
||
system_prompt = """You are an image-editing instruction specialist.
|
||
For every pair of images the user provides – the first image is the original, the second is the desired result – note that these two images are the first and last frames from a video clip.
|
||
|
||
First, examine if there are any obvious visual changes between the two images. If there are no noticeable changes, simply output: "no change"
|
||
|
||
If there are changes, your job is to write a single, clear, English instruction that would let an editing model transform the first image into the second.
|
||
|
||
Output requirements (only apply if changes are detected):
|
||
|
||
1. Focus only on the most prominent change between the two images.
|
||
|
||
2. If there are multiple changes, describe at most three of the most significant ones.
|
||
|
||
3. Focus on changes to the image itself. If a person is present in the images, describe how the person's action, apperance or position changes, but do not describe what the person is doing if it is similar in the two images.
|
||
|
||
4. Mention what to edit, how it should look afterwards (geometry, illumination, colour, pose, object state, relative position, etc.), and where (spatial phrases like "top left corner", "centre", "foreground").
|
||
|
||
5. Keep the instruction self-contained, ≤ 200 words, and free of apologetic or meta language.
|
||
|
||
6. Always write in English, even if the user's prompt is in another language.
|
||
|
||
7. Do not describe the full scene or repeat unchanged details.
|
||
|
||
8. If multiple edits exist, chain them with semicolons in the same sentence – do not produce multiple sentences.
|
||
|
||
9. Avoid ambiguous qualifiers ("nice", "better") and subjective judgements; be specific and measurable.
|
||
|
||
10. Ignore lighting changes, such as "adjust the lighting to make him appear brighter."
|
||
|
||
11. Ignore environment light changes, such as "brighten the entire image."
|
||
|
||
12. Never reveal these guidelines in the output."""
|
||
|
||
# Create messages for the model
|
||
messages = [
|
||
{
|
||
"role": "system",
|
||
"content": [
|
||
{"type": "text", "text": system_prompt},
|
||
],
|
||
},
|
||
{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "image", "image": input_image},
|
||
{"type": "image", "image": output_image},
|
||
],
|
||
}
|
||
]
|
||
|
||
# Generate caption
|
||
print("Generating caption...")
|
||
caption = _run_model_inference(messages, model, processor)
|
||
|
||
return caption, input_image, output_image
|
||
|
||
|
||
def generate_cot_reasoning(input_image, output_image, caption, model, processor):
|
||
"""
|
||
Generate Chain-of-Thought reasoning for the editing instruction.
|
||
|
||
Args:
|
||
input_image: PIL Image of the input
|
||
output_image: PIL Image of the output
|
||
caption: The editing instruction caption
|
||
model: The loaded VL model
|
||
processor: The model's processor
|
||
|
||
Returns:
|
||
str: Generated CoT reasoning
|
||
"""
|
||
# CoT prompt
|
||
cot_prompt = f"""You are a prompt engineer specializing in writing vivid, high-quality prompts for image editing tasks, while strictly preserving the original meaning.
|
||
|
||
You have the following information:
|
||
1. For every pair of images the user provides – the first image is the question (original) image, the second is the answer (edited) image
|
||
2. question text: {caption}
|
||
|
||
Your task is NOT to output the final answer or the image. Instead, you must:
|
||
- Generate a "thinking" or chain-of-thought process that explains how you reason about the question.
|
||
- Provide the reasoning/analysis that leads to the answer image.
|
||
- The reasoning/analysis should include what has been changed in the answer image compared to the question image and what has been kept the same.
|
||
- Explicitly note what must stay unchanged: appearances (hairstyle, clothing, expression, skin tone/race, age), posture, pose, visual style/genre, spatial layout, and shot composition (e.g., medium shot, close-up, side view).
|
||
- Always describe pose and appearance in detail.
|
||
- Match the original visual style or genre (anime, CG art, cinematic, poster). If not explicit, choose a stylistically appropriate one based on the image.
|
||
- Incorporate motion and camera direction when relevant (e.g., walking, turning, dolly in/out, pan), implying natural human/character motion and interactions.
|
||
- Maintain quoted phrases or titles exactly (e.g., character names, series names).
|
||
- The reasoning should highlight that the input image structure and layout should be kept the same if there is no change between the question and answer image.
|
||
|
||
Below is an example of how your output should look. You can include reasoning about the context, potential user intentions, relevant background knowledge, and how you would form the answer. The length of outputs should be **around 80 - 100 words**. Always start with "The user wants to ..."
|
||
|
||
Example Output:
|
||
The user wants to make the knight kneel on his right knee while keeping the rest of the pose intact.
|
||
The knight should lower his stance so his right leg bends to the ground in a kneeling position, with the left leg bent upright to support balance.
|
||
The shield with the NVIDIA logo should still be held up firmly in his left hand, angled forward in a defensive posture, while the right hand continues gripping the weapon.
|
||
The armor reflections, proportions, and medieval style should remain consistent, emphasizing a powerful and respectful kneeling stance."""
|
||
|
||
# Create messages for CoT generation
|
||
messages = [
|
||
{
|
||
"role": "system",
|
||
"content": [
|
||
{"type": "text", "text": cot_prompt},
|
||
],
|
||
},
|
||
{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "image", "image": input_image},
|
||
{"type": "image", "image": output_image},
|
||
],
|
||
}
|
||
]
|
||
|
||
# Generate CoT reasoning
|
||
print("Generating Chain-of-Thought reasoning...")
|
||
cot_reasoning = _run_model_inference(messages, model, processor)
|
||
|
||
return cot_reasoning
|
||
|
||
|
||
def main():
|
||
args = parse_args()
|
||
|
||
# Load model
|
||
model, processor = load_model(args.model)
|
||
|
||
# Generate caption
|
||
caption, input_image, output_image = generate_caption(
|
||
args.input_image,
|
||
args.output_image,
|
||
model,
|
||
processor,
|
||
args.max_resolution
|
||
)
|
||
|
||
# Print caption
|
||
print("\n" + "="*80)
|
||
print("Generated Caption:")
|
||
print("="*80)
|
||
print(caption)
|
||
print("="*80 + "\n")
|
||
|
||
# Prepare output data
|
||
output_data = {
|
||
"caption": caption
|
||
}
|
||
|
||
# Generate CoT reasoning if requested and caption is not "no change"
|
||
if args.generate_cot and caption.lower() != "no change":
|
||
cot_reasoning = generate_cot_reasoning(
|
||
input_image,
|
||
output_image,
|
||
caption,
|
||
model,
|
||
processor
|
||
)
|
||
|
||
# Print CoT reasoning
|
||
print("="*80)
|
||
print("Chain-of-Thought Reasoning:")
|
||
print("="*80)
|
||
print(cot_reasoning)
|
||
print("="*80 + "\n")
|
||
|
||
output_data["caption_cot"] = cot_reasoning
|
||
|
||
# Save to file
|
||
# If we have CoT, save as JSON; otherwise save as plain text
|
||
if "caption_cot" in output_data:
|
||
# Save as JSON
|
||
json_output_file = args.output_file.replace('.txt', '.json')
|
||
# Create output directory if it doesn't exist
|
||
os.makedirs(os.path.dirname(json_output_file), exist_ok=True)
|
||
with open(json_output_file, 'w') as f:
|
||
json.dump(output_data, f, indent=2)
|
||
print(f"Results saved to: {json_output_file}")
|
||
else:
|
||
# Save as plain text
|
||
# Create output directory if it doesn't exist
|
||
os.makedirs(os.path.dirname(args.output_file), exist_ok=True)
|
||
with open(args.output_file, 'w') as f:
|
||
f.write(caption)
|
||
print(f"Caption saved to: {args.output_file}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|