aura_style / scripts /run_flux2_edit_lora_local.py
ezhoureal's picture
Keep only Flux-2 training and inference scripts
b3052c4 verified
Raw
History Blame
17.7 kB
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import importlib.util
import json
import re
import sys
import time
from pathlib import Path
from typing import Any
import torch
from safetensors.torch import load_file, save_file
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_LORA = REPO_ROOT / "fal_flux2_edit_lora" / "pytorch_lora_weights.safetensors"
DEFAULT_OUTPUT_DIR = REPO_ROOT / "outputs" / "local_flux2_edit_inference"
DEFAULT_MODEL = "diffusers/FLUX.2-dev-bnb-4bit"
DEFAULT_PROMPT = (
"Transform this photorealistic image into the trained radiant aura style: smooth colorful "
"gradients, ethereal haze, subtle contour lighting, and a refined cinematic glow. Preserve the "
"subject identity, composition, pose, silhouette, camera framing, and important details."
)
SUPPORTED_IMAGE_SUFFIXES = {".avif", ".bmp", ".jpeg", ".jpg", ".png", ".webp"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run local FLUX.2 image editing with the fal-trained LoRA, or validate it offline."
)
parser.add_argument(
"input_dir",
nargs="?",
type=Path,
help="Directory containing photorealistic input images.",
)
parser.add_argument("--prompt", default=DEFAULT_PROMPT, help="Edit prompt.")
parser.add_argument("--lora", type=Path, default=DEFAULT_LORA, help="Input LoRA safetensors file.")
parser.add_argument(
"--converted-lora",
type=Path,
default=None,
help="Optional path for a converted diffusers-format LoRA safetensors file.",
)
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help="Local path or Hugging Face model id. Defaults to the 4-bit FLUX.2-dev diffusers repo.",
)
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
parser.add_argument("--output-path", type=Path, default=None)
parser.add_argument(
"--batch-size",
type=int,
default=1,
help=(
"Number of input images to edit per pipeline call. Keep this low on <24GB VRAM; "
"try 2 first, then increase if memory allows."
),
)
parser.add_argument("--height", type=int, default=1024)
parser.add_argument("--width", type=int, default=1024)
parser.add_argument("--num-inference-steps", type=int, default=28)
parser.add_argument("--guidance-scale", type=float, default=2.5)
parser.add_argument("--lora-scale", type=float, default=1.0)
parser.add_argument("--seed", type=int, default=None)
parser.add_argument(
"--torch-dtype",
choices=("auto", "float32", "float16", "bfloat16"),
default="bfloat16",
help="Pipeline dtype. Use bfloat16 on modern NVIDIA GPUs.",
)
parser.add_argument(
"--device",
default=None,
help="Torch device. Defaults to cuda if available, otherwise cpu.",
)
parser.add_argument(
"--device-map",
default=None,
help='Optional diffusers/accelerate device map, for example "balanced".',
)
parser.add_argument(
"--local-files-only",
action="store_true",
help="Do not download model files from Hugging Face.",
)
parser.add_argument(
"--check-only",
action="store_true",
help="Validate/convert LoRA against the default Flux2Transformer2DModel shape without loading the base model.",
)
return parser.parse_args()
def dtype_from_arg(value: str) -> torch.dtype | str:
if value == "auto":
return "auto"
return {
"float32": torch.float32,
"float16": torch.float16,
"bfloat16": torch.bfloat16,
}[value]
def require_module(import_name: str, install_name: str | None = None) -> None:
if importlib.util.find_spec(import_name) is None:
package = install_name or import_name
raise RuntimeError(f"Missing required package `{package}`. Install it with `uv add {package}`.")
def uses_4bit_model(model: str) -> bool:
return "bnb-4bit" in model.lower() or "4bit" in model.lower()
def preflight_environment(args: argparse.Namespace) -> None:
if args.check_only:
return
require_module("google.protobuf", "protobuf")
device_name = args.device or ("cuda" if torch.cuda.is_available() else "cpu")
if uses_4bit_model(args.model):
require_module("bitsandbytes")
if device_name == "cpu" or not torch.cuda.is_available():
raise RuntimeError(
"The 4-bit FLUX.2 model needs a CUDA GPU with bitsandbytes. "
"This environment does not expose CUDA to PyTorch."
)
def convert_fal_key(key: str, tensor: torch.Tensor) -> dict[str, torch.Tensor]:
prefix = "base_model.model."
if not key.startswith(prefix):
return {key: tensor}
body = key.removeprefix(prefix)
suffix = ".lora_A.weight" if body.endswith(".lora_A.weight") else ".lora_B.weight"
base = body.removesuffix(suffix)
simple_map = {
"img_in": "x_embedder",
"txt_in": "context_embedder",
"time_in.in_layer": "time_guidance_embed.timestep_embedder.linear_1",
"time_in.out_layer": "time_guidance_embed.timestep_embedder.linear_2",
"guidance_in.in_layer": "time_guidance_embed.guidance_embedder.linear_1",
"guidance_in.out_layer": "time_guidance_embed.guidance_embedder.linear_2",
"double_stream_modulation_img.lin": "double_stream_modulation_img.linear",
"double_stream_modulation_txt.lin": "double_stream_modulation_txt.linear",
"single_stream_modulation.lin": "single_stream_modulation.linear",
"final_layer.linear": "proj_out",
}
if base in simple_map:
return {f"transformer.{simple_map[base]}{suffix}": tensor}
double_match = re.fullmatch(r"double_blocks\.(\d+)\.(img_attn|txt_attn)\.(qkv|proj)", base)
if double_match:
block, stream, layer = double_match.groups()
stem = f"transformer.transformer_blocks.{block}.attn"
if layer == "proj":
target = "to_out.0" if stream == "img_attn" else "to_add_out"
return {f"{stem}.{target}{suffix}": tensor}
targets = (
("to_q", "to_k", "to_v")
if stream == "img_attn"
else ("add_q_proj", "add_k_proj", "add_v_proj")
)
if suffix == ".lora_A.weight":
return {f"{stem}.{target}{suffix}": tensor.clone() for target in targets}
chunks = tensor.chunk(3, dim=0)
return {f"{stem}.{target}{suffix}": chunk.contiguous() for target, chunk in zip(targets, chunks)}
single_match = re.fullmatch(r"single_blocks\.(\d+)\.(linear1|linear2)", base)
if single_match:
block, layer = single_match.groups()
target = "to_qkv_mlp_proj" if layer == "linear1" else "to_out"
return {f"transformer.single_transformer_blocks.{block}.attn.{target}{suffix}": tensor}
raise ValueError(f"Unsupported fal LoRA key: {key}")
def convert_fal_lora_to_diffusers(input_path: Path, output_path: Path) -> dict[str, Any]:
state = load_file(input_path)
converted: dict[str, torch.Tensor] = {}
for key, tensor in state.items():
for new_key, new_tensor in convert_fal_key(key, tensor).items():
if new_key in converted:
raise ValueError(f"Duplicate converted LoRA key: {new_key}")
converted[new_key] = new_tensor
output_path.parent.mkdir(parents=True, exist_ok=True)
save_file(converted, output_path, metadata={"format": "pt"})
return {
"input_keys": len(state),
"converted_keys": len(converted),
"input_bytes": input_path.stat().st_size,
"converted_bytes": output_path.stat().st_size,
}
def expected_linear_shapes() -> dict[str, tuple[int, ...]]:
from accelerate import init_empty_weights
from diffusers import Flux2Transformer2DModel
with init_empty_weights():
model = Flux2Transformer2DModel()
return {
f"transformer.{name}": tuple(module.weight.shape)
for name, module in model.named_modules()
if module.__class__.__name__ == "Linear"
}
def validate_converted_lora(path: Path) -> dict[str, Any]:
state = load_file(path)
shapes = expected_linear_shapes()
missing_targets = []
bad_shapes = []
ranks = set()
for key, tensor in state.items():
if key.endswith(".lora_A.weight"):
target = key.removesuffix(".lora_A.weight")
ranks.add(tensor.shape[0])
expected = shapes.get(target)
if expected is None:
missing_targets.append(target)
elif tuple(tensor.shape[1:]) != (expected[1],):
bad_shapes.append((key, tuple(tensor.shape), expected))
elif key.endswith(".lora_B.weight"):
target = key.removesuffix(".lora_B.weight")
ranks.add(tensor.shape[1])
expected = shapes.get(target)
if expected is None:
missing_targets.append(target)
elif tuple(tensor.shape[:1]) != (expected[0],):
bad_shapes.append((key, tuple(tensor.shape), expected))
else:
missing_targets.append(key)
return {
"keys": len(state),
"target_modules": len({key.rsplit(".lora_", 1)[0] for key in state}),
"ranks": sorted(ranks),
"missing_targets": sorted(set(missing_targets)),
"bad_shapes": bad_shapes,
"valid": not missing_targets and not bad_shapes,
}
def load_flux2_pipeline(args: argparse.Namespace, dtype: torch.dtype | str, device_name: str):
from diffusers import Flux2Pipeline
if uses_4bit_model(args.model) and device_name.startswith("cuda"):
from diffusers import AutoModel
from transformers import Mistral3ForConditionalGeneration
print("Loading 4-bit FLUX.2 with local text encoder on CPU and model CPU offload.", flush=True)
text_encoder = Mistral3ForConditionalGeneration.from_pretrained(
args.model,
subfolder="text_encoder",
torch_dtype=dtype,
device_map="cpu",
local_files_only=args.local_files_only,
)
transformer = AutoModel.from_pretrained(
args.model,
subfolder="transformer",
torch_dtype=dtype,
device_map="cpu",
local_files_only=args.local_files_only,
)
pipe = Flux2Pipeline.from_pretrained(
args.model,
text_encoder=text_encoder,
transformer=transformer,
torch_dtype=dtype,
local_files_only=args.local_files_only,
)
pipe.enable_model_cpu_offload()
return pipe
load_kwargs: dict[str, Any] = {
"torch_dtype": dtype,
"local_files_only": args.local_files_only,
}
if args.device_map is not None:
load_kwargs["device_map"] = args.device_map
elif device_name.startswith("cuda"):
load_kwargs["device_map"] = device_name
pipe = Flux2Pipeline.from_pretrained(args.model, **load_kwargs)
if "device_map" not in load_kwargs:
pipe.to(device_name)
return pipe
def batched(values: list[Path], batch_size: int) -> list[list[Path]]:
return [values[index : index + batch_size] for index in range(0, len(values), batch_size)]
def discover_input_images(input_dir: Path) -> list[Path]:
return sorted(
(
path
for path in input_dir.iterdir()
if path.is_file() and path.suffix.lower() in SUPPORTED_IMAGE_SUFFIXES
),
key=lambda path: path.name.lower(),
)
def output_paths_for_inputs(args: argparse.Namespace, input_images: list[Path]) -> list[Path]:
if args.output_path is None:
return [
args.output_dir / f"{input_path.stem}-flux2-local-stylized.png"
for input_path in input_images
]
if args.output_path.suffix:
stem = args.output_path.with_suffix("")
suffix = args.output_path.suffix
return [
stem.with_name(f"{stem.name}-{index:04d}{suffix}")
for index, _input_path in enumerate(input_images, start=1)
]
return [
args.output_path / f"{input_path.stem}-flux2-local-stylized.png"
for input_path in input_images
]
def generators_for_batch(
seed: int | None,
device_name: str,
*,
start_index: int,
batch_size: int,
) -> torch.Generator | list[torch.Generator] | None:
if seed is None:
return None
if batch_size == 1:
return torch.Generator(device=device_name).manual_seed(seed + start_index)
return [
torch.Generator(device=device_name).manual_seed(seed + start_index + index)
for index in range(batch_size)
]
def run_inference(args: argparse.Namespace, lora_path: Path) -> list[Path]:
from diffusers.utils import load_image
device_name = args.device or ("cuda" if torch.cuda.is_available() else "cpu")
dtype = dtype_from_arg(args.torch_dtype)
input_paths = discover_input_images(args.input_dir.expanduser().resolve())
output_paths = output_paths_for_inputs(args, input_paths)
print(
f"Found {len(input_paths)} input image(s). Processing in batches of {args.batch_size}.",
flush=True,
)
print("Using text encoder mode: local", flush=True)
pipe = load_flux2_pipeline(args, dtype, device_name)
pipe.load_lora_weights(str(lora_path), adapter_name="aura")
pipe.set_adapters(["aura"], adapter_weights=[args.lora_scale])
for start_index, batch_paths in enumerate(batched(input_paths, args.batch_size)):
batch_offset = start_index * args.batch_size
input_images = [load_image(str(input_path)) for input_path in batch_paths]
image_arg: Any = input_images[0] if len(input_images) == 1 else input_images
prompt_arg: Any = args.prompt if len(input_images) == 1 else [args.prompt] * len(batch_paths)
call_kwargs: dict[str, Any] = {
"image": image_arg,
"height": args.height,
"width": args.width,
"num_inference_steps": args.num_inference_steps,
"guidance_scale": args.guidance_scale,
"generator": generators_for_batch(
args.seed,
device_name,
start_index=batch_offset,
batch_size=len(batch_paths),
),
"prompt": prompt_arg,
}
images = pipe(**call_kwargs).images
if len(images) != len(batch_paths):
raise RuntimeError(f"Expected {len(batch_paths)} outputs from pipeline, received {len(images)}.")
for image, output_path in zip(images, output_paths[batch_offset : batch_offset + len(images)]):
output_path.parent.mkdir(parents=True, exist_ok=True)
image.save(output_path)
return output_paths
def main() -> int:
args = parse_args()
lora_path = args.lora.expanduser().resolve()
if not lora_path.exists():
print(f"LoRA file does not exist: {lora_path}", file=sys.stderr)
return 1
if not args.check_only:
if args.input_dir is None:
print("input_dir is required unless --check-only is set.", file=sys.stderr)
return 1
if args.batch_size < 1:
print("--batch-size must be at least 1.", file=sys.stderr)
return 1
input_dir = args.input_dir.expanduser().resolve()
if not input_dir.exists():
print(f"Input directory does not exist: {input_dir}", file=sys.stderr)
return 1
if not input_dir.is_dir():
print(f"Input path is not a directory: {input_dir}", file=sys.stderr)
return 1
input_images = discover_input_images(input_dir)
if not input_images:
print(
f"No supported images found in {input_dir}. "
f"Supported extensions: {', '.join(sorted(SUPPORTED_IMAGE_SUFFIXES))}.",
file=sys.stderr,
)
return 1
output_dir = args.output_dir.expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
converted_path = (
args.converted_lora.expanduser().resolve()
if args.converted_lora
else output_dir / "pytorch_lora_weights.diffusers.safetensors"
)
try:
conversion = convert_fal_lora_to_diffusers(lora_path, converted_path)
validation = validate_converted_lora(converted_path)
except Exception as exc:
print(str(exc), file=sys.stderr)
return 1
report: dict[str, Any] = {
"model": args.model,
"text_encoder_mode": "local",
"lora": str(lora_path),
"converted_lora": str(converted_path),
"conversion": conversion,
"validation": validation,
}
print(json.dumps(report, indent=2, default=str))
if not validation["valid"]:
print("Converted LoRA did not validate against Flux2Transformer2DModel.", file=sys.stderr)
return 1
if args.check_only:
return 0
try:
preflight_environment(args)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 1
started_at = time.time()
try:
image_paths = run_inference(args, converted_path)
except Exception as exc:
print(f"Local inference failed after {time.time() - started_at:.1f}s: {exc}", file=sys.stderr)
return 1
for image_path in image_paths:
print(f"Saved local FLUX.2 edit output: {image_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())