From 6b553318c89a91d9048f4ff1b65357a87ac5545f Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Mon, 26 Oct 2026 14:47:20 -0500 Subject: [PATCH 1/2] Add img2img_flux2: image + prompt editing with FLUX.2 klein Port the AutoencoderKLFlux2 encoder and the klein pipeline's reference-image path from diffusers: the reference is encoded once, its tokens (position ids T = 10) ride after the noisy latents in every forward, and only the latents' predictions drive the scheduler. flux2_load_pipeline(encoder = TRUE) loads the encoder; txt2img_flux2() shares the generation body unchanged. Matches diffusers on the real klein-4B weights at fp32 on the CPU (256x256 edit: PSNR 123 dB; encoder relative error 2e-6). --- CLAUDE.md | 15 ++ NAMESPACE | 6 + R/img2img_flux2.R | 161 +++++++++++++ R/rope_flux2.R | 24 ++ R/txt2img_flux2.R | 84 ++++++- R/vae_flux2_encoder.R | 217 ++++++++++++++++++ inst/REFERENCES.md | 4 + .../fixtures/vae_flux2_enc_io.safetensors | Bin 0 -> 25024 bytes inst/tinytest/test_img2img_flux2.R | 130 +++++++++++ man/VAEDownBlock.Rd | 30 +++ man/flux2_encode_image.Rd | 23 ++ man/flux2_load_pipeline.Rd | 7 +- man/flux2_prepare_image_ids.Rd | 22 ++ man/flux2_preprocess_image.Rd | 21 ++ man/flux2_vae_encoder.Rd | 35 +++ man/img2img_flux2.Rd | 67 ++++++ man/load_flux2_vae_encoder.Rd | 29 +++ tools/gen_fixtures_flux2_vae_encoder.py | 52 +++++ 18 files changed, 918 insertions(+), 9 deletions(-) create mode 100644 R/img2img_flux2.R create mode 100644 R/vae_flux2_encoder.R create mode 100644 inst/tinytest/fixtures/vae_flux2_enc_io.safetensors create mode 100644 inst/tinytest/test_img2img_flux2.R create mode 100644 man/VAEDownBlock.Rd create mode 100644 man/flux2_encode_image.Rd create mode 100644 man/flux2_prepare_image_ids.Rd create mode 100644 man/flux2_preprocess_image.Rd create mode 100644 man/flux2_vae_encoder.Rd create mode 100644 man/img2img_flux2.Rd create mode 100644 man/load_flux2_vae_encoder.Rd create mode 100644 tools/gen_fixtures_flux2_vae_encoder.py diff --git a/CLAUDE.md b/CLAUDE.md index 50d36d3..00babf5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -371,6 +371,21 @@ phase-onloaded): 1024x1024 in ~13 s (peak 12.5 GB alloc) after the allocator gc-gate fix (was ~48 s); pipeline load 31 s. Cast census is exactly 104 weights. +Image + prompt (same weights, one reference image): + +```r +pipe <- flux2_load_pipeline(encoder = TRUE) # adds the VAE encoder +img2img_flux2("Turn the scene into a snowy winter night", + image = "photo.png", pipeline = pipe, seed = 7) +``` + +The reference is VAE-encoded once and its tokens ride after the noisy +latents in every forward (position ids T = 10), so attention cost grows +with the reference size; the output defaults to the reference's size. +Verified against diffusers on the real weights (fp32 CPU, 256x256: PSNR +123 dB; encoder rel. error 2e-6). GPU memory at 1024x1024 not yet +measured. + Perf lesson that cost an afternoon: generation was 93.8% R garbage collection until the tokenizer stopped holding 151k-binding environments — R gc cost scales with live object count (12 vs 203 ms), diff --git a/NAMESPACE b/NAMESPACE index 439c73e..2736e3c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -53,6 +53,7 @@ export(flux_unpack_latents) export(flux2_bn_normalize) export(flux2_double_block) export(flux2_empirical_mu) +export(flux2_encode_image) export(flux2_feed_forward) export(flux2_is_quant_key) export(flux2_load_pipeline) @@ -60,13 +61,16 @@ export(flux2_modulation) export(flux2_pack_latents) export(flux2_parallel_self_attention) export(flux2_patchify_latents) +export(flux2_prepare_image_ids) export(flux2_prepare_latent_ids) export(flux2_prepare_text_ids) +export(flux2_preprocess_image) export(flux2_single_block) export(flux2_transformer) export(flux2_unpack_latents_with_ids) export(flux2_unpatchify_latents) export(flux2_vae_decoder) +export(flux2_vae_encoder) export(gemma3_config_ltx2) export(gemma3_encode_batch) export(gemma3_quantize_nf4) @@ -76,11 +80,13 @@ export(gemma4_quantize_nf4) export(gemma4_text_model) export(gemma4_tokenizer) export(img2img) +export(img2img_flux2) export(is_blackwell_gpu) export(latents_to_video) export(load_decoder_safetensors) export(load_decoder_weights) export(load_flux2_vae_decoder) +export(load_flux2_vae_encoder) export(load_gemma3_nf4) export(load_gemma3_text_encoder) export(load_gemma4_nf4) diff --git a/R/img2img_flux2.R b/R/img2img_flux2.R new file mode 100644 index 0000000..e57d798 --- /dev/null +++ b/R/img2img_flux2.R @@ -0,0 +1,161 @@ +#' Edit an image with FLUX.2 klein +#' +#' Image + prompt to image with the same klein-4B weights as +#' \code{\link{txt2img_flux2}}: the reference image is VAE-encoded once, +#' its tokens ride after the noisy latents in every transformer forward +#' (position ids at T = 10, see \code{\link{flux2_prepare_image_ids}}), +#' and only the latents' predictions drive the scheduler. Reference: +#' diffusers Flux2KleinPipeline with \code{image =} (Apache-2.0). +#' +#' The image is checked (both sides at least 64 px, aspect ratio at most +#' 8), resized with Lanczos to 1024 x 1024 in area if it is larger, and +#' center-cropped to multiples of 16. One reference image is supported. +#' +#' @param prompt Character. The edit instruction or prompt. +#' @param image Path to a PNG/JPEG, or an [H, W, 3] array in [0, 1]. +#' @param pipeline A \code{flux2_pipeline} loaded with +#' \code{encoder = TRUE}; NULL loads one (passing \code{...} through). +#' @param width,height Integers divisible by 16, or NULL (default) for +#' the processed reference image's size. +#' @param num_inference_steps Integer. Denoising steps (klein-4B: 4). +#' @param max_sequence_length Integer. Qwen3 token length (512). +#' @param seed Integer or NULL. See \code{\link{txt2img_flux2}}. +#' @param prompt_embeds Optional precomputed [B, S, 7680] embeddings. +#' @param save_file Logical. Write a PNG. +#' @param filename Output path (default derived from the prompt). +#' @param verbose Logical, or one of "silent", "progress", "steps". +#' @param ... Passed to \code{\link{flux2_load_pipeline}} when +#' \code{pipeline} is NULL. +#' +#' @return Invisibly, \code{list(image, metadata)} where \code{image} is +#' an [H, W, 3] array in [0, 1]. +#' +#' @export +img2img_flux2 <- function(prompt, image, pipeline = NULL, width = NULL, + height = NULL, num_inference_steps = 4L, + max_sequence_length = 512L, seed = NULL, + prompt_embeds = NULL, save_file = TRUE, + filename = NULL, verbose = TRUE, ...) { + level <- .verbosity(verbose) + reference <- flux2_preprocess_image(image) + width <- as.integer(width %||% reference$shape[4]) + height <- as.integer(height %||% reference$shape[3]) + if (level != "silent") { + message(sprintf("FLUX.2 klein edit: %dx%d from a %dx%d reference, %d steps", + width, height, as.integer(reference$shape[4]), + as.integer(reference$shape[3]), + as.integer(num_inference_steps))) + } + if (is.null(pipeline)) { + pipeline <- flux2_load_pipeline(..., encoder = TRUE, + verbose = level == "steps") + } + .flux2_generate(prompt, pipeline, width, height, num_inference_steps, + max_sequence_length, seed, prompt_embeds, save_file, + filename, level, reference = reference) +} + +#' Preprocess a FLUX.2 reference image +#' +#' Mirrors the diffusers klein pipeline: validate (both sides at least +#' 64 px, aspect ratio at most 8), Lanczos-resize to \code{max_area} if +#' larger (PIL's antialiased Lanczos-3, computed in floating point), floor +#' both sides to a multiple of 16, center-crop, and scale to [-1, 1]. +#' +#' @param image Path to a PNG/JPEG, or an [H, W, C] array in [0, 1]. +#' @param max_area Numeric. Pixel area cap. +#' +#' @return Float32 tensor [1, 3, H, W] in [-1, 1]. +#' +#' @export +flux2_preprocess_image <- function(image, max_area = 1024 * 1024) { + if (is.character(image)) { + if (!file.exists(image)) { + stop("image file not found: ", image, call. = FALSE) + } + image <- if (grepl("\\.png$", image, ignore.case = TRUE)) { + png::readPNG(image) + } else if (grepl("\\.jpe?g$", image, ignore.case = TRUE)) { + jpeg::readJPEG(image) + } else { + stop("image must be a .png, .jpg or .jpeg file", call. = FALSE) + } + } + if (!is.array(image) && !is.matrix(image)) { + stop("image must be a file path or an [H, W, 3] array", call. = FALSE) + } + if (length(dim(image)) == 2L) { + image <- array(rep(image, 3L), dim = c(dim(image), 3L)) + } + if (length(dim(image)) != 3L) { + stop("image must be an [H, W, 3] array", call. = FALSE) + } + if (dim(image)[3] == 4L) { + image <- image[,, 1:3, drop = FALSE] + } + if (dim(image)[3] != 3L) { + stop("image must have 3 colour channels", call. = FALSE) + } + + h <- dim(image)[1] + w <- dim(image)[2] + if (h < 64L || w < 64L) { + stop(sprintf("image too small: %dx%d; both sides must be at least 64 px", + w, h), call. = FALSE) + } + if (max(w / h, h / w) > 8) { + stop(sprintf("aspect ratio too extreme: %dx%d; at most 8:1", w, h), + call. = FALSE) + } + + x <- torch::torch_tensor(image, dtype = torch::torch_float32()) + x <- x$permute(c(3L, 1L, 2L)) # [3, H, W] + if (w * h > max_area) { + scale <- sqrt(max_area / (w * h)) + new_w <- as.integer(floor(w * scale)) + new_h <- as.integer(floor(h * scale)) + x <- .lanczos_resize(x, new_h, new_w) + h <- new_h + w <- new_w + } + crop_h <- (h %/% 16L) * 16L + crop_w <- (w %/% 16L) * 16L + top <- (h - crop_h) %/% 2L + left <- (w - crop_w) %/% 2L + x <- x$narrow(2L, top + 1L, crop_h)$narrow(3L, left + 1L, crop_w) + x$unsqueeze(1L)$mul(2)$sub(1) +} + +# PIL's Lanczos-3 resampling weights for one axis (ImagingResample +# precompute_coeffs): the kernel support widens by the downscale factor, +# which is what antialiases, and each row is normalized to sum to 1. +# Returns an [out_size, in_size] matrix. +.lanczos_weights <- function(in_size, out_size) { + scale <- in_size / out_size + filterscale <- max(scale, 1) + support <- 3 * filterscale + sinc <- function(v) ifelse(v == 0, 1, sin(pi * v) / (pi * v)) + lanczos <- function(v) ifelse(abs(v) < 3, sinc(v) * sinc(v / 3), 0) + m <- matrix(0, out_size, in_size) + for (i in seq_len(out_size)) { + center <- (i - 0.5) * scale + xmin <- max(as.integer(center - support + 0.5), 0L) + xmax <- min(as.integer(center + support + 0.5), in_size) + if (xmax <= xmin) { + next + } + src <- xmin:(xmax - 1L) + k <- lanczos((src - center + 0.5) / filterscale) + m[i, src + 1L] <- k / sum(k) + } + m +} + +# Separable Lanczos resize of a [C, H, W] float tensor. +.lanczos_resize <- function(x, new_h, new_w) { + f32 <- torch::torch_float32() + wh <- torch::torch_tensor(.lanczos_weights(x$shape[2], new_h), dtype = f32) + ww <- torch::torch_tensor(.lanczos_weights(x$shape[3], new_w), dtype = f32) + x <- torch::torch_matmul(x, ww$t()) # [C, H, new_w] + torch::torch_matmul(wh, x)$clamp(0, 1) # [C, new_h, new_w] +} diff --git a/R/rope_flux2.R b/R/rope_flux2.R index db13f15..68e6f5f 100644 --- a/R/rope_flux2.R +++ b/R/rope_flux2.R @@ -56,6 +56,30 @@ flux2_prepare_latent_ids <- function(height, width, device = "cpu") { ids$reshape(c(height * width, 4L)) } +#' FLUX.2 position ids for reference images +#' +#' (T, H, W, L) coordinates for reference-image tokens, concatenated in +#' order: the i-th reference (1-based) takes T = 10 * i, which keeps it +#' apart from the generated image's T = 0. Reference: +#' Flux2KleinPipeline._prepare_image_ids (scale 10). +#' +#' @param sizes List of \code{c(height, width)} packed grid sizes, one +#' per reference image. +#' @param device Device for the resulting tensor. +#' +#' @return Float tensor [sum(height * width), 4]. +#' +#' @export +flux2_prepare_image_ids <- function(sizes, device = "cpu") { + ids <- lapply(seq_along(sizes), function(i) { + x <- flux2_prepare_latent_ids(sizes[[i]][1], sizes[[i]][2], + device = device) + x[, 1] <- x[, 1]$add(10 * i) + x + }) + torch::torch_cat(ids, dim = 1L) +} + #' Empirical timestep shift for FLUX.2 #' #' BFL's piecewise-linear fit of the dynamic-shifting mu as a function of diff --git a/R/txt2img_flux2.R b/R/txt2img_flux2.R index b3ba0c6..f7306a9 100644 --- a/R/txt2img_flux2.R +++ b/R/txt2img_flux2.R @@ -86,6 +86,9 @@ NULL #' \code{snapshots//}, so the load works against a cache holding #' only that snapshot -- no \code{refs/} entry and no network. The #' transformer comes from \code{model_dir} and is unaffected. +#' @param encoder Logical. Also load the VAE encoder, which +#' \code{\link{img2img_flux2}} needs (FALSE keeps a text-to-image +#' pipeline's memory unchanged). #' #' @return A \code{flux2_pipeline} list. #' @@ -94,7 +97,8 @@ flux2_load_pipeline <- function(model_dir = NULL, device = "cuda", precision = c("auto", "fp8", "nf4", "bf16"), text_device = NULL, attn_chunk = NULL, phase_offload = TRUE, pin = NULL, - verbose = TRUE, revision = NULL) { + verbose = TRUE, revision = NULL, + encoder = FALSE) { ## Checked before any device work, where a configuration mistake is ## cheapest to report. .flux2_rev(revision) @@ -181,6 +185,21 @@ flux2_load_pipeline <- function(model_dir = NULL, device = "cuda", # the decode reads the decoder's own dtype and follows. pipe$decoder$to(dtype = torch::torch_bfloat16()) } + if (encoder) { + if (verbose) { + message("Loading FLUX.2 VAE encoder...") + } + pipe$encoder <- load_flux2_vae_encoder( + .flux2_cached("vae/diffusion_pytorch_model.safetensors", revision), + latent_channels = as.integer(vae_config$latent_channels %||% 32L), + verbose = verbose + ) + pipe$encoder$to(device = component_device) + # Same dtype policy as the decoder above + if (device == "cuda" && phase_offload) { + pipe$encoder$to(dtype = torch::torch_bfloat16()) + } + } if (verbose) { message("Loading Qwen3 text encoder...") @@ -195,6 +214,9 @@ flux2_load_pipeline <- function(model_dir = NULL, device = "cuda", .flux2_cached("tokenizer/tokenizer.json", revision)) components <- c("transformer", "decoder") + if (encoder) { + components <- c(components, "encoder") + } if (!identical(text_device, "cpu")) { components <- c(components, "text_encoder") } @@ -204,14 +226,18 @@ flux2_load_pipeline <- function(model_dir = NULL, device = "cuda", structure(pipe, class = "flux2_pipeline") } -# Flow-matching Euler loop; klein is CFG-free (one forward per step) +# Flow-matching Euler loop; klein is CFG-free (one forward per step). +# Reference-image tokens (img2img) ride after the noisy latents in every +# forward and only the latents' predictions are kept. .flux2_denoise <- function(transformer, latents, schedule, prompt_embeds, image_rotary_emb, compute_dtype, - chunk_size = NULL, verbose = TRUE) { + chunk_size = NULL, verbose = TRUE, + ref_tokens = NULL) { timesteps <- as.numeric(schedule$timesteps$cpu()) n <- length(timesteps) pb <- .denoise_progress(n, NULL, verbose) f32 <- torch::torch_float32() + seq_img <- latents$shape[2] torch::with_no_grad({ for (i in seq_len(n)) { @@ -219,13 +245,21 @@ flux2_load_pipeline <- function(model_dir = NULL, device = "cuda", t_model <- torch::torch_tensor(t / 1000, dtype = compute_dtype, device = latents$device)$reshape(1L) + hidden <- latents$to(dtype = compute_dtype) + if (!is.null(ref_tokens)) { + hidden <- torch::torch_cat(list(hidden, ref_tokens), dim = 2L) + } noise_pred <- transformer( - hidden_states = latents$to(dtype = compute_dtype), + hidden_states = hidden, encoder_hidden_states = prompt_embeds, timestep = t_model, image_rotary_emb = image_rotary_emb, chunk_size = chunk_size ) + rm(hidden) + if (!is.null(ref_tokens)) { + noise_pred <- noise_pred$narrow(2L, 1L, seq_img) + } step <- flowmatch_scheduler_step( noise_pred$to(dtype = f32), t, latents, schedule @@ -277,14 +311,26 @@ txt2img_flux2 <- function(prompt, pipeline = NULL, width = 1024L, prompt_embeds = NULL, save_file = TRUE, filename = NULL, verbose = TRUE, ...) { level <- .verbosity(verbose) - verbose <- level == "steps" if (level != "silent") { message(sprintf("FLUX.2 klein: %dx%d, %d steps", as.integer(width), as.integer(height), as.integer(num_inference_steps))) } if (is.null(pipeline)) { - pipeline <- flux2_load_pipeline(..., verbose = verbose) + pipeline <- flux2_load_pipeline(..., verbose = level == "steps") } + .flux2_generate(prompt, pipeline, width, height, num_inference_steps, + max_sequence_length, seed, prompt_embeds, save_file, + filename, level) +} + +# The shared FLUX.2 generation body. `reference` is NULL (txt2img) or a +# preprocessed [1, 3, H, W] image in [-1, 1] (img2img), encoded to +# reference tokens with the pipeline's VAE encoder. +.flux2_generate <- function(prompt, pipeline, width, height, + num_inference_steps, max_sequence_length, seed, + prompt_embeds, save_file, filename, level, + reference = NULL) { + verbose <- level == "steps" device <- pipeline$device width <- as.integer(width) height <- as.integer(height) @@ -367,7 +413,28 @@ txt2img_flux2 <- function(prompt, pipeline = NULL, width = 1024L, txt_ids <- flux2_prepare_text_ids(txt_len) latent_ids <- flux2_prepare_latent_ids(h2, w2) - ids <- torch::torch_cat(list(txt_ids, latent_ids), dim = 1L) + ids <- list(txt_ids, latent_ids) + + # --- Reference image (img2img): encode once, reuse every step ------------------- + ref_tokens <- NULL + if (!is.null(reference)) { + if (is.null(pipeline$encoder)) { + stop("this pipeline has no VAE encoder; load it with ", + "flux2_load_pipeline(encoder = TRUE)", call. = FALSE) + } + if (verbose) { + message("Encoding reference image...") + } + encoder <- onload("encoder") + ref <- flux2_encode_image(encoder, reference, + bn_eps = pipeline$vae_bn_eps %||% 1e-4) + offload("encoder") + ref_tokens <- ref$tokens$to(device = device, dtype = compute_dtype) + ids <- c(ids, list(flux2_prepare_image_ids(list(c(ref$height, + ref$width))))) + rm(ref) + } + ids <- torch::torch_cat(ids, dim = 1L) rope <- flux_pos_embed( ids, axes_dim = pipeline$transformer$axes_dims_rope %||% c(32L, 32L, 32L, 32L), @@ -403,8 +470,9 @@ txt2img_flux2 <- function(prompt, pipeline = NULL, width = 1024L, latents <- .flux2_denoise( transformer, latents, sched, prompt_embeds, rope, compute_dtype, chunk_size = pipeline$attn_chunk, - verbose = level + verbose = level, ref_tokens = ref_tokens ) + rm(ref_tokens) if (fp8_manual && phase_offload) { .flux_fp8_to_device(pipeline$transformer, "cpu") } diff --git a/R/vae_flux2_encoder.R b/R/vae_flux2_encoder.R new file mode 100644 index 0000000..a4eb2e4 --- /dev/null +++ b/R/vae_flux2_encoder.R @@ -0,0 +1,217 @@ +#' VAE Down Block +#' +#' diffusers DownEncoderBlock2D: resnets, then an optional stride-2 +#' downsample conv. The downsample pads (0, 1, 0, 1) and convolves with +#' padding 0 (Downsample2D with \code{downsample_padding = 0}). +#' +#' @param in_channels Input channels +#' @param out_channels Output channels +#' @param num_resnets Number of resnet blocks +#' @param add_downsample Whether to add the downsampler +#' @param norm_groups Group norm groups +#' @keywords internal +VAEDownBlock <- torch::nn_module( + "VAEDownBlock", + + initialize = function(in_channels, out_channels, num_resnets = 2, + add_downsample = TRUE, norm_groups = 32) { + self$resnets <- torch::nn_module_list() + for (i in seq_len(num_resnets)) { + res_in <- if (i == 1) in_channels else out_channels + self$resnets$append(VAEResnetBlock(res_in, out_channels, norm_groups)) + } + if (add_downsample) { + self$downsamplers <- torch::nn_module_list(list( + torch::nn_module( + "Downsampler", + initialize = function(channels) { + self$conv <- torch::nn_conv2d(channels, channels, + kernel_size = 3, stride = 2, padding = 0) + }, + forward = function(x) { + self$conv(torch::nnf_pad(x, c(0, 1, 0, 1))) + } + )(out_channels) + )) + } +}, + + forward = function(x) { + for (i in seq_along(self$resnets)) { + x <- self$resnets[[i]](x) + } + if (!is.null(self$downsamplers)) { + x <- self$downsamplers[[1]](x) + } + x +} +) + +#' FLUX.2 VAE encoder +#' +#' The AutoencoderKLFlux2 encode path: the standard diffusers AutoencoderKL +#' encoder body (conv_in, down blocks, attention mid block, GroupNorm + +#' SiLU, conv_out to 2 x latent channels) followed by quant_conv, plus the +#' BatchNorm running statistics used to normalize the patchified latent. +#' The forward returns the posterior mean (the reference pipeline encodes +#' with \code{sample_mode = "argmax"}). Reference: +#' src/diffusers/models/autoencoders/vae.py (Encoder) and +#' autoencoder_kl_flux2.py (Apache-2.0). +#' +#' @param latent_channels Integer (32 for FLUX.2). +#' @param block_channels Encoder block_out_channels. +#' @param layers_per_block Integer. Resnets per down block. +#' @param norm_groups Integer. Group norm groups. +#' +#' @return Module whose forward(x) encodes [B, 3, H, W] images in +#' [-1, 1] to [B, 32, H / 8, W / 8] latent means. +#' +#' @export +flux2_vae_encoder <- torch::nn_module( + "flux2_vae_encoder", + initialize = function(latent_channels = 32L, + block_channels = c(128L, 256L, 512L, 512L), + layers_per_block = 2L, norm_groups = 32L) { + body <- torch::nn_module( + "flux2_vae_encoder_body", + initialize = function() { + n <- length(block_channels) + self$conv_in <- torch::nn_conv2d(3L, block_channels[1], + kernel_size = 3, padding = 1) + self$down_blocks <- torch::nn_module_list() + for (i in seq_len(n)) { + in_ch <- block_channels[max(i - 1L, 1L)] + self$down_blocks$append(VAEDownBlock(in_ch, block_channels[i], + num_resnets = layers_per_block, + add_downsample = i < n, norm_groups = norm_groups)) + } + self$mid_block <- VAEMidBlock(block_channels[n], norm_groups) + self$conv_norm_out <- torch::nn_group_norm(norm_groups, + block_channels[n], eps = 1e-6) + self$conv_out <- torch::nn_conv2d(block_channels[n], + 2L * latent_channels, kernel_size = 3, padding = 1) + }, + forward = function(x) { + x <- self$conv_in(x) + for (i in seq_along(self$down_blocks)) { + x <- self$down_blocks[[i]](x) + } + x <- self$mid_block(x) + x <- torch::nnf_silu(self$conv_norm_out(x)) + self$conv_out(x) + } + ) + self$encoder <- body() + self$quant_conv <- torch::nn_conv2d(2L * latent_channels, + 2L * latent_channels, kernel_size = 1) + bn_stats <- torch::nn_module( + "flux2_bn_stats", + initialize = function(n) { + self$running_mean <- torch::nn_buffer(torch::torch_zeros(n)) + self$running_var <- torch::nn_buffer(torch::torch_ones(n)) + }, + forward = function(x) x + ) + self$bn <- bn_stats(latent_channels * 4L) + self$latent_channels <- latent_channels +}, + forward = function(x) { + moments <- self$quant_conv(self$encoder(x)) + # DiagonalGaussianDistribution: mean is the first half of the channels + moments$narrow(2L, 1L, self$latent_channels) +} +) + +#' Load the FLUX.2 VAE encoder from safetensors +#' +#' Loads the encoder half plus quant_conv and the BatchNorm running +#' statistics; decoder and post_quant_conv keys are skipped. +#' +#' @param path Path to the VAE .safetensors file (or a directory +#' containing diffusion_pytorch_model.safetensors). +#' @param latent_channels,block_channels,layers_per_block,norm_groups +#' Constructor arguments for \code{\link{flux2_vae_encoder}}. +#' @param verbose Logical. +#' +#' @return The loaded \code{flux2_vae_encoder} in eval mode. +#' +#' @export +load_flux2_vae_encoder <- function(path, latent_channels = 32L, + block_channels = c(128L, 256L, 512L, 512L), + layers_per_block = 2L, norm_groups = 32L, + verbose = TRUE) { + path <- path.expand(path) + if (dir.exists(path)) { + path <- file.path(path, "diffusion_pytorch_model.safetensors") + } + enc <- flux2_vae_encoder(latent_channels = latent_channels, + block_channels = block_channels, + layers_per_block = layers_per_block, + norm_groups = norm_groups) + + handle <- safetensors::safetensors$new(path, framework = "torch") + keys <- setdiff(handle$keys(), "__metadata__") + keep <- keys[startsWith(keys, "encoder.") | + startsWith(keys, "quant_conv.") | + keys %in% c("bn.running_mean", "bn.running_var")] + + dests <- c(enc$named_parameters(), enc$named_buffers()) + filled <- character(0) + unmapped <- character(0) + torch::with_no_grad({ + for (key in keep) { + dest <- dests[[key]] + if (is.null(dest)) { + unmapped <- c(unmapped, key) + next + } + dest$copy_(handle$get_tensor(key)) + filled <- c(filled, key) + } + }) + unfilled <- setdiff(names(dests), filled) + if (length(unmapped)) { + stop("FLUX.2 VAE encoder load: ", length(unmapped), + " unmapped keys, e.g. ", + paste(utils::head(unmapped, 3), collapse = ", ")) + } + if (length(unfilled)) { + stop("FLUX.2 VAE encoder load: ", length(unfilled), + " unfilled params, e.g. ", + paste(utils::head(unfilled, 3), collapse = ", ")) + } + if (verbose) { + message("Loaded ", length(filled), " FLUX.2 VAE encoder tensors from ", + path) + } + enc$eval() + enc +} + +#' Encode a reference image into FLUX.2 tokens +#' +#' VAE-encode (posterior mean), patchify 2x2, normalize with the VAE +#' BatchNorm statistics and pack to channels-last tokens. Reference: +#' Flux2KleinPipeline._encode_vae_image and prepare_image_latents. +#' +#' @param encoder A loaded \code{\link{flux2_vae_encoder}}. +#' @param image Tensor [1, 3, H, W] in [-1, 1]; H and W multiples of 16. +#' @param bn_eps Numeric. BatchNorm epsilon. +#' +#' @return \code{list(tokens, height, width)}: tokens [1, H/16 * W/16, 128] +#' on the encoder's device and dtype, and the packed grid size. +#' +#' @export +flux2_encode_image <- function(encoder, image, bn_eps = 1e-4) { + param <- encoder$encoder$conv_in$weight + torch::with_no_grad({ + latents <- encoder(image$to(device = param$device, dtype = param$dtype)) + latents <- flux2_patchify_latents(latents) + latents <- flux2_bn_normalize(latents, encoder$bn$running_mean, + encoder$bn$running_var, eps = bn_eps) + h <- latents$shape[3] + w <- latents$shape[4] + tokens <- flux2_pack_latents(latents) + }) + list(tokens = tokens, height = h, width = w) +} diff --git a/inst/REFERENCES.md b/inst/REFERENCES.md index 066630f..b316ee0 100644 --- a/inst/REFERENCES.md +++ b/inst/REFERENCES.md @@ -40,6 +40,10 @@ module; this file documents the actual lineage, idea by idea. | MMDiT transformer (shared modulation, SwiGLU feed-forwards, ViT-22B parallel single blocks, 4-axis RoPE) | Ported from HuggingFace **diffusers** (Apache-2.0): `models/transformers/transformer_flux2.py` | | Klein pipeline flow: Qwen3 prompt encoding contract (chat template, mid-stack hidden states 9/18/27), 4-channel position ids, latent patchify/pack chain, empirical dynamic-shift mu, BatchNorm latent statistics | diffusers `pipelines/flux2/pipeline_flux2_klein.py` (Apache-2.0); the empirical mu formula originates from BFL `sampling.py` (numeric facts) | | 32-channel `AutoencoderKLFlux2` decode path (post_quant_conv + standard decoder + BN running stats) | diffusers `models/autoencoders/autoencoder_kl_flux2.py` (Apache-2.0); decoder body shared with the SD/FLUX.1 port | +| `AutoencoderKLFlux2` encode path (Encoder with DownEncoderBlock2D / Downsample2D pad-then-stride-2 conv, quant_conv, posterior mean) | Ported from diffusers `models/autoencoders/vae.py`, `models/downsampling.py`, `autoencoder_kl_flux2.py` (Apache-2.0); resnet and mid blocks shared with the decoder port | +| Reference-image conditioning (`img2img_flux2`): encode, patchify, BN-normalize and pack the reference; position ids T = 10 * i; reference tokens concatenated after the latents each step and sliced off the prediction; mu from the target length only | diffusers `pipelines/flux2/pipeline_flux2_klein.py` (`image=`, `prepare_image_latents`, `_prepare_image_ids`) (Apache-2.0) | +| Reference-image preprocessing: 64 px minimum side, 8:1 aspect limit, 1024^2 area cap, floor to multiples of 16, center crop | diffusers `pipelines/flux2/image_processor.py` (Apache-2.0) | +| Antialiased Lanczos-3 resize weights (support widened by the downscale factor, per-row normalization) | Pillow `Resample.c` `precompute_coeffs` algorithm (MIT-CMU / HPND), reimplemented in floating point | | Qwen3 encoder (GQA, per-head q/k RMS norms, SwiGLU, RoPE theta 1e6, causal + padding mask) | Ported from HuggingFace **transformers** (Apache-2.0): `models/qwen3/` | | Qwen2 byte-level BPE tokenization (GPT-2 byte table, GPT-4-style split regex, rank-based merges) | GPT-2 BPE (Radford et al. 2019; openai/gpt-2 `encoder.py`, MIT); format facts from HuggingFace tokenizers documentation | | Weights | black-forest-labs/FLUX.2-klein-4B (Apache-2.0, ungated; downloaded by the user, never redistributed) | diff --git a/inst/tinytest/fixtures/vae_flux2_enc_io.safetensors b/inst/tinytest/fixtures/vae_flux2_enc_io.safetensors new file mode 100644 index 0000000000000000000000000000000000000000..24a1b9840c529ff43f4938c0ac1662c9101a8d1a GIT binary patch literal 25024 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⁢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 U$6>o@Ny{#XIciM@t1;pK0gXRA3jhEB literal 0 HcmV?d00001 diff --git a/inst/tinytest/test_img2img_flux2.R b/inst/tinytest/test_img2img_flux2.R new file mode 100644 index 0000000..c30ecb2 --- /dev/null +++ b/inst/tinytest/test_img2img_flux2.R @@ -0,0 +1,130 @@ +# FLUX.2 klein image + prompt input: VAE encoder parity against a tiny +# random-init diffusers AutoencoderKLFlux2 (fixtures from +# tools/gen_fixtures_flux2_vae_encoder.py), reference ids, preprocessing, +# and the reference-token wiring of the denoise loop. + +if (!requireNamespace("torch", quietly = TRUE) || !torch::torch_is_installed()) { + exit_file("torch not fully installed") +} +if (!requireNamespace("safetensors", quietly = TRUE)) { + exit_file("safetensors not installed") +} + +library(diffuseR) + +fixture <- function(name) { + p <- system.file("tinytest", "fixtures", name, package = "diffuseR") + if (p == "") p <- file.path("fixtures", name) + p +} +vae_path <- fixture("vae_flux2_tiny.safetensors") +io_path <- fixture("vae_flux2_enc_io.safetensors") +if (!file.exists(io_path)) exit_file("flux2 vae encoder fixtures missing") +io <- safetensors::safe_load_file(io_path, framework = "torch") + +max_abs_diff <- function(a, b) { + as.numeric(torch::torch_max(torch::torch_abs(a - b))) +} + +# --- Encoder parity ----------------------------------------------------------- +enc <- load_flux2_vae_encoder(vae_path, block_channels = c(8L, 16L, 32L, 32L), + norm_groups = 8L, verbose = FALSE) +mean <- torch::with_no_grad(enc(io$image)) +expect_equal(as.integer(mean$shape), c(1L, 32L, 4L, 6L)) +expect_true(max_abs_diff(mean, io$mean) < 1e-5) + +ref <- flux2_encode_image(enc, io$image) +expect_equal(c(ref$height, ref$width), c(2L, 3L)) +expect_true(max_abs_diff(ref$tokens, io$tokens) < 1e-5) + +# Reference ids: T = 10 for the first image, then the row/column grid +ids <- flux2_prepare_image_ids(list(c(ref$height, ref$width))) +expect_equal(as.matrix(ids), as.matrix(io$image_ids)) +ids2 <- flux2_prepare_image_ids(list(c(1L, 2L), c(1L, 1L))) +expect_equal(as.numeric(ids2[, 1]), c(10, 10, 20)) + +# The loader refuses a file it cannot fill +expect_error(load_flux2_vae_encoder(vae_path, verbose = FALSE), "unmapped|unfilled|size") + +# --- Preprocessing -------------------------------------------------------------- +img <- array(0.75, c(70L, 100L, 3L)) +p <- flux2_preprocess_image(img) +expect_equal(as.integer(p$shape), c(1L, 3L, 64L, 96L)) +expect_true(max(abs(as.numeric(p) - 0.5)) < 1e-6) + +# Center crop: a column marker survives at the right offset +img <- array(0, c(64L, 72L, 3L)) +img[, 5L, ] <- 1 # crop drops 4 columns from the left +p <- flux2_preprocess_image(img) +expect_equal(as.integer(p$shape), c(1L, 3L, 64L, 64L)) +expect_equal(as.numeric(p[1, 1, 1, 1]), 1) + +# Grayscale and alpha inputs become RGB +expect_equal(as.integer(flux2_preprocess_image(matrix(0.5, 64L, 64L))$shape), + c(1L, 3L, 64L, 64L)) +expect_equal(as.integer(flux2_preprocess_image(array(0.5, c(64L, 64L, 4L)))$shape), + c(1L, 3L, 64L, 64L)) + +# Area cap: 300 x 200 over a 100^2-pixel cap -> 122 x 81 -> 112 x 80 +p <- flux2_preprocess_image(array(0.25, c(200L, 300L, 3L)), max_area = 100^2) +expect_equal(as.integer(p$shape), c(1L, 3L, 80L, 112L)) +expect_true(max(abs(as.numeric(p) + 0.5)) < 1e-5) # a constant stays constant + +expect_error(flux2_preprocess_image(array(0, c(40L, 100L, 3L))), "too small") +expect_error(flux2_preprocess_image(array(0, c(64L, 600L, 3L))), "aspect ratio") +expect_error(flux2_preprocess_image("no-such-file.png"), "not found") + +# Lanczos weights: rows sum to 1; same size is the identity +w <- diffuseR:::.lanczos_weights(10L, 4L) +expect_equal(rowSums(w), rep(1, 4)) +expect_equal(diffuseR:::.lanczos_weights(5L, 5L), diag(5), tolerance = 1e-12) + +# --- Denoise wiring: reference tokens ride after the latents --------------------- +seen <- new.env() +fake <- function(hidden_states, encoder_hidden_states, timestep, + image_rotary_emb, chunk_size = NULL) { + seen$len <- hidden_states$shape[2] + hidden_states$mul(0) +} +sched <- flowmatch_scheduler_create(use_dynamic_shifting = TRUE, + time_shift_type = "exponential") +sched <- flowmatch_set_timesteps(sched, 2L, mu = 1, sigmas = c(1, 0.5)) +lat <- torch::torch_randn(1L, 6L, 128L) +out <- diffuseR:::.flux2_denoise(fake, lat, sched, NULL, NULL, + torch::torch_float32(), verbose = FALSE, + ref_tokens = torch::torch_randn(1L, 4L, 128L)) +expect_equal(seen$len, 10L) +expect_equal(as.integer(out$shape), c(1L, 6L, 128L)) +expect_true(max_abs_diff(out, lat) < 1e-6) # zero velocity leaves latents unchanged + +# --- End to end with tiny components ------------------------------------------------ +torch::torch_manual_seed(8) +transformer <- flux2_transformer(in_channels = 128L, num_layers = 1L, + num_single_layers = 1L, attention_head_dim = 8L, + num_attention_heads = 2L, joint_attention_dim = 24L, + mlp_ratio = 3.0, axes_dims_rope = c(2L, 2L, 2L, 2L), + rope_theta = 2000) +transformer$eval() +decoder <- flux2_vae_decoder(latent_channels = 32L, + block_channels = c(32L, 32L, 16L, 8L), norm_groups = 8L) +decoder$eval() +pipeline <- structure(list(transformer = transformer, decoder = decoder, + device = "cpu", text_device = "cpu", phase_offload = FALSE, + fp8_resident = FALSE, format = "full", attn_chunk = NULL, + vae_bn_eps = 1e-4), + class = "flux2_pipeline") +emb <- torch::torch_randn(1L, 7L, 24L) + +# Without an encoder the edit says how to load one +expect_error(img2img_flux2("x", array(0.5, c(64L, 96L, 3L)), pipeline = pipeline, + num_inference_steps = 1L, prompt_embeds = emb, + save_file = FALSE, verbose = FALSE), + "encoder = TRUE") + +pipeline$encoder <- enc +res <- img2img_flux2("tiny edit", array(0.5, c(64L, 96L, 3L)), pipeline = pipeline, + num_inference_steps = 2L, seed = 42L, prompt_embeds = emb, + save_file = FALSE, verbose = FALSE) +expect_equal(dim(res$image), c(64L, 96L, 3L)) # output size follows the reference +expect_true(all(is.finite(res$image))) +expect_true(all(res$image >= 0) && all(res$image <= 1)) diff --git a/man/VAEDownBlock.Rd b/man/VAEDownBlock.Rd new file mode 100644 index 0000000..ae2f90a --- /dev/null +++ b/man/VAEDownBlock.Rd @@ -0,0 +1,30 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{VAEDownBlock} +\alias{VAEDownBlock} +\title{VAE Down Block} +\usage{ +VAEDownBlock( + in_channels, + out_channels, + num_resnets = 2, + add_downsample = TRUE, + norm_groups = 32 +) +} +\arguments{ +\item{in_channels}{Input channels} + +\item{out_channels}{Output channels} + +\item{num_resnets}{Number of resnet blocks} + +\item{add_downsample}{Whether to add the downsampler} + +\item{norm_groups}{Group norm groups} +} +\description{ +diffusers DownEncoderBlock2D: resnets, then an optional stride-2 +downsample conv. The downsample pads (0, 1, 0, 1) and convolves with +padding 0 (Downsample2D with \code{downsample_padding = 0}). +} +\keyword{internal} diff --git a/man/flux2_encode_image.Rd b/man/flux2_encode_image.Rd new file mode 100644 index 0000000..5b09776 --- /dev/null +++ b/man/flux2_encode_image.Rd @@ -0,0 +1,23 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{flux2_encode_image} +\alias{flux2_encode_image} +\title{Encode a reference image into FLUX.2 tokens} +\usage{ +flux2_encode_image(encoder, image, bn_eps = 1e-04) +} +\arguments{ +\item{encoder}{A loaded \code{\link{flux2_vae_encoder}}.} + +\item{image}{Tensor [1, 3, H, W] in [-1, 1]; H and W multiples of 16.} + +\item{bn_eps}{Numeric. BatchNorm epsilon.} +} +\value{ +\code{list(tokens, height, width)}: tokens [1, H/16 * W/16, 128] + on the encoder's device and dtype, and the packed grid size. +} +\description{ +VAE-encode (posterior mean), patchify 2x2, normalize with the VAE +BatchNorm statistics and pack to channels-last tokens. Reference: +Flux2KleinPipeline._encode_vae_image and prepare_image_latents. +} diff --git a/man/flux2_load_pipeline.Rd b/man/flux2_load_pipeline.Rd index 809e978..6accf19 100644 --- a/man/flux2_load_pipeline.Rd +++ b/man/flux2_load_pipeline.Rd @@ -12,7 +12,8 @@ flux2_load_pipeline( phase_offload = TRUE, pin = NULL, verbose = TRUE, - revision = NULL + revision = NULL, + encoder = FALSE ) } \arguments{ @@ -45,6 +46,10 @@ cache. With one they resolve straight out of \code{snapshots//}, so the load works against a cache holding only that snapshot -- no \code{refs/} entry and no network. The transformer comes from \code{model_dir} and is unaffected.} + +\item{encoder}{Logical. Also load the VAE encoder, which +\code{\link{img2img_flux2}} needs (FALSE keeps a text-to-image +pipeline's memory unchanged).} } \value{ A \code{flux2_pipeline} list. diff --git a/man/flux2_prepare_image_ids.Rd b/man/flux2_prepare_image_ids.Rd new file mode 100644 index 0000000..146a402 --- /dev/null +++ b/man/flux2_prepare_image_ids.Rd @@ -0,0 +1,22 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{flux2_prepare_image_ids} +\alias{flux2_prepare_image_ids} +\title{FLUX.2 position ids for reference images} +\usage{ +flux2_prepare_image_ids(sizes, device = "cpu") +} +\arguments{ +\item{sizes}{List of \code{c(height, width)} packed grid sizes, one +per reference image.} + +\item{device}{Device for the resulting tensor.} +} +\value{ +Float tensor [sum(height * width), 4]. +} +\description{ +(T, H, W, L) coordinates for reference-image tokens, concatenated in +order: the i-th reference (1-based) takes T = 10 * i, which keeps it +apart from the generated image's T = 0. Reference: +Flux2KleinPipeline._prepare_image_ids (scale 10). +} diff --git a/man/flux2_preprocess_image.Rd b/man/flux2_preprocess_image.Rd new file mode 100644 index 0000000..56e23ae --- /dev/null +++ b/man/flux2_preprocess_image.Rd @@ -0,0 +1,21 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{flux2_preprocess_image} +\alias{flux2_preprocess_image} +\title{Preprocess a FLUX.2 reference image} +\usage{ +flux2_preprocess_image(image, max_area = 1024 * 1024) +} +\arguments{ +\item{image}{Path to a PNG/JPEG, or an [H, W, C] array in [0, 1].} + +\item{max_area}{Numeric. Pixel area cap.} +} +\value{ +Float32 tensor [1, 3, H, W] in [-1, 1]. +} +\description{ +Mirrors the diffusers klein pipeline: validate (both sides at least +64 px, aspect ratio at most 8), Lanczos-resize to \code{max_area} if +larger (PIL's antialiased Lanczos-3, computed in floating point), floor +both sides to a multiple of 16, center-crop, and scale to [-1, 1]. +} diff --git a/man/flux2_vae_encoder.Rd b/man/flux2_vae_encoder.Rd new file mode 100644 index 0000000..8b956cc --- /dev/null +++ b/man/flux2_vae_encoder.Rd @@ -0,0 +1,35 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{flux2_vae_encoder} +\alias{flux2_vae_encoder} +\title{FLUX.2 VAE encoder} +\usage{ +flux2_vae_encoder( + latent_channels = 32L, + block_channels = c(128L, 256L, 512L, 512L), + layers_per_block = 2L, + norm_groups = 32L +) +} +\arguments{ +\item{latent_channels}{Integer (32 for FLUX.2).} + +\item{block_channels}{Encoder block_out_channels.} + +\item{layers_per_block}{Integer. Resnets per down block.} + +\item{norm_groups}{Integer. Group norm groups.} +} +\value{ +Module whose forward(x) encodes [B, 3, H, W] images in + [-1, 1] to [B, 32, H / 8, W / 8] latent means. +} +\description{ +The AutoencoderKLFlux2 encode path: the standard diffusers AutoencoderKL +encoder body (conv_in, down blocks, attention mid block, GroupNorm + +SiLU, conv_out to 2 x latent channels) followed by quant_conv, plus the +BatchNorm running statistics used to normalize the patchified latent. +The forward returns the posterior mean (the reference pipeline encodes +with \code{sample_mode = "argmax"}). Reference: +src/diffusers/models/autoencoders/vae.py (Encoder) and +autoencoder_kl_flux2.py (Apache-2.0). +} diff --git a/man/img2img_flux2.Rd b/man/img2img_flux2.Rd new file mode 100644 index 0000000..162aa7d --- /dev/null +++ b/man/img2img_flux2.Rd @@ -0,0 +1,67 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{img2img_flux2} +\alias{img2img_flux2} +\title{Edit an image with FLUX.2 klein} +\usage{ +img2img_flux2( + prompt, + image, + pipeline = NULL, + width = NULL, + height = NULL, + num_inference_steps = 4L, + max_sequence_length = 512L, + seed = NULL, + prompt_embeds = NULL, + save_file = TRUE, + filename = NULL, + verbose = TRUE, + ... +) +} +\arguments{ +\item{prompt}{Character. The edit instruction or prompt.} + +\item{image}{Path to a PNG/JPEG, or an [H, W, 3] array in [0, 1].} + +\item{pipeline}{A \code{flux2_pipeline} loaded with +\code{encoder = TRUE}; NULL loads one (passing \code{...} through).} + +\item{num_inference_steps}{Integer. Denoising steps (klein-4B: 4).} + +\item{max_sequence_length}{Integer. Qwen3 token length (512).} + +\item{seed}{Integer or NULL. See \code{\link{txt2img_flux2}}.} + +\item{prompt_embeds}{Optional precomputed [B, S, 7680] embeddings.} + +\item{save_file}{Logical. Write a PNG.} + +\item{filename}{Output path (default derived from the prompt).} + +\item{verbose}{Logical, or one of "silent", "progress", "steps".} + +\item{...}{Passed to \code{\link{flux2_load_pipeline}} when +\code{pipeline} is NULL.} + +\item{width,height}{Integers divisible by 16, or NULL (default) for +the processed reference image's size.} +} +\value{ +Invisibly, \code{list(image, metadata)} where \code{image} is + an [H, W, 3] array in [0, 1]. +} +\description{ +Image + prompt to image with the same klein-4B weights as +\code{\link{txt2img_flux2}}: the reference image is VAE-encoded once, +its tokens ride after the noisy latents in every transformer forward +(position ids at T = 10, see \code{\link{flux2_prepare_image_ids}}), +and only the latents' predictions drive the scheduler. Reference: +diffusers Flux2KleinPipeline with \code{image =} (Apache-2.0). +} +\details{ +The image is checked (both sides at least 64 px, aspect ratio at most +8), resized with Lanczos to 1024 x 1024 in area if it is larger, and +center-cropped to multiples of 16. One reference image is supported. + +} diff --git a/man/load_flux2_vae_encoder.Rd b/man/load_flux2_vae_encoder.Rd new file mode 100644 index 0000000..a7226e5 --- /dev/null +++ b/man/load_flux2_vae_encoder.Rd @@ -0,0 +1,29 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{load_flux2_vae_encoder} +\alias{load_flux2_vae_encoder} +\title{Load the FLUX.2 VAE encoder from safetensors} +\usage{ +load_flux2_vae_encoder( + path, + latent_channels = 32L, + block_channels = c(128L, 256L, 512L, 512L), + layers_per_block = 2L, + norm_groups = 32L, + verbose = TRUE +) +} +\arguments{ +\item{path}{Path to the VAE .safetensors file (or a directory +containing diffusion_pytorch_model.safetensors).} + +\item{verbose}{Logical.} + +\item{latent_channels,block_channels,layers_per_block,norm_groups}{Constructor arguments for \code{\link{flux2_vae_encoder}}.} +} +\value{ +The loaded \code{flux2_vae_encoder} in eval mode. +} +\description{ +Loads the encoder half plus quant_conv and the BatchNorm running +statistics; decoder and post_quant_conv keys are skipped. +} diff --git a/tools/gen_fixtures_flux2_vae_encoder.py b/tools/gen_fixtures_flux2_vae_encoder.py new file mode 100644 index 0000000..eeebb96 --- /dev/null +++ b/tools/gen_fixtures_flux2_vae_encoder.py @@ -0,0 +1,52 @@ +# Generate FLUX.2 VAE encode parity fixtures for the R port. +# +# Loads the tiny random-init AutoencoderKLFlux2 already checked in by +# gen_fixtures_flux2_vae.py (same config) and encodes a random image the +# way Flux2KleinPipeline does for a reference image: posterior mean +# (sample_mode="argmax"), patchify, BatchNorm normalize, pack. Run via +# tools/gen_fixtures.sh. + +import os +import sys + +import torch +from safetensors.torch import load_file, save_file + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "ref", "upstream", "diffusers", "src")) + +from diffusers import AutoencoderKLFlux2 # noqa: E402 +from diffusers.pipelines.flux2.pipeline_flux2_klein import Flux2KleinPipeline # noqa: E402 + +OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "inst", "tinytest", "fixtures") + +vae = AutoencoderKLFlux2( + in_channels=3, + out_channels=3, + down_block_types=("DownEncoderBlock2D",) * 4, + up_block_types=("UpDecoderBlock2D",) * 4, + block_out_channels=(8, 16, 32, 32), + layers_per_block=2, + latent_channels=32, + norm_num_groups=8, + use_quant_conv=True, + use_post_quant_conv=True, + sample_size=64, +) +vae.load_state_dict(load_file(os.path.join(OUT_DIR, "vae_flux2_tiny.safetensors"))) +vae.eval() + +torch.manual_seed(52) +image = torch.rand(1, 3, 32, 48) * 2 - 1 +with torch.no_grad(): + mean = vae.encode(image).latent_dist.mode() + lat = Flux2KleinPipeline._patchify_latents(mean) + bn_mean = vae.bn.running_mean.view(1, -1, 1, 1) + bn_std = torch.sqrt(vae.bn.running_var.view(1, -1, 1, 1) + vae.config.batch_norm_eps) + tokens = Flux2KleinPipeline._pack_latents((lat - bn_mean) / bn_std) + ids = Flux2KleinPipeline._prepare_image_ids([lat])[0].float() + +io = {"image": image, "mean": mean, "tokens": tokens, "image_ids": ids} +io = {k: v.contiguous() for k, v in io.items()} +save_file(io, os.path.join(OUT_DIR, "vae_flux2_enc_io.safetensors"), + metadata={"purpose": "diffuseR FLUX.2 test fixture"}) +print({k: tuple(v.shape) for k, v in io.items()}) From 7a65038919b1aceb9d45727ff1d40a5c8cb4b5fb Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Mon, 26 Oct 2026 14:47:28 -0500 Subject: [PATCH 2/2] Bump version to 0.2.2.15 --- DESCRIPTION | 2 +- NEWS.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index aa5c3d3..221bc0c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: diffuseR Title: Functional Interface to Diffusion Models in R -Version: 0.2.2.14 +Version: 0.2.2.15 Authors@R: c( person("Troy", "Hernandez", email = "troy@cornball.ai", role = c("aut", "cre"), comment = c(ORCID = "0009-0005-4248-604X")), diff --git a/NEWS.md b/NEWS.md index f1bca17..f27aed1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,18 @@ +# diffuseR 0.2.2.15 + +* **FLUX.2 klein image + prompt editing.** `img2img_flux2(prompt, image)` + edits one reference image with the same klein-4B weights: the + reference is VAE-encoded once and conditions every step, following the + diffusers klein pipeline's `image =` path. + - `flux2_load_pipeline(encoder = TRUE)` loads the new VAE encoder + (`flux2_vae_encoder()`, `load_flux2_vae_encoder()`); the default + keeps text-to-image memory unchanged. + - `flux2_preprocess_image()` validates and prepares the reference + (64 px minimum side, 8:1 aspect limit, Lanczos resize to 1024^2 in + area, center crop to multiples of 16). + - `flux2_encode_image()` and `flux2_prepare_image_ids()` build the + reference tokens and their position ids. + # diffuseR 0.2.2.14 * **LTX-2.5 (distilled) text-to-video.** The 2.3 pipeline runs the 2.5