Add support for PRX Pixel T2I

This commit is contained in:
Jaret Burkett
2026-06-13 09:47:53 -06:00
parent a01c83073a
commit 6c3b82696e
9 changed files with 1464 additions and 0 deletions

View File

@@ -27,6 +27,7 @@ AI Toolkit is an easy to use all in one training suite for diffusion models. I t
- [baidu/ERNIE-Image](https://huggingface.co/baidu/ERNIE-Image) (ERNIE-Image)
- [NucleusAI/Nucleus-Image](https://huggingface.co/NucleusAI/Nucleus-Image) (Nucleus-Image)
- [HiDream-ai/HiDream-O1-Image](https://huggingface.co/HiDream-ai/HiDream-O1-Image) (HiDream O1)
- [Photoroom/prxpixel-t2i](https://huggingface.co/Photoroom/prxpixel-t2i) (PRXPixel)
### Instruction / Edit
- [black-forest-labs/FLUX.1-Kontext-dev](https://huggingface.co/black-forest-labs/FLUX.1-Kontext-dev) (FLUX.1-Kontext-dev)

View File

@@ -14,6 +14,7 @@ from .nucleus_image import NucleusImageModel
from .hidream.hidream_o1_model import HidreamO1Model
from .z_image.z_image_l2p_model import ZImageL2PModel
from .ideogram4 import Ideogram4Model
from .prx_pixel_t2i import PRXPixelT2IModel
AI_TOOLKIT_MODELS = [
# put a list of models here
@@ -42,4 +43,5 @@ AI_TOOLKIT_MODELS = [
HidreamO1Model,
ZImageL2PModel,
Ideogram4Model,
PRXPixelT2IModel,
]

View File

@@ -0,0 +1,53 @@
# PRXPixel (Photoroom PRX-7B, pixel-space text-to-image)
ai-toolkit integration for [`Photoroom/prxpixel-t2i`](https://huggingface.co/Photoroom/prxpixel-t2i),
a ~7B pixel-space diffusion transformer.
It is implemented **from scratch** so ai-toolkit does not depend on the (still
unmerged) diffusers PR [huggingface/diffusers#13928](https://github.com/huggingface/diffusers/pull/13928):
the transformer is vendored in [src/transformer_prx.py](src/transformer_prx.py)
and a minimal preview sampler lives in [src/pipeline.py](src/pipeline.py).
## What makes this model unusual
PRXPixel differs from a typical latent flow-matching model in three ways, each
handled in [prx_pixel_t2i.py](prx_pixel_t2i.py):
| Property | What it means | How it's handled |
|---|---|---|
| **Pixel space** | No VAE — the transformer denoises raw RGB (`in_channels=3`, `patch_size=16`) | A `FakeVAE` (identity, scaling 1) so encode/decode are no-ops; "latents" are the image in `[-1, 1]` |
| **x-prediction** | The model predicts the clean image `x0`, not the flow velocity | `get_noise_prediction` returns `x0`; `get_loss_target` is the clean latents. The `x0 → velocity` conversion only happens at sampling time |
| **noise_scale = 2.0** | Trains/samples from `randn * 2.0`, not unit noise | `get_latent_noise_from_latents` scales the training noise; the pipeline scales the starting noise |
Text is encoded by the Qwen3-VL text tower (`Qwen3VLTextModel`, hidden size
2048 → the transformer's `context_in_dim`), padded to 256 tokens.
The x-prediction objective follows *"Back to Basics: Let Denoising Generative
Models Denoise"* (https://arxiv.org/abs/2511.13720).
## Architecture (released checkpoint)
`depth=24`, `hidden_size=3584`, `num_heads=28`, `mlp_ratio=3.5`,
`in_channels=3`, `patch_size=16`, `context_in_dim=2048`, `bottleneck_size=768`,
`axes_dim=[64, 64]`, `resolution_embeds=True`, flow-matching scheduler with
`shift=3.0`.
## Train it
```yaml
model:
arch: "prx_pixel"
name_or_path: "/path/to/prxpixel-t2i" # diffusers folder: transformer/,
# text_encoder/, tokenizer/, scheduler/
quantize: true # optional: qfloat8 the transformer
quantize_te: true # optional: qfloat8 the Qwen3-VL text encoder
train:
gradient_checkpointing: true
sample:
guidance_scale: 5.0
sample_steps: 28
```
Datasets bucket to multiples of 16px (`vae_scale_factor * patch_size`).
See [../example_model/README.md](../example_model/README.md) for the generic
lifecycle, registration and LoRA conventions.

View File

@@ -0,0 +1,3 @@
from .prx_pixel_t2i import PRXPixelT2IModel
__all__ = ["PRXPixelT2IModel"]

View File

@@ -0,0 +1,345 @@
"""PRXPixelT2IModel -- the Photoroom pixel-space PRX-7B text-to-image model
(https://huggingface.co/Photoroom/prxpixel-t2i) wired into ai-toolkit.
This is implemented from scratch (the diffusers support is an unmerged PR,
https://github.com/huggingface/diffusers/pull/13928) so ai-toolkit does not
depend on it: the transformer architecture is vendored in ``src/transformer_prx.py``
and a minimal sampler lives in ``src/pipeline.py``.
What makes PRXPixel unusual (and what each override below is doing about it):
- **Pixel space, no VAE.** The transformer denoises raw RGB directly
(``in_channels=3``, ``patch_size=16``). We use a ``FakeVAE`` (identity,
scaling_factor=1) so BaseModel's encode_images/decode_latents become no-ops
and the "latents" everywhere in the toolkit are just the image in [-1, 1].
Same trick as ``../chroma/chroma_radiance_model.py`` and
``extensions/z_image_pixel``.
- **x-prediction.** The model predicts the CLEAN image x0, not the
flow-matching velocity. ai-toolkit's MSE compares ``get_noise_prediction``
against ``get_loss_target``; we set BOTH to the x0 space (prediction = the
model's x0 output, target = the clean latents), which is PRXPixel's native
training objective ("Back to Basics: Let Denoising Generative Models
Denoise", https://arxiv.org/abs/2511.13720). The x0->velocity conversion
only happens at sampling time, inside the pipeline.
- **noise_scale = 2.0.** PRXPixel trains with a non-unit initial-noise std,
so the noise mixed into the latents (training) and the starting noise
(sampling) are ``randn * noise_scale``. We override
``get_latent_noise_from_latents`` for the training side; the pipeline
handles the sampling side.
- **Qwen3-VL text tower.** Prompts are encoded by ``Qwen3VLTextModel``
(hidden size 2048 -> the transformer's ``context_in_dim``). We keep the
per-token ``last_hidden_state`` plus an attention mask.
See ../example_model/README.md for the generic lifecycle/registration guide.
"""
import os
from typing import List, Optional
import torch
import yaml
from transformers import AutoTokenizer, Qwen3VLTextModel
from optimum.quanto import freeze
from toolkit.accelerator import unwrap_model
from toolkit.basic import flush
from toolkit.config_modules import GenerateImageConfig, ModelConfig
from toolkit.models.base_model import BaseModel
from toolkit.models.FakeVAE import FakeVAE
from toolkit.prompt_utils import PromptEmbeds
from toolkit.samplers.custom_flowmatch_sampler import (
CustomFlowMatchEulerDiscreteScheduler,
)
from toolkit.train_tools import apply_noise_offset
from toolkit.util.quantize import quantize, get_qtype, quantize_model
from .src.transformer_prx import PRXTransformer2DModel
from .src.pipeline import PRXPixelPipeline
# Flow-matching scheduler config, matching the released model's
# scheduler/scheduler_config.json (shift 3.0, 1000 train timesteps).
scheduler_config = {
"num_train_timesteps": 1000,
"use_dynamic_shifting": False,
"shift": 3.0,
}
# Number of text tokens PRXPixel was trained with (the Qwen tokenizer's own
# model_max_length is far larger). Matches PRX_PIXEL_DEFAULT_MAX_TOKENS.
PROMPT_MAX_TOKENS = 256
# Initial-noise std PRXPixel trains/samples with.
NOISE_SCALE = 2.0
class PRXPixelT2IModel(BaseModel):
# ``model.arch: "prx_pixel"`` in the training config YAML selects this class.
arch = "prx_pixel"
def __init__(
self,
device,
model_config: ModelConfig,
dtype="bf16",
custom_pipeline=None,
noise_scheduler=None,
**kwargs,
):
super().__init__(
device, model_config, dtype, custom_pipeline, noise_scheduler, **kwargs
)
self.is_flow_matching = True
self.is_transformer = True
# Matched against type(module).__name__ to place LoRA layers.
self.target_lora_modules = ["PRXTransformer2DModel"]
# used by our overrides below
self.patch_size = 16
self.vae_scale_factor = 1 # pixel space: no VAE downsampling
self.max_text_length = PROMPT_MAX_TOKENS
self.noise_scale = NOISE_SCALE
@staticmethod
def get_train_scheduler():
return CustomFlowMatchEulerDiscreteScheduler(**scheduler_config)
def get_bucket_divisibility(self):
# pixels must be divisible by patch_size (no VAE downsample): 1 * 16 = 16
return self.vae_scale_factor * self.patch_size
# ------------------------------------------------------------------
# Loading
# ------------------------------------------------------------------
def load_model(self):
dtype = self.torch_dtype
self.print_and_status_update("Loading PRXPixel model")
# Expected diffusers-style layout under name_or_path:
# transformer/ (PRXTransformer2DModel) text_encoder/ (Qwen3VLTextModel)
# tokenizer/ scheduler/
model_path = self.model_config.name_or_path
# --- transformer (vendored PRXTransformer2DModel) ---
self.print_and_status_update("Loading transformer")
# from_pretrained reads config.json (bottleneck_size, resolution_embeds,
# in_channels=3, ...) and the safetensors in one shot.
transformer = PRXTransformer2DModel.from_pretrained(
model_path, subfolder="transformer", torch_dtype=dtype
)
transformer.to(dtype=dtype)
flush()
if self.model_config.quantize:
self.print_and_status_update("Quantizing transformer")
quantize_model(self, transformer)
flush()
if self.model_config.low_vram:
transformer.to("cpu")
else:
transformer.to(self.device_torch, dtype=dtype)
flush()
# --- text encoder + tokenizer (Qwen3-VL text tower) ---
self.print_and_status_update("Loading text encoder")
tokenizer = AutoTokenizer.from_pretrained(model_path, subfolder="tokenizer")
text_encoder = Qwen3VLTextModel.from_pretrained(
model_path, subfolder="text_encoder", torch_dtype=dtype
)
text_encoder.to(self.te_device_torch)
text_encoder.eval()
text_encoder.requires_grad_(False)
flush()
if self.model_config.quantize_te:
self.print_and_status_update("Quantizing text encoder")
quantize(text_encoder, weights=get_qtype(self.model_config.qtype_te))
freeze(text_encoder)
flush()
# --- "VAE": identity, since PRXPixel is pixel space ---
self.print_and_status_update("Preparing pixel-space VAE (identity)")
vae = FakeVAE(scaling_factor=1.0)
vae.to(self.vae_device_torch, dtype=self.vae_torch_dtype)
# --- scheduler + store everything ---
self.noise_scheduler = PRXPixelT2IModel.get_train_scheduler()
self.vae = vae
self.text_encoder = text_encoder
self.tokenizer = tokenizer
self.model = transformer # aliased as self.transformer / self.unet
self.pipeline = PRXPixelPipeline(self)
self.print_and_status_update("Model Loaded")
# ------------------------------------------------------------------
# Sampling (training previews)
# ------------------------------------------------------------------
def get_generation_pipeline(self):
return PRXPixelPipeline(self)
def generate_single_image(
self,
pipeline: PRXPixelPipeline,
gen_config: GenerateImageConfig,
conditional_embeds: PromptEmbeds,
unconditional_embeds: PromptEmbeds,
generator: torch.Generator,
extra: dict,
):
if self.model.device == torch.device("cpu"):
self.model.to(self.device_torch)
# snap requested size to the model's divisibility (16px)
sc = self.get_bucket_divisibility()
gen_config.width = int(gen_config.width // sc * sc)
gen_config.height = int(gen_config.height // sc * sc)
img = pipeline(
conditional_embeds=conditional_embeds,
unconditional_embeds=unconditional_embeds,
height=gen_config.height,
width=gen_config.width,
num_inference_steps=gen_config.num_inference_steps,
guidance_scale=gen_config.guidance_scale,
latents=gen_config.latents,
generator=generator,
)[0]
return img
# ------------------------------------------------------------------
# Training hooks
# ------------------------------------------------------------------
def get_noise_prediction(
self,
latent_model_input: torch.Tensor,
timestep: torch.Tensor, # 0..1000 scale, 1000 = pure noise
text_embeddings: PromptEmbeds,
**kwargs,
):
"""Forward pass. Returns the model's predicted CLEAN image x0.
PRXPixel is an x-prediction model, so the raw transformer output is the
prediction we compare against ``get_loss_target`` (the clean latents).
No velocity conversion happens here -- that is a sampling-time concern.
"""
if self.model.device == torch.device("cpu"):
self.model.to(self.device_torch)
# toolkit timestep (0..1000) -> PRX flow time in [0, 1].
t01 = timestep.to(self.device_torch, dtype=torch.float32) / 1000.0
feats = text_embeddings.text_embeds.to(self.device_torch, self.torch_dtype)
mask = getattr(text_embeddings, "attention_mask", None)
if mask is not None:
mask = mask.to(self.device_torch)
x0_pred = self.model(
hidden_states=latent_model_input.to(self.device_torch, self.torch_dtype),
timestep=t01,
encoder_hidden_states=feats,
attention_mask=mask,
return_dict=False,
)[0]
return x0_pred
def get_prompt_embeds(self, prompt) -> PromptEmbeds:
"""Encode prompt text with the Qwen3-VL text tower.
Returns a PromptEmbeds whose ``text_embeds`` is (B, L, 2048) and whose
``attention_mask`` is (B, L). Prompts are padded to a fixed length
(PROMPT_MAX_TOKENS) so cached embeds are concatenatable for CFG.
"""
if isinstance(prompt, str):
prompt = [prompt]
if self.text_encoder.device == torch.device("cpu"):
self.text_encoder.to(self.device_torch)
text_inputs = self.tokenizer(
prompt,
padding="max_length",
max_length=self.max_text_length,
truncation=True,
return_attention_mask=True,
return_tensors="pt",
)
input_ids = text_inputs.input_ids.to(self.text_encoder.device)
attention_mask = text_inputs.attention_mask.to(self.text_encoder.device)
with torch.no_grad():
output = self.text_encoder(
input_ids=input_ids,
attention_mask=attention_mask,
output_hidden_states=True,
)
embeds = output["last_hidden_state"].to(self.torch_dtype)
pe = PromptEmbeds(embeds)
# keep the mask as bool/long; it must not be dtype-cast with the embeds
pe.attention_mask = attention_mask.bool()
return pe
def get_loss_target(self, *args, **kwargs):
"""x-prediction target: the clean image (x0).
PRXPixel predicts x0 directly, so the MSE target is simply the clean
latents (the pixel image in [-1, 1]), not the flow velocity.
"""
batch = kwargs.get("batch")
return batch.latents.detach()
def get_latent_noise_from_latents(self, latents: torch.Tensor, noise_offset=0.0):
"""Noise for the forward flow, scaled by the model's noise_scale.
PRXPixel trains with a non-unit initial-noise std: the noise mixed into
the latents is ``randn * noise_scale``. The scheduler's add_noise then
forms ``x_t = (1 - t) * clean + t * noise``.
"""
noise = torch.randn_like(latents) * self.noise_scale
if noise_offset is not None and noise_offset != 0.0:
noise = apply_noise_offset(noise, noise_offset)
return noise
def condition_noisy_latents(self, latents: torch.Tensor, batch):
# plain text-to-image: nothing to inject
return latents
# ------------------------------------------------------------------
# Saving / bookkeeping
# ------------------------------------------------------------------
def get_model_has_grad(self):
return False
def get_te_has_grad(self):
return False
def save_model(self, output_path, meta, save_dtype):
"""Full fine-tune save: write the transformer back in diffusers layout."""
transformer: PRXTransformer2DModel = unwrap_model(self.model)
transformer.save_pretrained(
os.path.join(output_path, "transformer"),
safe_serialization=True,
)
with open(os.path.join(output_path, "aitk_meta.yaml"), "w") as f:
yaml.dump(meta, f)
def get_base_model_version(self):
return "prx_pixel"
def get_transformer_block_names(self) -> Optional[List[str]]:
return ["blocks"]
def convert_lora_weights_before_save(self, state_dict):
return {
k.replace("transformer.", "diffusion_model."): v
for k, v in state_dict.items()
}
def convert_lora_weights_before_load(self, state_dict):
return {
k.replace("diffusion_model.", "transformer."): v
for k, v in state_dict.items()
}

View File

@@ -0,0 +1,4 @@
# Everything diffusers does NOT ship (until PR #13928 lands) lives in src/:
# the vendored PRX transformer architecture and a minimal pixel-space sampler.
from .transformer_prx import PRXTransformer2DModel
from .pipeline import PRXPixelPipeline

View File

@@ -0,0 +1,154 @@
"""Minimal preview sampler for the pixel-space PRX (PRXPixel) model.
ai-toolkit only uses this pipeline to render preview/sample images during
training (BaseModel.generate_images -> PRXPixelT2IModel.generate_single_image).
It does not need to be a diffusers DiffusionPipeline, and ai-toolkit always
encodes the prompts itself, so the pipeline only ever receives already-encoded
``PromptEmbeds`` (text features + attention mask), never raw text.
PRXPixel specifics this sampler bakes in (see ../prx_pixel_t2i.py for the why):
- **pixel space**: there is no VAE. The model's "latents" are the RGB image
itself in [-1, 1]; the final "decode" is just a clamp + uint8 cast.
- **x-prediction**: the transformer predicts the clean image x0, not the
flow-matching velocity. Each step converts it to velocity
``v = (x_t - x0) / t`` (t clamped for stability) before the scheduler step,
exactly like the diffusers PRXPixelPipeline.
- **noise_scale**: PRXPixel trains with a non-unit initial-noise std, so the
starting noise is ``randn * noise_scale`` (2.0 for the released model).
- **CFG** is applied on the x0 prediction (before the velocity conversion).
"""
from typing import List, Optional
import torch
from PIL import Image
from diffusers.utils.torch_utils import randn_tensor
# Minimum normalized timestep used when converting an x0 prediction to a flow
# velocity ``v = (x_t - x0) / t``. Mirrors the 0.05 clamp in the diffusers
# PRXPixelPipeline; without it the division blows up as t -> 0.
X_PRED_T_MIN = 0.05
class PRXPixelPipeline:
"""Lightweight pixel-space flow-matching sampler used for training previews."""
def __init__(self, model):
# ``model`` is the PRXPixelT2IModel (a BaseModel subclass), giving us
# access to model.transformer, model.decode_latents, device/dtype, the
# scheduler factory and the noise scale.
self.model = model
@property
def device(self):
return self.model.device_torch
def to(self, *args, **kwargs):
# BaseModel.generate_images may call pipeline.to(device); we manage
# devices through the model itself, so this is a no-op.
return self
def set_progress_bar_config(self, **kwargs):
# called by the sampler harness (inside a try/except, so optional)
pass
def _embeds_and_mask(self, embeds, device, dtype):
"""Pull (features, attention_mask) out of a PromptEmbeds onto device/dtype.
``text_embeds`` is (B, L, D). The mask is (B, L) bool (1 = real token);
it is kept as long for the transformer's boolean masking and never cast
to the model dtype.
"""
feats = embeds.text_embeds.to(device, dtype=dtype)
mask = getattr(embeds, "attention_mask", None)
if mask is not None:
mask = mask.to(device)
return feats, mask
@torch.no_grad()
def __call__(
self,
conditional_embeds, # PromptEmbeds: .text_embeds (B,L,D) + .attention_mask
unconditional_embeds, # PromptEmbeds or None (negative prompt)
height: int = 1024,
width: int = 1024,
num_inference_steps: int = 28,
guidance_scale: float = 5.0,
latents: Optional[torch.Tensor] = None, # pre-made noise, usually None
generator: Optional[
torch.Generator
] = None, # seeded RNG for reproducible samples
**kwargs,
) -> List[Image.Image]:
model = self.model
device = model.device_torch
dtype = model.torch_dtype
transformer = model.transformer
# Always sample with a FRESH scheduler -- the training scheduler is
# stateful and mutating it mid-training would corrupt the train step.
scheduler = model.get_train_scheduler()
scheduler.set_timesteps(num_inference_steps, device=device)
timesteps = scheduler.timesteps # 1000 -> 0 scale
do_cfg = unconditional_embeds is not None and guidance_scale != 1.0
# 1. starting noise -- pixel space, so channels = transformer.in_channels (3),
# spatial size = the requested pixels (no VAE downsample). Scaled by the
# model's noise_scale to match the learned flow-matching trajectory.
if latents is None:
shape = (1, transformer.in_channels, height, width)
latents = randn_tensor(
shape, generator=generator, device=device, dtype=torch.float32
)
latents = latents * model.noise_scale
latents = latents.to(device, dtype=torch.float32)
# 2. text features + masks
cond_feats, cond_mask = self._embeds_and_mask(conditional_embeds, device, dtype)
if do_cfg:
uncond_feats, uncond_mask = self._embeds_and_mask(
unconditional_embeds, device, dtype
)
# 3. denoising loop
for t in timesteps:
# scheduler timesteps are 0-1000; the transformer wants [0, 1]
t01 = (t / 1000.0).to(device).float().expand(latents.shape[0])
x0_cond = transformer(
hidden_states=latents.to(dtype),
timestep=t01,
encoder_hidden_states=cond_feats,
attention_mask=cond_mask,
return_dict=False,
)[0]
if do_cfg:
x0_uncond = transformer(
hidden_states=latents.to(dtype),
timestep=t01,
encoder_hidden_states=uncond_feats,
attention_mask=uncond_mask,
return_dict=False,
)[0]
# classifier-free guidance is applied on the x0 prediction
x0 = x0_uncond + guidance_scale * (x0_cond - x0_uncond)
else:
x0 = x0_cond
# convert the x0 (clean-image) prediction to the flow velocity the
# scheduler consumes: v = (x_t - x0) / t, t clamped for stability.
t_x = torch.clamp(t01.to(torch.float32), min=X_PRED_T_MIN).view(-1, 1, 1, 1)
v = (latents - x0.to(torch.float32)) / t_x
latents = scheduler.step(v, t, latents, return_dict=False)[0]
# 4. pixel space: the denoised latents ARE the image in [-1, 1].
# decode_latents is an identity (FakeVAE) but we route through it so
# any future latent normalization stays in one place.
images = model.decode_latents(latents, device=device, dtype=torch.float32)
images = images.float().clamp(-1.0, 1.0)
images = ((images + 1.0) * 127.5).round().to(torch.uint8)
images = images.permute(0, 2, 3, 1).cpu().numpy()
return [Image.fromarray(arr) for arr in images]

View File

@@ -0,0 +1,881 @@
# PRXTransformer2DModel -- vendored, self-contained copy of the Photoroom PRX
# transformer so ai-toolkit does not depend on an unmerged diffusers PR
# (https://github.com/huggingface/diffusers/pull/13928).
#
# This is the architecture as it appears on the ``prx-pixel`` branch, with two
# small edits so it runs against a stock installed diffusers:
# - the relative ``from ...`` / ``from ..`` imports are made absolute
# (``from diffusers...``) -- every building block used here ships in stock
# diffusers already, only the assembled PRX model is missing.
# - ``maybe_adjust_dtype_for_device`` (added in a newer diffusers) is inlined
# below so we don't depend on its presence.
#
# The pixel-space PRX-7B variant (Photoroom/prxpixel-t2i) sets
# ``in_channels=3`` (raw RGB, no VAE), ``bottleneck_size`` (two-layer patch
# projection) and ``resolution_embeds=True`` (resolution-aware modulation).
#
# Copyright 2025 The Photoroom and The HuggingFace Teams. 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.
from typing import Any, Optional
import torch
from torch import nn
from diffusers.configuration_utils import ConfigMixin, register_to_config
from diffusers.utils import logging
from diffusers.models.attention import AttentionMixin, AttentionModuleMixin
from diffusers.models.attention_dispatch import dispatch_attention_fn
from diffusers.models.embeddings import get_timestep_embedding
from diffusers.models.modeling_outputs import Transformer2DModelOutput
from diffusers.models.modeling_utils import ModelMixin
from diffusers.models.normalization import RMSNorm
logger = logging.get_logger(__name__)
def maybe_adjust_dtype_for_device(
dtype: torch.dtype, device: torch.device
) -> torch.dtype:
r"""
MPS does not implement float64; fall back to float32 there. On every other device the requested dtype is returned
unchanged. Inlined from newer diffusers so this file works on older installs.
"""
if (
device is not None
and getattr(device, "type", None) == "mps"
and dtype == torch.float64
):
return torch.float32
return dtype
def get_image_ids(
batch_size: int, height: int, width: int, patch_size: int, device: torch.device
) -> torch.Tensor:
r"""
Generates 2D patch coordinate indices for a batch of images.
Args:
batch_size (`int`):
Number of images in the batch.
height (`int`):
Height of the input images (in pixels).
width (`int`):
Width of the input images (in pixels).
patch_size (`int`):
Size of the square patches that the image is divided into.
device (`torch.device`):
The device on which to create the tensor.
Returns:
`torch.Tensor`:
Tensor of shape `(batch_size, num_patches, 2)` containing the (row, col) coordinates of each patch in the
image grid.
"""
img_ids = torch.zeros(height // patch_size, width // patch_size, 2, device=device)
img_ids[..., 0] = torch.arange(height // patch_size, device=device)[:, None]
img_ids[..., 1] = torch.arange(width // patch_size, device=device)[None, :]
return (
img_ids.reshape((height // patch_size) * (width // patch_size), 2)
.unsqueeze(0)
.repeat(batch_size, 1, 1)
)
def apply_rope(xq: torch.Tensor, freqs_cis: torch.Tensor) -> torch.Tensor:
r"""
Applies rotary positional embeddings (RoPE) to a query tensor.
Args:
xq (`torch.Tensor`):
Input tensor of shape `(..., dim)` representing the queries.
freqs_cis (`torch.Tensor`):
Precomputed rotary frequency components of shape `(..., dim/2, 2)` containing cosine and sine pairs.
Returns:
`torch.Tensor`:
Tensor of the same shape as `xq` with rotary embeddings applied.
"""
xq_ = xq.float().reshape(*xq.shape[:-1], -1, 1, 2)
# Ensure freqs_cis is on the same device as queries to avoid device mismatches with offloading
freqs_cis = freqs_cis.to(device=xq.device, dtype=xq_.dtype)
xq_out = freqs_cis[..., 0] * xq_[..., 0] + freqs_cis[..., 1] * xq_[..., 1]
return xq_out.reshape(*xq.shape).type_as(xq)
class PRXAttnProcessor2_0:
r"""
Processor for implementing PRX-style attention with multi-source tokens and RoPE. Supports multiple attention
backends (Flash Attention, Sage Attention, etc.) via dispatch_attention_fn.
"""
_attention_backend = None
_parallel_config = None
def __init__(self):
if not hasattr(torch.nn.functional, "scaled_dot_product_attention"):
raise ImportError(
"PRXAttnProcessor2_0 requires PyTorch 2.0, please upgrade PyTorch to 2.0."
)
def __call__(
self,
attn: "PRXAttention",
hidden_states: torch.Tensor,
encoder_hidden_states: torch.Tensor | None = None,
attention_mask: torch.Tensor | None = None,
image_rotary_emb: torch.Tensor | None = None,
**kwargs,
) -> torch.Tensor:
"""
Apply PRX attention using PRXAttention module.
Args:
attn: PRXAttention module containing projection layers
hidden_states: Image tokens [B, L_img, D]
encoder_hidden_states: Text tokens [B, L_txt, D]
attention_mask: Boolean mask for text tokens [B, L_txt]
image_rotary_emb: Rotary positional embeddings [B, 1, L_img, head_dim//2, 2, 2]
"""
if encoder_hidden_states is None:
raise ValueError(
"PRXAttnProcessor2_0 requires 'encoder_hidden_states' containing text tokens."
)
# Project image tokens to Q, K, V
img_qkv = attn.img_qkv_proj(hidden_states)
B, L_img, _ = img_qkv.shape
img_qkv = img_qkv.reshape(B, L_img, 3, attn.heads, attn.head_dim)
img_qkv = img_qkv.permute(2, 0, 3, 1, 4) # [3, B, H, L_img, D]
img_q, img_k, img_v = img_qkv[0], img_qkv[1], img_qkv[2]
# Apply QK normalization to image tokens
img_q = attn.norm_q(img_q)
img_k = attn.norm_k(img_k)
# Project text tokens to K, V
txt_kv = attn.txt_kv_proj(encoder_hidden_states)
B, L_txt, _ = txt_kv.shape
txt_kv = txt_kv.reshape(B, L_txt, 2, attn.heads, attn.head_dim)
txt_kv = txt_kv.permute(2, 0, 3, 1, 4) # [2, B, H, L_txt, D]
txt_k, txt_v = txt_kv[0], txt_kv[1]
# Apply K normalization to text tokens
txt_k = attn.norm_added_k(txt_k)
# Apply RoPE to image queries and keys
if image_rotary_emb is not None:
img_q = apply_rope(img_q, image_rotary_emb)
img_k = apply_rope(img_k, image_rotary_emb)
# Concatenate text and image keys/values
k = torch.cat((txt_k, img_k), dim=2) # [B, H, L_txt + L_img, D]
v = torch.cat((txt_v, img_v), dim=2) # [B, H, L_txt + L_img, D]
# Build attention mask if provided
attn_mask_tensor = None
if attention_mask is not None:
bs, _, l_img, _ = img_q.shape
l_txt = txt_k.shape[2]
if attention_mask.dim() != 2:
raise ValueError(
f"Unsupported attention_mask shape: {attention_mask.shape}"
)
if attention_mask.shape[-1] != l_txt:
raise ValueError(
f"attention_mask last dim {attention_mask.shape[-1]} must equal text length {l_txt}"
)
device = img_q.device
ones_img = torch.ones((bs, l_img), dtype=torch.bool, device=device)
attention_mask = attention_mask.to(device=device, dtype=torch.bool)
joint_mask = torch.cat([attention_mask, ones_img], dim=-1)
attn_mask_tensor = joint_mask[:, None, None, :].expand(
-1, attn.heads, l_img, -1
)
# Apply attention using dispatch_attention_fn for backend support
# Reshape to match dispatch_attention_fn expectations: [B, L, H, D]
query = img_q.transpose(1, 2) # [B, L_img, H, D]
key = k.transpose(1, 2) # [B, L_txt + L_img, H, D]
value = v.transpose(1, 2) # [B, L_txt + L_img, H, D]
attn_output = dispatch_attention_fn(
query,
key,
value,
attn_mask=attn_mask_tensor,
backend=self._attention_backend,
parallel_config=self._parallel_config,
)
# Reshape from [B, L_img, H, D] to [B, L_img, H*D]
batch_size, seq_len, num_heads, head_dim = attn_output.shape
attn_output = attn_output.reshape(batch_size, seq_len, num_heads * head_dim)
# Apply output projection
attn_output = attn.to_out[0](attn_output)
if len(attn.to_out) > 1:
attn_output = attn.to_out[1](attn_output) # dropout if present
return attn_output
class PRXAttention(nn.Module, AttentionModuleMixin):
r"""
PRX-style attention module that handles multi-source tokens and RoPE. Similar to FluxAttention but adapted for
PRX's architecture.
"""
_default_processor_cls = PRXAttnProcessor2_0
_available_processors = [PRXAttnProcessor2_0]
def __init__(
self,
query_dim: int,
heads: int = 8,
dim_head: int = 64,
bias: bool = False,
out_bias: bool = False,
eps: float = 1e-6,
processor=None,
):
super().__init__()
self.heads = heads
self.head_dim = dim_head
self.inner_dim = dim_head * heads
self.query_dim = query_dim
self.img_qkv_proj = nn.Linear(query_dim, query_dim * 3, bias=bias)
self.norm_q = RMSNorm(self.head_dim, eps=eps, elementwise_affine=True)
self.norm_k = RMSNorm(self.head_dim, eps=eps, elementwise_affine=True)
self.txt_kv_proj = nn.Linear(query_dim, query_dim * 2, bias=bias)
self.norm_added_k = RMSNorm(self.head_dim, eps=eps, elementwise_affine=True)
self.to_out = nn.ModuleList([])
self.to_out.append(nn.Linear(self.inner_dim, query_dim, bias=out_bias))
self.to_out.append(nn.Dropout(0.0))
if processor is None:
processor = self._default_processor_cls()
self.set_processor(processor)
def forward(
self,
hidden_states: torch.Tensor,
encoder_hidden_states: torch.Tensor | None = None,
attention_mask: torch.Tensor | None = None,
image_rotary_emb: torch.Tensor | None = None,
**kwargs,
) -> torch.Tensor:
return self.processor(
self,
hidden_states,
encoder_hidden_states=encoder_hidden_states,
attention_mask=attention_mask,
image_rotary_emb=image_rotary_emb,
**kwargs,
)
# inspired from https://github.com/black-forest-labs/flux/blob/main/src/flux/modules/layers.py
class PRXEmbedND(nn.Module):
r"""
N-dimensional rotary positional embedding.
This module creates rotary embeddings (RoPE) across multiple axes, where each axis can have its own embedding
dimension. The embeddings are combined and returned as a single tensor
Args:
dim (int):
Base embedding dimension (must be even).
theta (int):
Scaling factor that controls the frequency spectrum of the rotary embeddings.
axes_dim (list[int]):
list of embedding dimensions for each axis (each must be even).
"""
def __init__(self, dim: int, theta: int, axes_dim: list[int]):
super().__init__()
self.dim = dim
self.theta = theta
self.axes_dim = axes_dim
def rope(self, pos: torch.Tensor, dim: int, theta: int) -> torch.Tensor:
assert dim % 2 == 0
dtype = maybe_adjust_dtype_for_device(torch.float64, pos.device)
scale = torch.arange(0, dim, 2, dtype=dtype, device=pos.device) / dim
omega = 1.0 / (theta**scale)
out = pos.unsqueeze(-1) * omega.unsqueeze(0)
out = torch.stack(
[torch.cos(out), -torch.sin(out), torch.sin(out), torch.cos(out)], dim=-1
)
# Native PyTorch equivalent of: Rearrange("b n d (i j) -> b n d i j", i=2, j=2)
# out shape: (b, n, d, 4) -> reshape to (b, n, d, 2, 2)
out = out.reshape(*out.shape[:-1], 2, 2)
return out.float()
def forward(self, ids: torch.Tensor) -> torch.Tensor:
n_axes = ids.shape[-1]
emb = torch.cat(
[
self.rope(ids[:, :, i], self.axes_dim[i], self.theta)
for i in range(n_axes)
],
dim=-3,
)
return emb.unsqueeze(1)
class MLPEmbedder(nn.Module):
r"""
A simple 2-layer MLP used for embedding inputs.
Args:
in_dim (`int`):
Dimensionality of the input features.
hidden_dim (`int`):
Dimensionality of the hidden and output embedding space.
Returns:
`torch.Tensor`:
Tensor of shape `(..., hidden_dim)` containing the embedded representations.
"""
def __init__(self, in_dim: int, hidden_dim: int):
super().__init__()
self.in_layer = nn.Linear(in_dim, hidden_dim, bias=True)
self.silu = nn.SiLU()
self.out_layer = nn.Linear(hidden_dim, hidden_dim, bias=True)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.out_layer(self.silu(self.in_layer(x)))
class PRXResolutionEmbedder(nn.Module):
r"""
Embeds the spatial resolution `(height, width)` of the latent into a vector that is added to the timestep
embedding, so the model can condition its modulation on the generation resolution.
A sinusoidal embedding of dimension 128 is built for the height and the width separately and concatenated into a
256-dim vector, which is then projected to `hidden_size` by a 2-layer MLP. This matches the `"vec"` mode of the
resolution-aware conditioning used during PRX-7B training.
Args:
hidden_size (`int`):
Dimension of the output embedding (must match the timestep embedding dimension).
max_period (`int`, *optional*, defaults to 10000):
Maximum frequency period for the sinusoidal resolution embedding.
"""
def __init__(self, hidden_size: int, max_period: int = 10000):
super().__init__()
self.max_period = max_period
self.mlp = MLPEmbedder(in_dim=256, hidden_dim=hidden_size)
def forward(
self, height: torch.Tensor, width: torch.Tensor, dtype: torch.dtype
) -> torch.Tensor:
h_emb = get_timestep_embedding(
timesteps=height,
embedding_dim=128,
max_period=self.max_period,
scale=1.0,
flip_sin_to_cos=True,
downscale_freq_shift=0.0,
)
w_emb = get_timestep_embedding(
timesteps=width,
embedding_dim=128,
max_period=self.max_period,
scale=1.0,
flip_sin_to_cos=True,
downscale_freq_shift=0.0,
)
hw_emb = torch.cat([h_emb, w_emb], dim=-1).to(dtype)
return self.mlp(hw_emb)
class Modulation(nn.Module):
r"""
Modulation network that generates scale, shift, and gating parameters.
Given an input vector, the module projects it through a linear layer to produce six chunks, which are grouped into
two tuples `(shift, scale, gate)`.
Args:
dim (`int`):
Dimensionality of the input vector. The output will have `6 * dim` features internally.
Returns:
((`torch.Tensor`, `torch.Tensor`, `torch.Tensor`), (`torch.Tensor`, `torch.Tensor`, `torch.Tensor`)):
Two tuples `(shift, scale, gate)`.
"""
def __init__(self, dim: int):
super().__init__()
self.lin = nn.Linear(dim, 6 * dim, bias=True)
nn.init.constant_(self.lin.weight, 0)
nn.init.constant_(self.lin.bias, 0)
def forward(
self, vec: torch.Tensor
) -> tuple[
tuple[torch.Tensor, torch.Tensor, torch.Tensor],
tuple[torch.Tensor, torch.Tensor, torch.Tensor],
]:
out = self.lin(nn.functional.silu(vec))[:, None, :].chunk(6, dim=-1)
return tuple(out[:3]), tuple(out[3:])
class PRXBlock(nn.Module):
r"""
Multimodal transformer block with text-image cross-attention, modulation, and MLP.
Args:
hidden_size (`int`):
Dimension of the hidden representations.
num_heads (`int`):
Number of attention heads.
mlp_ratio (`float`, *optional*, defaults to 4.0):
Expansion ratio for the hidden dimension inside the MLP.
qk_scale (`float`, *optional*):
Scale factor for queries and keys. If not provided, defaults to ``head_dim**-0.5``.
"""
def __init__(
self,
hidden_size: int,
num_heads: int,
mlp_ratio: float = 4.0,
qk_scale: float | None = None,
):
super().__init__()
self.hidden_dim = hidden_size
self.num_heads = num_heads
self.head_dim = hidden_size // num_heads
self.scale = qk_scale or self.head_dim**-0.5
self.mlp_hidden_dim = int(hidden_size * mlp_ratio)
self.hidden_size = hidden_size
# Pre-attention normalization for image tokens
self.img_pre_norm = nn.LayerNorm(
hidden_size, elementwise_affine=False, eps=1e-6
)
# PRXAttention module with built-in projections and norms
self.attention = PRXAttention(
query_dim=hidden_size,
heads=num_heads,
dim_head=self.head_dim,
bias=False,
out_bias=False,
eps=1e-6,
processor=PRXAttnProcessor2_0(),
)
# mlp
self.post_attention_layernorm = nn.LayerNorm(
hidden_size, elementwise_affine=False, eps=1e-6
)
self.gate_proj = nn.Linear(hidden_size, self.mlp_hidden_dim, bias=False)
self.up_proj = nn.Linear(hidden_size, self.mlp_hidden_dim, bias=False)
self.down_proj = nn.Linear(self.mlp_hidden_dim, hidden_size, bias=False)
self.mlp_act = nn.GELU(approximate="tanh")
self.modulation = Modulation(hidden_size)
def forward(
self,
hidden_states: torch.Tensor,
encoder_hidden_states: torch.Tensor,
temb: torch.Tensor,
image_rotary_emb: torch.Tensor,
attention_mask: torch.Tensor | None = None,
**kwargs: dict[str, Any],
) -> torch.Tensor:
r"""
Runs modulation-gated cross-attention and MLP, with residual connections.
Args:
hidden_states (`torch.Tensor`):
Image tokens of shape `(B, L_img, hidden_size)`.
encoder_hidden_states (`torch.Tensor`):
Text tokens of shape `(B, L_txt, hidden_size)`.
temb (`torch.Tensor`):
Conditioning vector used by `Modulation` to produce scale/shift/gates, shape `(B, hidden_size)` (or
broadcastable).
image_rotary_emb (`torch.Tensor`):
Rotary positional embeddings applied inside attention.
attention_mask (`torch.Tensor`, *optional*):
Boolean mask for text tokens of shape `(B, L_txt)`, where `0` marks padding.
Returns:
`torch.Tensor`:
Updated image tokens of shape `(B, L_img, hidden_size)`.
"""
mod_attn, mod_mlp = self.modulation(temb)
attn_shift, attn_scale, attn_gate = mod_attn
mlp_shift, mlp_scale, mlp_gate = mod_mlp
hidden_states_mod = (1 + attn_scale) * self.img_pre_norm(
hidden_states
) + attn_shift
attn_out = self.attention(
hidden_states=hidden_states_mod,
encoder_hidden_states=encoder_hidden_states,
attention_mask=attention_mask,
image_rotary_emb=image_rotary_emb,
)
hidden_states = hidden_states + attn_gate * attn_out
x = (1 + mlp_scale) * self.post_attention_layernorm(hidden_states) + mlp_shift
hidden_states = hidden_states + mlp_gate * (
self.down_proj(self.mlp_act(self.gate_proj(x)) * self.up_proj(x))
)
return hidden_states
class FinalLayer(nn.Module):
r"""
Final projection layer with adaptive LayerNorm modulation.
This layer applies a normalized and modulated transformation to input tokens and projects them into patch-level
outputs.
Args:
hidden_size (`int`):
Dimensionality of the input tokens.
patch_size (`int`):
Size of the square image patches.
out_channels (`int`):
Number of output channels per pixel (e.g. RGB = 3).
"""
def __init__(self, hidden_size: int, patch_size: int, out_channels: int):
super().__init__()
self.norm_final = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
self.linear = nn.Linear(
hidden_size, patch_size * patch_size * out_channels, bias=True
)
self.adaLN_modulation = nn.Sequential(
nn.SiLU(), nn.Linear(hidden_size, 2 * hidden_size, bias=True)
)
def forward(self, x: torch.Tensor, vec: torch.Tensor) -> torch.Tensor:
shift, scale = self.adaLN_modulation(vec).chunk(2, dim=1)
x = (1 + scale[:, None, :]) * self.norm_final(x) + shift[:, None, :]
x = self.linear(x)
return x
def img2seq(img: torch.Tensor, patch_size: int) -> torch.Tensor:
r"""
Flattens an image tensor into a sequence of non-overlapping patches.
Args:
img (`torch.Tensor`):
Input image tensor of shape `(B, C, H, W)`.
patch_size (`int`):
Size of each square patch. Must evenly divide both `H` and `W`.
Returns:
`torch.Tensor`:
Flattened patch sequence of shape `(B, L, C * patch_size * patch_size)`, where `L = (H // patch_size) * (W
// patch_size)` is the number of patches.
"""
b, c, h, w = img.shape
p = patch_size
# Reshape to (B, C, H//p, p, W//p, p) separating grid and patch dimensions
img = img.reshape(b, c, h // p, p, w // p, p)
# Permute to (B, H//p, W//p, C, p, p) using einsum
# n=batch, c=channels, h=grid_height, p=patch_height, w=grid_width, q=patch_width
img = torch.einsum("nchpwq->nhwcpq", img)
# Flatten to (B, L, C * p * p)
img = img.reshape(b, -1, c * p * p)
return img
def seq2img(seq: torch.Tensor, patch_size: int, shape: torch.Tensor) -> torch.Tensor:
r"""
Reconstructs an image tensor from a sequence of patches (inverse of `img2seq`).
Args:
seq (`torch.Tensor`):
Patch sequence of shape `(B, L, C * patch_size * patch_size)`, where `L = (H // patch_size) * (W //
patch_size)`.
patch_size (`int`):
Size of each square patch.
shape (`tuple` or `torch.Tensor`):
The original image spatial shape `(H, W)`. If a tensor is provided, the first two values are interpreted as
height and width.
Returns:
`torch.Tensor`:
Reconstructed image tensor of shape `(B, C, H, W)`.
"""
if isinstance(shape, tuple):
h, w = shape[-2:]
elif isinstance(shape, torch.Tensor):
h, w = (int(shape[0]), int(shape[1]))
else:
raise NotImplementedError(f"shape type {type(shape)} not supported")
b, l, d = seq.shape
p = patch_size
c = d // (p * p)
# Reshape back to grid structure: (B, H//p, W//p, C, p, p)
seq = seq.reshape(b, h // p, w // p, c, p, p)
# Permute back to image layout: (B, C, H//p, p, W//p, p)
# n=batch, h=grid_height, w=grid_width, c=channels, p=patch_height, q=patch_width
seq = torch.einsum("nhwcpq->nchpwq", seq)
# Final reshape to (B, C, H, W)
seq = seq.reshape(b, c, h, w)
return seq
class PRXTransformer2DModel(ModelMixin, ConfigMixin, AttentionMixin):
r"""
Transformer-based 2D model for text to image generation.
Args:
in_channels (`int`, *optional*, defaults to 16):
Number of input channels in the latent image. The pixel-space PRX uses `3` (raw RGB, no VAE).
patch_size (`int`, *optional*, defaults to 2):
Size of the square patches used to flatten the input image.
context_in_dim (`int`, *optional*, defaults to 2304):
Dimensionality of the text conditioning input.
hidden_size (`int`, *optional*, defaults to 1792):
Dimension of the hidden representation.
mlp_ratio (`float`, *optional*, defaults to 3.5):
Expansion ratio for the hidden dimension inside MLP blocks.
num_heads (`int`, *optional*, defaults to 28):
Number of attention heads.
depth (`int`, *optional*, defaults to 16):
Number of transformer blocks.
axes_dim (`list[int]`, *optional*):
list of dimensions for each positional embedding axis. Defaults to `[32, 32]`.
theta (`int`, *optional*, defaults to 10000):
Frequency scaling factor for rotary embeddings.
time_factor (`float`, *optional*, defaults to 1000.0):
Scaling factor applied in timestep embeddings.
time_max_period (`int`, *optional*, defaults to 10000):
Maximum frequency period for timestep embeddings.
bottleneck_size (`int`, *optional*):
If set, the image patch projection (`img_in`) uses a two-layer bottleneck (`patch_dim -> bottleneck_size ->
hidden_size`) instead of a single linear layer. Used by the pixel-space PRX-7B variant where the patch
dimension is large.
resolution_embeds (`bool`, *optional*, defaults to `False`):
Whether to condition the timestep modulation on the latent resolution `(H, W)` via a
`PRXResolutionEmbedder`. Used by the PRX-7B variant.
"""
config_name = "config.json"
_supports_gradient_checkpointing = True
@register_to_config
def __init__(
self,
in_channels: int = 16,
patch_size: int = 2,
context_in_dim: int = 2304,
hidden_size: int = 1792,
mlp_ratio: float = 3.5,
num_heads: int = 28,
depth: int = 16,
axes_dim: list = None,
theta: int = 10000,
time_factor: float = 1000.0,
time_max_period: int = 10000,
bottleneck_size: Optional[int] = None,
resolution_embeds: bool = False,
):
super().__init__()
if axes_dim is None:
axes_dim = [32, 32]
# Store parameters directly
self.in_channels = in_channels
self.patch_size = patch_size
self.out_channels = self.in_channels * self.patch_size**2
self.time_factor = time_factor
self.time_max_period = time_max_period
if hidden_size % num_heads != 0:
raise ValueError(
f"Hidden size {hidden_size} must be divisible by num_heads {num_heads}"
)
pe_dim = hidden_size // num_heads
if sum(axes_dim) != pe_dim:
raise ValueError(f"Got {axes_dim} but expected positional dim {pe_dim}")
self.hidden_size = hidden_size
self.num_heads = num_heads
self.pe_embedder = PRXEmbedND(dim=pe_dim, theta=theta, axes_dim=axes_dim)
patch_dim = self.in_channels * self.patch_size**2
if bottleneck_size is not None:
# Two-layer bottleneck projection (used by pixel-space PRX where the patch dimension is large).
self.img_in = nn.Sequential(
nn.Linear(patch_dim, bottleneck_size, bias=True),
nn.Linear(bottleneck_size, self.hidden_size, bias=True),
)
else:
self.img_in = nn.Linear(patch_dim, self.hidden_size, bias=True)
self.time_in = MLPEmbedder(in_dim=256, hidden_dim=self.hidden_size)
self.txt_in = nn.Linear(context_in_dim, self.hidden_size)
self.resolution_embedder = (
PRXResolutionEmbedder(self.hidden_size, max_period=time_max_period)
if resolution_embeds
else None
)
self.blocks = nn.ModuleList(
[
PRXBlock(
self.hidden_size,
self.num_heads,
mlp_ratio=mlp_ratio,
)
for i in range(depth)
]
)
self.final_layer = FinalLayer(self.hidden_size, 1, self.out_channels)
self.gradient_checkpointing = False
def _compute_timestep_embedding(
self, timestep: torch.Tensor, dtype: torch.dtype
) -> torch.Tensor:
return self.time_in(
get_timestep_embedding(
timesteps=timestep,
embedding_dim=256,
max_period=self.time_max_period,
scale=self.time_factor,
flip_sin_to_cos=True, # Match original cos, sin order
downscale_freq_shift=0.0,
).to(dtype)
)
def forward(
self,
hidden_states: torch.Tensor,
timestep: torch.Tensor,
encoder_hidden_states: torch.Tensor,
attention_mask: torch.Tensor | None = None,
attention_kwargs: dict[str, Any] | None = None,
return_dict: bool = True,
) -> tuple[torch.Tensor, ...] | Transformer2DModelOutput:
r"""
Forward pass of the PRXTransformer2DModel.
The latent image is split into patch tokens, combined with text conditioning, and processed through a stack of
transformer blocks modulated by the timestep. The output is reconstructed into the latent image space.
Args:
hidden_states (`torch.Tensor`):
Input latent image tensor of shape `(B, C, H, W)`.
timestep (`torch.Tensor`):
Timestep tensor of shape `(B,)` or `(1,)` on a `[0, 1]` scale, used for temporal conditioning.
encoder_hidden_states (`torch.Tensor`):
Text conditioning tensor of shape `(B, L_txt, context_in_dim)`.
attention_mask (`torch.Tensor`, *optional*):
Boolean mask of shape `(B, L_txt)`, where `0` marks padding in the text sequence.
Returns:
`Transformer2DModelOutput` if `return_dict=True`, otherwise a tuple `(sample,)` with the predicted clean
image of shape `(B, C, H, W)`.
"""
# Process text conditioning
txt = self.txt_in(encoder_hidden_states)
# Convert image to sequence and embed
img = img2seq(hidden_states, self.patch_size)
img = self.img_in(img)
# Generate positional embeddings
bs, _, h, w = hidden_states.shape
img_ids = get_image_ids(
bs, h, w, patch_size=self.patch_size, device=hidden_states.device
)
pe = self.pe_embedder(img_ids)
# Compute time embedding
vec = self._compute_timestep_embedding(timestep, dtype=img.dtype)
# Add resolution conditioning (PRX-7B "vec" mode): embed the latent (H, W) and add it to the timestep vector
# so every block's modulation is resolution-aware.
if self.resolution_embedder is not None:
height = torch.full(
(bs,), h, device=hidden_states.device, dtype=torch.float32
)
width = torch.full(
(bs,), w, device=hidden_states.device, dtype=torch.float32
)
vec = vec + self.resolution_embedder(height, width, dtype=vec.dtype)
# Apply transformer blocks
for block in self.blocks:
if torch.is_grad_enabled() and self.gradient_checkpointing:
img = self._gradient_checkpointing_func(
block.__call__,
img,
txt,
vec,
pe,
attention_mask,
)
else:
img = block(
hidden_states=img,
encoder_hidden_states=txt,
temb=vec,
image_rotary_emb=pe,
attention_mask=attention_mask,
)
# Final layer and convert back to image
img = self.final_layer(img, vec)
output = seq2img(img, self.patch_size, hidden_states.shape)
if not return_dict:
return (output,)
return Transformer2DModelOutput(sample=output)

View File

@@ -1004,6 +1004,27 @@ export const modelArchs: ModelArch[] = [
],
hasMultiLinePrompts: true,
},
{
name: 'prx_pixel',
label: 'PRXPixel (pixel space)',
group: 'image',
defaults: {
'config.process[0].model.name_or_path': ['Photoroom/prxpixel-t2i', defaultNameOrPath],
'config.process[0].model.quantize': [true, false],
'config.process[0].model.quantize_te': [true, false],
'config.process[0].train.timestep_type': ['linear', 'sigmoid'],
'config.process[0].network.conv': [undefined, 16],
'config.process[0].network.conv_alpha': [undefined, 16],
'config.process[0].model.low_vram': [true, false],
},
disableSections: [
'network.conv',
],
additionalSections: [
'model.low_vram',
'model.layer_offloading',
],
},
].sort((a, b) => {
// Sort by label, case-insensitive
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });