krea2-edit / encoders.py
linoyts's picture
linoyts HF Staff
Krea 2 edit modular blocks (remote code)
4684d79 verified
Raw
History Blame Contribute Delete
35.5 kB
# Copyright 2026 Krea AI and The HuggingFace Team. All rights reserved.
#
# 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.
"""
Text and VAE encoder blocks for Krea 2 pipelines.
"""
import math
import numpy as np
import PIL.Image
import torch
import torch.nn.functional as F
from transformers import Qwen2Tokenizer, Qwen3VLModel, Qwen3VLProcessor
from diffusers.configuration_utils import FrozenDict
from diffusers.guiders import ClassifierFreeGuidance
from diffusers.image_processor import InpaintProcessor, VaeImageProcessor
from diffusers.models import AutoencoderKLQwenImage
from diffusers.utils import logging
from diffusers.modular_pipelines.modular_pipeline import ModularPipelineBlocks, PipelineState
from diffusers.modular_pipelines.modular_pipeline_utils import ComponentSpec, InputParam, OutputParam
from .modular_pipeline import Krea2ModularPipeline, Krea2Pachifier
logger = logging.get_logger(__name__)
# Text conditioning uses the Qwen-Image chat template, tokenized as a fixed-length block: the prompt is padded to a
# fixed length first and the assistant suffix is appended after the padding (matching how the model was sampled at
# training time). The first `KREA2_PROMPT_TEMPLATE_START_IDX` (system prefix) tokens are dropped from the encoder
# outputs.
KREA2_PROMPT_TEMPLATE_PREFIX = (
"<|im_start|>system\nDescribe the image by detailing the color, shape, size, texture, quantity, text, "
"spatial relationships of the objects and background:<|im_end|>\n<|im_start|>user\n"
)
KREA2_PROMPT_TEMPLATE_SUFFIX = "<|im_end|>\n<|im_start|>assistant\n"
KREA2_PROMPT_TEMPLATE_START_IDX = 34
KREA2_PROMPT_TEMPLATE_NUM_SUFFIX_TOKENS = 5
# Indices into the text encoder's `hidden_states` tuple (0 is the embedding output) whose states are stacked per token
# and fed to the transformer's text fusion stage. These are the Krea 2 (Qwen3-VL-4B) taps; must have
# `transformer.config.num_text_layers` entries.
KREA2_TEXT_ENCODER_SELECT_LAYERS = (2, 5, 8, 11, 14, 17, 20, 23, 26, 29, 32, 35)
def get_krea2_prompt_embeds(
text_encoder,
tokenizer,
prompt: str | list[str],
text_encoder_select_layers: tuple[int, ...] = KREA2_TEXT_ENCODER_SELECT_LAYERS,
prompt_template_prefix: str = KREA2_PROMPT_TEMPLATE_PREFIX,
prompt_template_suffix: str = KREA2_PROMPT_TEMPLATE_SUFFIX,
prompt_template_start_idx: int = KREA2_PROMPT_TEMPLATE_START_IDX,
prompt_template_num_suffix_tokens: int = KREA2_PROMPT_TEMPLATE_NUM_SUFFIX_TOKENS,
max_sequence_length: int = 512,
device: torch.device | None = None,
):
"""Tokenize `prompt` into the fixed-length Krea 2 layout and tap the selected encoder hidden states.
Returns a `(prompt_embeds, prompt_embeds_mask)` tuple of shapes `(batch_size, text_seq_len, num_text_layers,
text_hidden_dim)` and `(batch_size, text_seq_len)` (bool).
"""
prompt = [prompt] if isinstance(prompt, str) else prompt
prefix_idx = prompt_template_start_idx
text = [prompt_template_prefix + e for e in prompt]
text_tokens = tokenizer(
text,
truncation=True,
padding="max_length",
max_length=max_sequence_length + prefix_idx - prompt_template_num_suffix_tokens,
return_tensors="pt",
).to(device)
suffix_tokens = tokenizer([prompt_template_suffix] * len(text), return_tensors="pt").to(device)
input_ids = torch.cat([text_tokens.input_ids, suffix_tokens.input_ids], dim=1)
attention_mask = torch.cat([text_tokens.attention_mask, suffix_tokens.attention_mask], dim=1).bool()
# Krea 2 pads in the middle of the template (`[prefix | prompt | PAD | suffix]`), so the suffix tokens sit
# downstream of the padding. The text features must use positions that count only real tokens (padding does
# not consume a position) to match how the model was trained; otherwise the suffix gets a shifted mRoPE phase.
# `Qwen3VLModel`'s default raw-index positions would place the suffix at ~max_length instead. Build the
# cumulative-valid-token positions explicitly and broadcast across the 3 mRoPE axes (T/H/W are equal for text).
position_ids = (attention_mask.long().cumsum(dim=-1) - 1).clamp(min=0)
position_ids = position_ids.unsqueeze(0).expand(3, -1, -1)
outputs = text_encoder(
input_ids=input_ids,
attention_mask=attention_mask,
position_ids=position_ids,
output_hidden_states=True,
)
hidden_states = torch.stack([outputs.hidden_states[i] for i in text_encoder_select_layers], dim=2)
prompt_embeds = hidden_states[:, prefix_idx:]
prompt_embeds_mask = attention_mask[:, prefix_idx:]
return prompt_embeds, prompt_embeds_mask
# Reference images ride in the user message ahead of the prompt through named vision placeholders; the Qwen3-VL
# processor expands each `<|image_pad|>` into the image's token grid so the text conditioning "sees" the references.
KREA2_EDIT_IMAGE_PLACEHOLDER = "Picture {}: <|vision_start|><|image_pad|><|vision_end|>"
def to_chw_tensor(image) -> torch.Tensor:
"""Convert a PIL image / numpy array / CHW tensor to a float CHW tensor in [0, 1]."""
if isinstance(image, torch.Tensor):
t = image.squeeze(0) if image.ndim == 4 else image
t = t.float()
if t.min() < 0: # assume [-1, 1]
t = (t + 1.0) / 2.0
return t.clamp(0, 1)
if isinstance(image, np.ndarray):
image = PIL.Image.fromarray(image)
image = image.convert("RGB")
arr = np.asarray(image).astype(np.float32) / 255.0
return torch.from_numpy(arr).permute(2, 0, 1)
def prep_vl_images(images: list[torch.Tensor], max_pixels: int) -> list[torch.Tensor]:
"""Resize reference images for the Qwen3-VL pass: aspect-preserving downscale (never upscaled) to fit
`max_pixels` total area. The MLLM only needs a coarse view of the references; high-res detail flows through the
VAE reference latents."""
prepped = []
for img in images:
h, w = img.shape[1], img.shape[2]
scale = min(1.0, math.sqrt(max_pixels / (h * w)))
nh, nw = max(round(h * scale), 28), max(round(w * scale), 28)
if (nh, nw) != (h, w):
img = F.interpolate(img.unsqueeze(0).float(), size=(nh, nw), mode="bicubic", antialias=True).squeeze(0)
img = img.clamp(0, 1)
prepped.append(img.float())
return prepped
def get_krea2_edit_prompt_embeds(
text_encoder,
tokenizer,
processor,
prompt: str | list[str],
images: list[torch.Tensor] | None = None,
text_encoder_select_layers: tuple[int, ...] = KREA2_TEXT_ENCODER_SELECT_LAYERS,
prompt_template_prefix: str = KREA2_PROMPT_TEMPLATE_PREFIX,
prompt_template_suffix: str = KREA2_PROMPT_TEMPLATE_SUFFIX,
prompt_template_start_idx: int = KREA2_PROMPT_TEMPLATE_START_IDX,
max_sequence_length: int = 512,
device: torch.device | None = None,
):
"""Encode prompts for the edit task, embedding reference images (a coarse VL view of each) into the text
conditioning through the Qwen3-VL vision tower.
Unlike `get_krea2_prompt_embeds`, prompts are tokenized at their natural (unpadded) length: the processor
expands each `<|image_pad|>` placeholder into a run of vision tokens that must stay intact, so no truncation or
fixed padding is applied before encoding. All prompts share the same `images`. Returns a
`(prompt_embeds, prompt_embeds_mask)` tuple of shapes `(batch_size, text_seq_len, num_text_layers,
text_hidden_dim)` and `(batch_size, text_seq_len)` (bool), right-padded across the batch.
"""
prompt = [prompt] if isinstance(prompt, str) else prompt
prefix_idx = prompt_template_start_idx
# The suffix is tokenized separately so it lands after the (image +) prompt tokens.
suffix_inputs = tokenizer([prompt_template_suffix], return_tensors="pt").to(device)
suffix_ids = suffix_inputs["input_ids"]
suffix_mask = suffix_inputs["attention_mask"].bool()
image_prompt = ""
if images:
image_prompt = "".join(KREA2_EDIT_IMAGE_PLACEHOLDER.format(i + 1) for i in range(len(images)))
features = []
for p in prompt:
text = prompt_template_prefix + image_prompt + p
extra_inputs = {}
if images:
# No truncation: the expanded image-pad runs must stay intact.
inputs = processor(text=[text], images=list(images), return_tensors="pt", do_rescale=False).to(device)
for k, v in inputs.items():
if k in ("input_ids", "attention_mask"):
continue
if isinstance(v, torch.Tensor) and v.is_floating_point():
v = v.to(text_encoder.dtype)
extra_inputs[k] = v
else:
inputs = tokenizer(
[text], truncation=True, max_length=max_sequence_length + prefix_idx, return_tensors="pt"
).to(device)
input_ids = torch.cat([inputs["input_ids"], suffix_ids], dim=1)
attention_mask = torch.cat([inputs["attention_mask"].bool(), suffix_mask], dim=1)
# mm_token_type_ids (used for M-RoPE) must cover the appended suffix tokens too; they are plain text -> type 0.
if "mm_token_type_ids" in extra_inputs:
tt = extra_inputs["mm_token_type_ids"]
extra_inputs["mm_token_type_ids"] = torch.cat([tt, torch.zeros_like(suffix_ids, dtype=tt.dtype)], dim=1)
outputs = text_encoder(
input_ids=input_ids, attention_mask=attention_mask, output_hidden_states=True, **extra_inputs
)
hidden_states = torch.stack([outputs.hidden_states[i] for i in text_encoder_select_layers], dim=2)
# Drop the system-prefix tokens; what remains is (image +) prompt + suffix.
features.append(hidden_states[0, prefix_idx:])
max_len = max(f.shape[0] for f in features)
prompt_embeds = features[0].new_zeros(len(features), max_len, *features[0].shape[1:])
prompt_embeds_mask = torch.zeros(len(features), max_len, dtype=torch.bool, device=device)
for i, f in enumerate(features):
prompt_embeds[i, : f.shape[0]] = f
prompt_embeds_mask[i, : f.shape[0]] = True
return prompt_embeds, prompt_embeds_mask
# Copied from diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion_img2img.retrieve_latents
def retrieve_latents(
encoder_output: torch.Tensor, generator: torch.Generator | None = None, sample_mode: str = "sample"
):
if hasattr(encoder_output, "latent_dist") and sample_mode == "sample":
return encoder_output.latent_dist.sample(generator)
elif hasattr(encoder_output, "latent_dist") and sample_mode == "argmax":
return encoder_output.latent_dist.mode()
elif hasattr(encoder_output, "latents"):
return encoder_output.latents
else:
raise AttributeError("Could not access latents of provided encoder_output")
# Modified from diffusers.modular_pipelines.qwenimage.encoders.encode_vae_image
def encode_vae_image(
image: torch.Tensor,
vae: AutoencoderKLQwenImage,
generator: torch.Generator,
device: torch.device,
dtype: torch.dtype,
latent_channels: int = 16,
sample_mode: str = "argmax",
):
if not isinstance(image, torch.Tensor):
raise ValueError(f"Expected image to be a tensor, got {type(image)}.")
# preprocessed image should be a 4D tensor: batch_size, num_channels, height, width
if image.dim() == 4:
image = image.unsqueeze(2)
elif image.dim() != 5:
raise ValueError(f"Expected image dims 4 or 5, got {image.dim()}.")
image = image.to(device=device, dtype=dtype)
if isinstance(generator, list):
image_latents = [
retrieve_latents(vae.encode(image[i : i + 1]), generator=generator[i], sample_mode=sample_mode)
for i in range(image.shape[0])
]
image_latents = torch.cat(image_latents, dim=0)
else:
image_latents = retrieve_latents(vae.encode(image), generator=generator, sample_mode=sample_mode)
latents_mean = (
torch.tensor(vae.config.latents_mean)
.view(1, latent_channels, 1, 1, 1)
.to(image_latents.device, image_latents.dtype)
)
latents_std = (
torch.tensor(vae.config.latents_std)
.view(1, latent_channels, 1, 1, 1)
.to(image_latents.device, image_latents.dtype)
)
image_latents = (image_latents - latents_mean) / latents_std
return image_latents
def encode_reference_latents(
images: list[torch.Tensor],
vae: AutoencoderKLQwenImage,
max_pixels: int,
generator: torch.Generator | None,
device: torch.device,
vae_scale_factor: int,
patch_size: int,
latent_channels: int = 16,
) -> list[torch.Tensor]:
"""Encode `[0, 1]` CHW reference images to normalized VAE latents, one `(C, h, w)` tensor per image. Each image
is downscaled (aspect-preserving, never upscaled) to fit within `max_pixels`, then snapped so the latent grid is
patchifiable. References keep their own aspect ratio, independent of the generated output size."""
snap = vae_scale_factor * patch_size
vae_dtype = vae.dtype
latents_mean = torch.tensor(vae.config.latents_mean).view(1, latent_channels, 1, 1, 1)
latents_std = torch.tensor(vae.config.latents_std).view(1, latent_channels, 1, 1, 1)
ref_latents = []
for img in images:
img = img.unsqueeze(0).to(device, dtype=vae_dtype)
h, w = img.shape[2], img.shape[3]
if h * w > max_pixels:
ratio = h / w
new_h, new_w = math.sqrt(max_pixels * ratio), math.sqrt(max_pixels / ratio)
else:
new_h, new_w = float(h), float(w)
new_h = max(snap, int(round(new_h / snap)) * snap)
new_w = max(snap, int(round(new_w / snap)) * snap)
if (new_h, new_w) != (h, w):
img = F.interpolate(img.float(), size=(new_h, new_w), mode="bilinear").to(vae_dtype)
img = (img * 2.0 - 1.0).unsqueeze(2) # [0, 1] -> [-1, 1], add frame dim
latent = retrieve_latents(vae.encode(img), generator=generator, sample_mode="sample")
latent = (latent - latents_mean.to(latent.device, latent.dtype)) / latents_std.to(latent.device, latent.dtype)
ref_latents.append(latent[:, :, 0][0]) # drop frame + batch dims -> (C, h, w)
return ref_latents
def pack_reference_latents(
ref_latents: list[torch.Tensor],
pachifier: Krea2Pachifier,
device: torch.device,
dtype: torch.dtype,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Patchify reference latents into `(1, ref_seq_len, C * p * p)` tokens and build their `(ref_seq_len, 3)` rotary
coordinates. The i-th reference sits on frame axis `i + 1` with its own y/x grid starting at 0 (the Kontext-style
"index" placement that marks each reference as a distinct image rather than more of the canvas)."""
p = pachifier.config.patch_size
tokens, position_ids = [], []
for i, ref in enumerate(ref_latents):
ref = ref.unsqueeze(0).to(device, dtype)
tokens.append(pachifier.pack_latents(ref))
_, _, h, w = ref.shape
ids = torch.zeros(h // p, w // p, 3, device=device)
ids[..., 0] = i + 1
ids[..., 1] = torch.arange(h // p, device=device)[:, None]
ids[..., 2] = torch.arange(w // p, device=device)[None, :]
position_ids.append(ids.reshape(-1, 3))
return torch.cat(tokens, dim=1), torch.cat(position_ids, dim=0)
# ====================
# 1. TEXT ENCODER
# ====================
class Krea2TextEncoderStep(ModularPipelineBlocks):
model_name = "krea2"
def __init__(self, text_encoder_select_layers: tuple[int, ...] | None = None):
"""Text encoder step for Krea 2.
Args:
text_encoder_select_layers (`tuple[int, ...]`, *optional*):
Indices into the text encoder's `hidden_states` tuple (0 is the embedding output) whose states are
stacked per token as the transformer's text conditioning. Must have
`transformer.config.num_text_layers` entries. Defaults to the Krea 2 (Qwen3-VL-4B) taps.
"""
if text_encoder_select_layers is None:
text_encoder_select_layers = KREA2_TEXT_ENCODER_SELECT_LAYERS
self.text_encoder_select_layers = tuple(text_encoder_select_layers)
super().__init__()
@property
def description(self) -> str:
return "Text Encoder step that generates text embeddings to guide the image generation."
@property
def expected_components(self) -> list[ComponentSpec]:
return [
ComponentSpec("text_encoder", Qwen3VLModel, description="The text encoder to use"),
ComponentSpec("tokenizer", Qwen2Tokenizer, description="The tokenizer to use"),
ComponentSpec(
"guider",
ClassifierFreeGuidance,
config=FrozenDict({"guidance_scale": 4.5, "use_original_formulation": True}),
default_creation_method="from_config",
),
]
@property
def inputs(self) -> list[InputParam]:
return [
InputParam.template("prompt"),
InputParam.template("negative_prompt"),
InputParam.template("max_sequence_length"),
]
@property
def intermediate_outputs(self) -> list[OutputParam]:
return [
OutputParam.template("prompt_embeds"),
OutputParam.template("prompt_embeds_mask"),
OutputParam.template("negative_prompt_embeds"),
OutputParam.template("negative_prompt_embeds_mask"),
]
@staticmethod
def check_inputs(prompt, negative_prompt, max_sequence_length):
if not isinstance(prompt, str) and not isinstance(prompt, list):
raise ValueError(f"`prompt` has to be of type `str` or `list` but is {type(prompt)}")
if (
negative_prompt is not None
and not isinstance(negative_prompt, str)
and not isinstance(negative_prompt, list)
):
raise ValueError(f"`negative_prompt` has to be of type `str` or `list` but is {type(negative_prompt)}")
if max_sequence_length is not None and max_sequence_length <= 0:
raise ValueError(f"`max_sequence_length` must be a positive integer but is {max_sequence_length}")
@torch.no_grad()
def __call__(self, components: Krea2ModularPipeline, state: PipelineState):
block_state = self.get_block_state(state)
device = components._execution_device
self.check_inputs(block_state.prompt, block_state.negative_prompt, block_state.max_sequence_length)
block_state.prompt_embeds, block_state.prompt_embeds_mask = get_krea2_prompt_embeds(
components.text_encoder,
components.tokenizer,
prompt=block_state.prompt,
text_encoder_select_layers=self.text_encoder_select_layers,
max_sequence_length=block_state.max_sequence_length,
device=device,
)
block_state.negative_prompt_embeds = None
block_state.negative_prompt_embeds_mask = None
if components.requires_unconditional_embeds:
negative_prompt = block_state.negative_prompt or ""
block_state.negative_prompt_embeds, block_state.negative_prompt_embeds_mask = get_krea2_prompt_embeds(
components.text_encoder,
components.tokenizer,
prompt=negative_prompt,
text_encoder_select_layers=self.text_encoder_select_layers,
max_sequence_length=block_state.max_sequence_length,
device=device,
)
self.set_block_state(state, block_state)
return components, state
class Krea2EditTextEncoderStep(ModularPipelineBlocks):
model_name = "krea2"
def __init__(self, text_encoder_select_layers: tuple[int, ...] | None = None):
"""Text encoder step for the Krea 2 edit task: encodes the prompt while embedding a coarse view of the
reference image(s) into the conditioning through the Qwen3-VL vision tower.
Args:
text_encoder_select_layers (`tuple[int, ...]`, *optional*):
Indices into the text encoder's `hidden_states` tuple whose states are stacked per token as the
transformer's text conditioning. Defaults to the Krea 2 (Qwen3-VL-4B) taps.
"""
if text_encoder_select_layers is None:
text_encoder_select_layers = KREA2_TEXT_ENCODER_SELECT_LAYERS
self.text_encoder_select_layers = tuple(text_encoder_select_layers)
super().__init__()
@property
def description(self) -> str:
return (
"Text encoder step for the edit task. Embeds reference image(s) into the text conditioning via the "
"Qwen3-VL vision tower, matching how the Ostris AI-Toolkit edit LoRAs are trained."
)
@property
def expected_components(self) -> list[ComponentSpec]:
return [
ComponentSpec("text_encoder", Qwen3VLModel, description="The text encoder to use"),
ComponentSpec("tokenizer", Qwen2Tokenizer, description="The tokenizer to use"),
ComponentSpec("processor", Qwen3VLProcessor, description="The Qwen3-VL processor for reference images"),
ComponentSpec(
"guider",
ClassifierFreeGuidance,
config=FrozenDict({"guidance_scale": 4.5, "use_original_formulation": True}),
default_creation_method="from_config",
),
]
@property
def inputs(self) -> list[InputParam]:
return [
InputParam.template("prompt"),
InputParam.template("negative_prompt"),
InputParam.template("image", required=True, note="The reference image(s) for the edit."),
InputParam.template("max_sequence_length"),
InputParam(
"vl_image_max_pixels",
type_hint=int,
default=384 * 384,
description="Pixel budget for the coarse Qwen3-VL view of each reference image.",
),
]
@property
def intermediate_outputs(self) -> list[OutputParam]:
return [
OutputParam.template("prompt_embeds"),
OutputParam.template("prompt_embeds_mask"),
OutputParam.template("negative_prompt_embeds"),
OutputParam.template("negative_prompt_embeds_mask"),
]
@staticmethod
def check_inputs(prompt, negative_prompt, max_sequence_length):
if not isinstance(prompt, str) and not isinstance(prompt, list):
raise ValueError(f"`prompt` has to be of type `str` or `list` but is {type(prompt)}")
if (
negative_prompt is not None
and not isinstance(negative_prompt, str)
and not isinstance(negative_prompt, list)
):
raise ValueError(f"`negative_prompt` has to be of type `str` or `list` but is {type(negative_prompt)}")
if max_sequence_length is not None and max_sequence_length <= 0:
raise ValueError(f"`max_sequence_length` must be a positive integer but is {max_sequence_length}")
@torch.no_grad()
def __call__(self, components: Krea2ModularPipeline, state: PipelineState):
block_state = self.get_block_state(state)
device = components._execution_device
self.check_inputs(block_state.prompt, block_state.negative_prompt, block_state.max_sequence_length)
image_list = block_state.image if isinstance(block_state.image, (list, tuple)) else [block_state.image]
ref_images = [to_chw_tensor(img).to(device) for img in image_list]
vl_images = prep_vl_images(ref_images, block_state.vl_image_max_pixels)
block_state.prompt_embeds, block_state.prompt_embeds_mask = get_krea2_edit_prompt_embeds(
components.text_encoder,
components.tokenizer,
components.processor,
prompt=block_state.prompt,
images=vl_images,
text_encoder_select_layers=self.text_encoder_select_layers,
max_sequence_length=block_state.max_sequence_length,
device=device,
)
block_state.negative_prompt_embeds = None
block_state.negative_prompt_embeds_mask = None
if components.requires_unconditional_embeds:
negative_prompt = block_state.negative_prompt or ""
block_state.negative_prompt_embeds, block_state.negative_prompt_embeds_mask = get_krea2_edit_prompt_embeds(
components.text_encoder,
components.tokenizer,
components.processor,
prompt=negative_prompt,
images=vl_images,
text_encoder_select_layers=self.text_encoder_select_layers,
max_sequence_length=block_state.max_sequence_length,
device=device,
)
self.set_block_state(state, block_state)
return components, state
# ====================
# 2. IMAGE PREPROCESS
# ====================
class Krea2InpaintProcessImagesInputStep(ModularPipelineBlocks):
model_name = "krea2"
@property
def description(self) -> str:
return "Image Preprocess step for inpainting task. This processes the image and mask inputs together. Images will be resized to the given height and width."
@property
def expected_components(self) -> list[ComponentSpec]:
return [
ComponentSpec(
"image_mask_processor",
InpaintProcessor,
config=FrozenDict({"vae_scale_factor": 16}),
default_creation_method="from_config",
),
]
@property
def inputs(self) -> list[InputParam]:
return [
InputParam.template("mask_image"),
InputParam.template("image"),
InputParam.template("height"),
InputParam.template("width"),
InputParam.template("padding_mask_crop"),
]
@property
def intermediate_outputs(self) -> list[OutputParam]:
return [
OutputParam(
name="processed_image",
type_hint=torch.Tensor,
description="The processed image",
),
OutputParam(
name="processed_mask_image",
type_hint=torch.Tensor,
description="The processed mask image",
),
OutputParam(
name="mask_overlay_kwargs",
type_hint=dict,
description="The kwargs for the postprocess step to apply the mask overlay",
),
]
@staticmethod
def check_inputs(height, width, vae_scale_factor):
if height is not None and height % (vae_scale_factor * 2) != 0:
raise ValueError(f"Height must be divisible by {vae_scale_factor * 2} but is {height}")
if width is not None and width % (vae_scale_factor * 2) != 0:
raise ValueError(f"Width must be divisible by {vae_scale_factor * 2} but is {width}")
@torch.no_grad()
def __call__(self, components: Krea2ModularPipeline, state: PipelineState):
block_state = self.get_block_state(state)
self.check_inputs(
height=block_state.height, width=block_state.width, vae_scale_factor=components.vae_scale_factor
)
height = block_state.height or components.default_height
width = block_state.width or components.default_width
block_state.processed_image, block_state.processed_mask_image, block_state.mask_overlay_kwargs = (
components.image_mask_processor.preprocess(
image=block_state.image,
mask=block_state.mask_image,
height=height,
width=width,
padding_mask_crop=block_state.padding_mask_crop,
)
)
self.set_block_state(state, block_state)
return components, state
class Krea2ProcessImagesInputStep(ModularPipelineBlocks):
model_name = "krea2"
@property
def description(self) -> str:
return "Image Preprocess step. will resize the image to the given height and width."
@property
def expected_components(self) -> list[ComponentSpec]:
return [
ComponentSpec(
"image_processor",
VaeImageProcessor,
config=FrozenDict({"vae_scale_factor": 16}),
default_creation_method="from_config",
),
]
@property
def inputs(self) -> list[InputParam]:
return [
InputParam.template("image"),
InputParam.template("height"),
InputParam.template("width"),
]
@property
def intermediate_outputs(self) -> list[OutputParam]:
return [
OutputParam(
name="processed_image",
type_hint=torch.Tensor,
description="The processed image",
)
]
@staticmethod
def check_inputs(height, width, vae_scale_factor):
if height is not None and height % (vae_scale_factor * 2) != 0:
raise ValueError(f"Height must be divisible by {vae_scale_factor * 2} but is {height}")
if width is not None and width % (vae_scale_factor * 2) != 0:
raise ValueError(f"Width must be divisible by {vae_scale_factor * 2} but is {width}")
@torch.no_grad()
def __call__(self, components: Krea2ModularPipeline, state: PipelineState):
block_state = self.get_block_state(state)
self.check_inputs(
height=block_state.height, width=block_state.width, vae_scale_factor=components.vae_scale_factor
)
height = block_state.height or components.default_height
width = block_state.width or components.default_width
block_state.processed_image = components.image_processor.preprocess(
image=block_state.image,
height=height,
width=width,
)
self.set_block_state(state, block_state)
return components, state
# ====================
# 3. VAE ENCODER
# ====================
class Krea2VaeEncoderStep(ModularPipelineBlocks):
model_name = "krea2"
@property
def description(self) -> str:
return "VAE Encoder step that converts processed_image into latent representations image_latents."
@property
def expected_components(self) -> list[ComponentSpec]:
return [ComponentSpec("vae", AutoencoderKLQwenImage)]
@property
def inputs(self) -> list[InputParam]:
return [
InputParam(
name="processed_image", required=True, type_hint=torch.Tensor, description="The image tensor to encode"
),
InputParam.template("generator"),
]
@property
def intermediate_outputs(self) -> list[OutputParam]:
return [OutputParam.template("image_latents")]
@torch.no_grad()
def __call__(self, components: Krea2ModularPipeline, state: PipelineState) -> PipelineState:
block_state = self.get_block_state(state)
device = components._execution_device
dtype = components.vae.dtype
block_state.image_latents = encode_vae_image(
image=block_state.processed_image,
vae=components.vae,
generator=block_state.generator,
device=device,
dtype=dtype,
latent_channels=components.num_channels_latents,
)
self.set_block_state(state, block_state)
return components, state
class Krea2EditReferenceLatentsStep(ModularPipelineBlocks):
model_name = "krea2"
@property
def description(self) -> str:
return (
"Reference (edit) VAE encoder step. Encodes reference image(s) to clean, normalized VAE latents and packs "
"them into transformer tokens with their frame-axis rotary coordinates. These tokens are appended to the "
"sequence at flow time t=0 to condition the generation on the references."
)
@property
def expected_components(self) -> list[ComponentSpec]:
return [
ComponentSpec("vae", AutoencoderKLQwenImage),
ComponentSpec("pachifier", Krea2Pachifier, default_creation_method="from_config"),
]
@property
def inputs(self) -> list[InputParam]:
return [
InputParam.template("image", required=True, note="The reference image(s) for the edit."),
InputParam.template("generator"),
InputParam(
"reference_max_pixels",
type_hint=int,
default=1024 * 1024,
description="Pixel budget each reference image is downscaled to fit before VAE encoding.",
),
]
@property
def intermediate_outputs(self) -> list[OutputParam]:
return [
OutputParam(
name="reference_latents",
type_hint=torch.Tensor,
description="Packed clean reference tokens of shape (1, ref_seq_len, C * p * p), appended to the "
"denoiser sequence at t=0.",
),
OutputParam(
name="reference_position_ids",
type_hint=torch.Tensor,
description="Rotary coordinates (ref_seq_len, 3) for the reference tokens; the i-th reference sits on "
"frame axis i + 1.",
),
OutputParam(
name="ref_seq_len",
kwargs_type="denoiser_input_fields",
type_hint=int,
description="Number of reference tokens appended to the denoiser sequence.",
),
]
@torch.no_grad()
def __call__(self, components: Krea2ModularPipeline, state: PipelineState) -> PipelineState:
block_state = self.get_block_state(state)
device = components._execution_device
patch_size = components.pachifier.config.patch_size
image_list = block_state.image if isinstance(block_state.image, (list, tuple)) else [block_state.image]
ref_images = [to_chw_tensor(img) for img in image_list]
ref_latents = encode_reference_latents(
images=ref_images,
vae=components.vae,
max_pixels=block_state.reference_max_pixels,
generator=block_state.generator,
device=device,
vae_scale_factor=components.vae_scale_factor,
patch_size=patch_size,
latent_channels=components.num_channels_latents,
)
block_state.reference_latents, block_state.reference_position_ids = pack_reference_latents(
ref_latents, components.pachifier, device, components.vae.dtype
)
block_state.ref_seq_len = block_state.reference_latents.shape[1]
self.set_block_state(state, block_state)
return components, state