HTBris's picture
Deploy Ostris AI-Toolkit ZeroGPU Trainer (Illustrious XL / SDXL / FLUX.1)
855e1f5
Raw History Blame Contribute Delete
8.26 kB
"""Fibo 2.0 text conditioning: Qwen3-VL-4B hidden states in six bundles.
The prompt goes in Qwen3-VL's user turn; 15 hidden layers are each scaled to
unit RMS in fp32, and every bundle concatenates three of them: the
Perceiver's, then one per injection block. Stored per prompt as
(L, 6 * 7680), the ComfyUI conditioning layout.
- Generation: a structured JSON caption, cleaned and compacted the way Fibo
2.0 was trained, or plain text as it is; an empty prompt is the null
caption (CFG negative, caption dropout).
- Edit: one JSON object, a vision marker per reference (``image`` or
``image_1``..), the result's caption when the prompt has one, then the edit
instruction; Qwen3-VL sees each reference in place of its marker.
Not done: the images of a caption's text_render entries (diffusers' LoRA
trainers encode without them).
"""
import json
import math
from typing import List, Optional, Sequence
import torch
from PIL import Image
from toolkit.models.v2.text_encoders.qwen3_vl import Qwen3VLModelEncoder
from .checkpoints import QWEN3_VL_4B_REPO
PROMPT_TEMPLATE = "<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n"
IMAGE_PAD = "<|image_pad|>"
VISION_MARKER = f"<|vision_start|>{IMAGE_PAD}<|vision_end|>"
# the null caption Fibo 2.0 was trained with for classifier-free guidance
NULL_CAPTION = (
'{"short_description":"","objects":[],"background_setting":"","lighting":{},'
'"aesthetics":{},"photographic_characteristics":{},"style_medium":"",'
'"context":"","artistic_style":""}'
)
# Qwen3-VL hidden_states indices (0 = embeddings) behind the six bundles
BUNDLE_LAYERS = (
(9, 20, 31),
(5, 16, 27),
(7, 18, 29),
(9, 20, 31),
(11, 22, 33),
(13, 24, 35),
)
MAX_TEXT_TOKENS = 4096
SCORES = ("aesthetic_score", "preference_score")
def _dumps(value) -> str:
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
def _empty(value) -> bool:
if isinstance(value, float):
return math.isnan(value)
return value is None or (isinstance(value, (str, dict, list)) and not value)
def _clean(value):
if isinstance(value, dict):
value = {k: _clean(v) for k, v in value.items()}
return {k: v for k, v in value.items() if not _empty(v)}
if isinstance(value, list):
return [v for v in map(_clean, value) if not _empty(v)]
return value
def parse_prompt(prompt: str):
"""(caption, edit instruction) as diffusers' BriaFibo2Pipeline parses
them; a prompt that isn't a JSON object comes back as it is."""
try:
caption = json.loads(prompt)
except json.JSONDecodeError:
return prompt, None
if not isinstance(caption, dict):
return prompt, None
instruction = caption.pop("edit_instruction", None)
if "structured_caption" in caption:
caption = caption.pop("structured_caption")
if not isinstance(caption, dict):
return caption or None, instruction
keys = set(caption)
caption = {
key: {k: v for k, v in value.items() if k not in SCORES}
if isinstance(value, dict)
else value
for key, value in _clean(caption).items()
if key not in SCORES
}
caption = {key: value for key, value in caption.items() if value != {}}
# exactly a short_description and a text_render list keeps both, the list
# even when empty: the image has no text
if keys == {"short_description", "text_render"}:
caption = {
"short_description": caption.get("short_description") or "",
"text_render": caption.get("text_render") or [],
}
return caption or None, instruction
def generation_text(prompt: str) -> str:
if prompt.strip() == "":
return NULL_CAPTION
caption, _ = parse_prompt(prompt)
return prompt if isinstance(caption, str) else _dumps(caption or {})
def edit_text(prompt: str, num_images: int) -> str:
caption, instruction = parse_prompt(prompt)
if instruction is None:
caption, instruction = None, prompt
if num_images == 1:
edit = {"image": VISION_MARKER}
else:
edit = {f"image_{k}": VISION_MARKER for k in range(1, num_images + 1)}
if caption:
edit["structured_caption"] = caption
edit["edit_instruction"] = instruction
return _dumps(edit)
class Fibo2TextEncoder(Qwen3VLModelEncoder):
"""Qwen3-VL-4B (language + vision tower) from the Comfy single file, int8
convrot or bf16; the tied lm_head is dropped on load."""
aitk_config_repo = QWEN3_VL_4B_REPO
aitk_tokenizer_repo = QWEN3_VL_4B_REPO
aitk_tokenizer_subfolder = ""
@classmethod
def convert_state_dict_on_load(cls, state_dict):
return {
key[len("model.") :] if key.startswith("model.") else key: value
for key, value in state_dict.items()
if not key.startswith("lm_head.")
}
@classmethod
def convert_state_dict_on_save(cls, state_dict):
return {f"model.{k}": v for k, v in state_dict.items()}
def _bundles(hidden_states, dtype) -> torch.Tensor:
normalized = {}
for layer in sorted({layer for bundle in BUNDLE_LAYERS for layer in bundle}):
states = hidden_states[layer].float()
normalized[layer] = (
states * torch.rsqrt(states.pow(2).mean(-1, keepdim=True) + 1e-6)
).to(dtype)
return torch.cat(
[normalized[layer] for bundle in BUNDLE_LAYERS for layer in bundle], -1
)
@torch.no_grad()
def encode_prompts(
text_encoder,
tokenizer,
image_processor,
prompts: List[str],
images: Sequence[Optional[List[Image.Image]]],
device: torch.device,
dtype: torch.dtype,
max_length: int = MAX_TEXT_TOKENS,
) -> List[torch.Tensor]:
"""Prompts (+ per-prompt Qwen3-VL reference images) -> per-prompt
(L_i, 46080) bundles. Text-only prompts share one right-padded forward
without a mask: causal attention keeps the pads from reaching any real
token. Prompts with references run one forward each."""
out: List[Optional[torch.Tensor]] = [None] * len(prompts)
text_only = [
i for i, imgs in enumerate(images) if not imgs or prompts[i].strip() == ""
]
if text_only:
enc = tokenizer(
[PROMPT_TEMPLATE.format(generation_text(prompts[i])) for i in text_only],
return_tensors="pt",
padding=True,
padding_side="right",
truncation=True,
max_length=max_length,
)
lengths = enc["attention_mask"].sum(1).tolist()
hidden_states = text_encoder(
input_ids=enc["input_ids"].to(device),
output_hidden_states=True,
use_cache=False,
).hidden_states
context = _bundles(hidden_states, dtype)
for row, (i, n) in enumerate(zip(text_only, lengths)):
out[i] = context[row, :n]
image_token_id = text_encoder.config.image_token_id
merge = image_processor.merge_size**2
for i, imgs in enumerate(images):
if out[i] is not None:
continue
pixels = image_processor(images=imgs, do_resize=False, return_tensors="pt")
grid = pixels["image_grid_thw"]
text = PROMPT_TEMPLATE.format(edit_text(prompts[i], len(imgs)))
# each marker's single pad expands to its image's token count; no
# truncation, the pad runs must stay whole
parts = text.split(IMAGE_PAD)
counts = (grid.prod(-1) // merge).tolist()
text = parts[0] + "".join(
IMAGE_PAD * n + part for n, part in zip(counts, parts[1:])
)
input_ids = tokenizer(text, return_tensors="pt")["input_ids"].to(device)
mm_token_type_ids = (input_ids == image_token_id).int()
if int(mm_token_type_ids.sum()) != sum(counts):
raise ValueError("image placeholder count does not match the image grids")
hidden_states = text_encoder(
input_ids=input_ids,
pixel_values=pixels["pixel_values"].to(device, dtype),
image_grid_thw=grid.to(device),
mm_token_type_ids=mm_token_type_ids,
output_hidden_states=True,
use_cache=False,
).hidden_states
out[i] = _bundles(hidden_states, dtype)[0]
return out