-
Notifications
You must be signed in to change notification settings - Fork 311
feat(workflow): Qwen-Image-Edit Workflow GPU Block #2666
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Tyler-Odenthal
wants to merge
26
commits into
main
Choose a base branch
from
qwen-image-edit-gpu-block
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
acb8044
push initial qwen block addition
Tyler-Odenthal e4b241f
remove strength from tests and generate
Tyler-Odenthal f010854
update v1
Tyler-Odenthal 8561131
update cpu mem to false
Tyler-Odenthal 994a344
device map auto
Tyler-Odenthal ec4f2df
Update qwen_image_edit_hf.py
Tyler-Odenthal cd98e7e
Merge branch 'roboflow:main' into qwen-image-edit-gpu-block
Tyler-Odenthal f9c950f
update diffuser pipeline
Tyler-Odenthal af6c51f
Merge branch 'qwen-image-edit-gpu-block' of https://github.com/Tyler-…
Tyler-Odenthal 6cef518
LoRA flag for smaller GPUs
Tyler-Odenthal 42088d3
needs gpu flag
Tyler-Odenthal a3a495b
make style
Tyler-Odenthal 9006c7d
final edits and test
Tyler-Odenthal 44b7c6b
Merge remote-tracking branch 'upstream/main' into qwen-image-edit-gpu…
Tyler-Odenthal bb64050
minor test fix
Tyler-Odenthal fa67d04
Merge branch 'main' into qwen-image-edit-gpu-block
Tyler-Odenthal 40cbc7e
Merge branch 'main' into qwen-image-edit-gpu-block
PawelPeczek-Roboflow 2ebc3c9
Merge branch 'main' into qwen-image-edit-gpu-block
Tyler-Odenthal 4f4d3f2
Address review: version bumps, docs companions, env-var validation, i…
Tyler-Odenthal c6fb1a9
Merge branch 'main' into qwen-image-edit-gpu-block
Tyler-Odenthal 019d002
Update inference/core/workflows/core_steps/models/foundation/qwen_ima…
Tyler-Odenthal 5e6ed92
Format qwen_image_edit v1.py after kind fix; drop unused import
Tyler-Odenthal a1eb23e
Address review: lightning-by-default, load lock + single-entry cache …
Tyler-Odenthal c96c14a
test: fix flaky visual_search_classifier batch test
Tyler-Odenthal 2c6d48e
test: make Qwen-Image-Edit integration tests strictly opt-in
Tyler-Odenthal 5b04793
Merge branch 'main' into qwen-image-edit-gpu-block
Tyler-Odenthal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| __version__ = "1.3.5" | ||
| __version__ = "1.3.6" | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
372 changes: 372 additions & 0 deletions
372
inference/core/workflows/core_steps/models/foundation/qwen_image_edit/v1.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,372 @@ | ||
| """Qwen-Image-Edit workflow block (v1). | ||
|
|
||
| Takes an input image and a text editing instruction and returns an edited image. | ||
| Runs locally on GPU only — no remote/hosted execution path. | ||
|
|
||
| Model: Qwen/Qwen-Image-Edit (HuggingFace) | ||
| Architecture key: qwen-image-edit | ||
| Task: image-editing | ||
| """ | ||
|
|
||
| import logging | ||
| import os | ||
| import uuid | ||
| from threading import Lock | ||
| from typing import Dict, List, Literal, Optional, Tuple, Type, Union | ||
|
|
||
| import numpy as np | ||
| from pydantic import ConfigDict, Field | ||
|
|
||
| from inference.core.workflows.core_steps.common.entities import StepExecutionMode | ||
| from inference.core.workflows.execution_engine.entities.base import ( | ||
| Batch, | ||
| ImageParentMetadata, | ||
| OutputDefinition, | ||
| WorkflowImageData, | ||
| ) | ||
| from inference.core.workflows.execution_engine.entities.types import ( | ||
| BOOLEAN_KIND, | ||
| FLOAT_KIND, | ||
| IMAGE_KIND, | ||
| INTEGER_KIND, | ||
| STRING_KIND, | ||
| ImageInputField, | ||
| Selector, | ||
| ) | ||
| from inference.core.workflows.prototypes.block import ( | ||
| AirGappedAvailability, | ||
| BlockResult, | ||
| Runtime, | ||
| RuntimeRestriction, | ||
| Severity, | ||
| WorkflowBlock, | ||
| WorkflowBlockManifest, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| LONG_DESCRIPTION = """ | ||
| Edit an image using a text instruction with **Qwen-Image-Edit**, Alibaba's | ||
| diffusion-based image editing model. | ||
|
|
||
| Provide a source image and describe the change you want to make (e.g. *"change | ||
| the sky to a sunset"*, *"add a red hat"*, *"remove the background"*). The block | ||
| returns the edited image so it can be passed to downstream blocks or saved as an | ||
| output. | ||
|
|
||
| #### ⚠️ Requirements | ||
|
|
||
| * Requires a **local GPU** — this block cannot run on CPU or hosted inference. | ||
| * Weights are loaded from `local_weights_path` if provided, otherwise (with the | ||
| default Lightning configuration) pulled directly from HuggingFace — no | ||
| Roboflow registry / API key needed. | ||
|
|
||
| #### Lightning LoRA (fast / low-VRAM, enabled by default) | ||
|
|
||
| By default the block fuses the lightx2v **Qwen-Image-Lightning** | ||
| step-distillation LoRA into the pipeline. The model then runs in ~4 diffusion | ||
| steps with guidance disabled — dramatically faster and feasible on consumer | ||
| GPUs. Disable `use_lightning_lora` to run the full base model (higher quality, | ||
| much slower and heavier); the full model is fetched from the Roboflow model | ||
| registry using `model_id`. On GPUs with limited VRAM set | ||
| `INFERENCE_MODELS_QWEN_IMAGE_EDIT_CPU_OFFLOAD=sequential`. | ||
|
|
||
| #### Parameters | ||
|
|
||
| | Parameter | Default | Notes | | ||
| |---|---|---| | ||
| | `prompt` | — | Required editing instruction | | ||
| | `local_weights_path` | None | Absolute path to locally downloaded weights directory | | ||
| | `use_lightning_lora` | True | Fuse the 4-step Qwen-Image-Lightning LoRA | | ||
| | `num_inference_steps` | auto | Auto = 4 with LoRA, 28 otherwise | | ||
| | `guidance_scale` | auto | Auto = 1.0 with LoRA, 5.0 otherwise | | ||
| | `seed` | None | Set for reproducible outputs | | ||
| """ | ||
|
|
||
| DEFAULT_MODEL_ID = "qwen-image-edit/1" | ||
|
|
||
|
|
||
| class BlockManifest(WorkflowBlockManifest): | ||
| model_config = ConfigDict( | ||
| json_schema_extra={ | ||
| "name": "Qwen-Image-Edit", | ||
| "version": "v1", | ||
| "short_description": "Edit an image with a text instruction using Qwen-Image-Edit.", | ||
| "long_description": LONG_DESCRIPTION, | ||
| "license": "Apache-2.0", | ||
| "block_type": "model", | ||
| "search_keywords": [ | ||
| "Qwen", | ||
| "image edit", | ||
| "image editing", | ||
| "diffusion", | ||
| "Alibaba", | ||
| "generative", | ||
| ], | ||
| "ui_manifest": { | ||
| "section": "model", | ||
| "icon": "fal fa-paintbrush", | ||
| "blockPriority": 6, | ||
| "needsGPU": True, | ||
| }, | ||
| }, | ||
| protected_namespaces=(), | ||
| ) | ||
| type: Literal["roboflow_core/qwen_image_edit@v1"] | ||
|
|
||
| images: Selector(kind=[IMAGE_KIND]) = ImageInputField | ||
|
|
||
| prompt: Union[Selector(kind=[STRING_KIND]), str] = Field( | ||
| description="Text instruction describing the desired edit (e.g. 'make the sky orange at sunset').", | ||
| examples=["remove the background", "$inputs.edit_prompt"], | ||
| json_schema_extra={"multiline": True}, | ||
| ) | ||
|
|
||
| model_id: Union[Selector(kind=[STRING_KIND]), str] = Field( | ||
| default=DEFAULT_MODEL_ID, | ||
| description=( | ||
| "Roboflow model-registry id for the Qwen-Image-Edit weights. Only used " | ||
| "when use_lightning_lora is False and no local_weights_path is set." | ||
| ), | ||
| examples=[DEFAULT_MODEL_ID], | ||
| ) | ||
|
|
||
| local_weights_path: Optional[Union[Selector(kind=[STRING_KIND]), str]] = Field( | ||
| default=None, | ||
| description=( | ||
| "Absolute path to a locally downloaded Qwen-Image-Edit weights directory. " | ||
| "When set, skips the Roboflow registry and loads directly from disk. " | ||
| "Useful for development before weights are registered." | ||
| ), | ||
| examples=["/tmp/qwen-image-edit-weights", "$inputs.weights_path"], | ||
| ) | ||
|
|
||
| use_lightning_lora: Union[Selector(kind=[BOOLEAN_KIND]), bool] = Field( | ||
| default=True, | ||
| description=( | ||
| "Fuse the lightx2v Qwen-Image-Lightning step-distillation LoRA into the " | ||
| "pipeline (default). This lets the model run in ~4 diffusion steps " | ||
| "(guidance disabled), making it dramatically faster and feasible on " | ||
| "consumer GPUs. When enabled and no weights path is given, the base " | ||
| "model and LoRA are pulled directly from HuggingFace (no Roboflow " | ||
| "registry / API key needed). Disable to run the full base model fetched " | ||
| "from the Roboflow registry via model_id." | ||
| ), | ||
| examples=[True, "$inputs.use_lightning_lora"], | ||
| ) | ||
|
|
||
| num_inference_steps: Optional[Union[Selector(kind=[INTEGER_KIND]), int]] = Field( | ||
| default=None, | ||
| description=( | ||
| "Number of diffusion denoising steps. More steps improve quality at the " | ||
| "cost of speed. Leave unset to auto-select (4 with the Lightning LoRA, " | ||
| "28 otherwise)." | ||
| ), | ||
| examples=[4, 28, 50], | ||
| ) | ||
|
|
||
| guidance_scale: Optional[Union[Selector(kind=[FLOAT_KIND]), float]] = Field( | ||
| default=None, | ||
| description=( | ||
| "Classifier-free guidance scale. Higher values make the output adhere " | ||
| "more strongly to the prompt. Leave unset to auto-select (1.0 with the " | ||
| "Lightning LoRA, 5.0 otherwise)." | ||
| ), | ||
| examples=[1.0, 5.0, 7.5], | ||
| ) | ||
|
|
||
| seed: Optional[Union[Selector(kind=[INTEGER_KIND]), int]] = Field( | ||
| default=None, | ||
| description="Optional RNG seed for reproducible outputs. Leave unset for random results.", | ||
| examples=[42, "$inputs.seed"], | ||
| ) | ||
|
|
||
| scale_megapixels: Optional[Union[Selector(kind=[FLOAT_KIND]), float]] = Field( | ||
| default=None, | ||
| description=( | ||
| "Downscale inputs larger than this many megapixels before inference " | ||
| "(never upscales). Diffusion VRAM/latency scales with pixel count, so a " | ||
| "small cap is what keeps the model on a consumer GPU. Leave unset to " | ||
| "auto-select (~0.35 MP with the Lightning LoRA, full size otherwise)." | ||
| ), | ||
| examples=[0.35, 1.0], | ||
| ) | ||
|
|
||
| @classmethod | ||
| def get_air_gapped_availability(cls) -> AirGappedAvailability: | ||
| return AirGappedAvailability(available=True, reason=None) | ||
|
|
||
| @classmethod | ||
| def get_parameters_accepting_batches(cls) -> List[str]: | ||
| return ["images"] | ||
|
|
||
| @classmethod | ||
| def describe_outputs(cls) -> List[OutputDefinition]: | ||
| return [ | ||
| OutputDefinition( | ||
| name="image", | ||
| kind=[IMAGE_KIND], | ||
| description="The edited output image.", | ||
| ), | ||
| ] | ||
|
|
||
| @classmethod | ||
| def get_execution_engine_compatibility(cls) -> Optional[str]: | ||
| return ">=1.4.0,<2.0.0" | ||
|
|
||
| @classmethod | ||
| def get_supported_model_variants(cls) -> Optional[List[str]]: | ||
| return [DEFAULT_MODEL_ID] | ||
|
|
||
| @classmethod | ||
| def get_restrictions(cls) -> List[RuntimeRestriction]: | ||
| return [ | ||
| RuntimeRestriction( | ||
| severity=Severity.HARD, | ||
| note="Qwen-Image-Edit requires a GPU. This block cannot run on CPU-only infrastructure.", | ||
| applies_to_runtimes=[Runtime.SELF_HOSTED_CPU], | ||
| applies_to_step_execution_modes=[StepExecutionMode.LOCAL], | ||
| ), | ||
| RuntimeRestriction( | ||
| severity=Severity.HARD, | ||
| note="Qwen-Image-Edit is not available on Roboflow Hosted Inference. Run a local GPU inference server.", | ||
| applies_to_runtimes=[Runtime.HOSTED_SERVERLESS], | ||
| applies_to_step_execution_modes=[StepExecutionMode.LOCAL], | ||
| ), | ||
| ] | ||
|
|
||
|
|
||
| class QwenImageEditBlockV1(WorkflowBlock): | ||
| """Workflow block that wraps QwenImageEditHF. | ||
|
|
||
| Model instances are cached by their load configuration so weights are only | ||
| loaded once per process regardless of how many workflow steps use this | ||
| block. The cache holds at most one pipeline: the model is multi-GB even | ||
| with CPU offload, so keeping several configurations resident would exhaust | ||
| VRAM. Loading a different configuration evicts the previous one (in-flight | ||
| `edit()` calls keep their own reference, so eviction is safe). | ||
| """ | ||
|
|
||
| _model_cache: Dict[Tuple[str, bool], object] = {} | ||
| _model_load_lock = Lock() | ||
|
|
||
| def __init__(self, api_key: Optional[str]): | ||
| self._api_key = api_key | ||
|
|
||
| @classmethod | ||
| def get_init_parameters(cls) -> List[str]: | ||
| return ["api_key"] | ||
|
|
||
| @classmethod | ||
| def get_manifest(cls) -> Type[WorkflowBlockManifest]: | ||
| return BlockManifest | ||
|
|
||
| def run( | ||
| self, | ||
| images: Batch[WorkflowImageData], | ||
| prompt: str, | ||
| model_id: str, | ||
| local_weights_path: Optional[str], | ||
| use_lightning_lora: bool, | ||
| num_inference_steps: Optional[int], | ||
| guidance_scale: Optional[float], | ||
| seed: Optional[int], | ||
| scale_megapixels: Optional[float], | ||
| ) -> BlockResult: | ||
| model = self._get_model( | ||
| model_id=model_id, | ||
| local_weights_path=local_weights_path, | ||
| use_lightning_lora=use_lightning_lora, | ||
| ) | ||
| results = [] | ||
| for image in images: | ||
| edited_pil = model.edit( | ||
| image=image.numpy_image, | ||
| prompt=prompt, | ||
| num_inference_steps=num_inference_steps, | ||
| guidance_scale=guidance_scale, | ||
| seed=seed, | ||
| scale_megapixels=scale_megapixels, | ||
| ) | ||
| edited_np = np.array(edited_pil)[:, :, ::-1] # RGB → BGR | ||
| parent_metadata = ImageParentMetadata(parent_id=str(uuid.uuid4())) | ||
| results.append( | ||
| { | ||
| "image": WorkflowImageData( | ||
| parent_metadata=parent_metadata, | ||
| numpy_image=edited_np, | ||
| ) | ||
| } | ||
| ) | ||
| return results | ||
|
|
||
| def _get_model( | ||
| self, | ||
| model_id: str, | ||
| local_weights_path: Optional[str], | ||
| use_lightning_lora: bool, | ||
| ): | ||
| # Use the load path as cache key when provided so switching paths forces | ||
| # a reload, while the same path reuses the cached instance. The Lightning | ||
| # toggle is part of the key so flipping it loads a distinct instance. | ||
| base_key = local_weights_path if local_weights_path else model_id | ||
| cache_key = (base_key, bool(use_lightning_lora)) | ||
|
|
||
| model = QwenImageEditBlockV1._model_cache.get(cache_key) | ||
| if model is not None: | ||
| return model | ||
|
|
||
| # Validate the cheap precondition before taking the load lock or | ||
| # importing the (heavy) backend so a bad path fails fast with a clear | ||
| # error regardless of whether the GPU model stack is importable. | ||
| if local_weights_path and not os.path.isdir(local_weights_path): | ||
| raise ValueError( | ||
| f"local_weights_path '{local_weights_path}' does not exist or is not a directory." | ||
| ) | ||
|
|
||
| # Serialize loading: concurrent cold requests must not each load a | ||
| # multi-GB pipeline (double the peak VRAM and likely OOM). Losers of | ||
| # the race block until the winner finishes, then reuse its instance. | ||
| with QwenImageEditBlockV1._model_load_lock: | ||
| model = QwenImageEditBlockV1._model_cache.get(cache_key) | ||
| if model is not None: | ||
| return model | ||
|
|
||
| if QwenImageEditBlockV1._model_cache: | ||
| evicted = list(QwenImageEditBlockV1._model_cache.keys()) | ||
| QwenImageEditBlockV1._model_cache.clear() | ||
| logger.info( | ||
| "Evicting cached Qwen-Image-Edit pipeline(s) %s to load %s.", | ||
| evicted, | ||
| cache_key, | ||
| ) | ||
|
|
||
| from inference_models.models.qwen_image_edit.qwen_image_edit_hf import ( | ||
| MODEL_ID, | ||
| QwenImageEditHF, | ||
| ) | ||
|
|
||
| if local_weights_path: | ||
| model = QwenImageEditHF.from_pretrained( | ||
| model_name_or_path=local_weights_path, | ||
| local_files_only=True, | ||
| use_lightning_lora=use_lightning_lora, | ||
| ) | ||
| elif use_lightning_lora: | ||
| # Default path: pull the base model and LoRA straight from | ||
| # HuggingFace, bypassing the Roboflow registry. | ||
| model = QwenImageEditHF.from_pretrained( | ||
| model_name_or_path=MODEL_ID, | ||
| local_files_only=False, | ||
| use_lightning_lora=True, | ||
| ) | ||
| else: | ||
| from inference_models import AutoModel | ||
|
|
||
| model = AutoModel.from_pretrained( | ||
| model_id_or_path=model_id, | ||
| api_key=self._api_key, | ||
| use_lightning_lora=False, | ||
| ) | ||
| QwenImageEditBlockV1._model_cache[cache_key] = model | ||
| return model |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.