luca115's picture
Deploy from 3dvalley spaces/seamless-texture
60d1b4f verified
Raw History Blame Contribute Delete
24.2 kB
"""
Seamless textures for 3dvalley.com with FLUX.2 klein 4B (Apache-2.0), the
distilled 4-step model, and the maps a PBR renderer needs to go with them
(maps.py). Three endpoints, one headless caller (the site). With `maps`, the
first two return the normal, height, roughness and metallic maps of the
texture from the same GPU run, so a material is one run and one reservation:
- /texture(prompt, image=None, seed, size=1024, upscale=1): a surface from a
description, or a photo restyled by a description, painted tiling. Every
denoising step runs the transformer twice, on the image as it is and
shifted by half, blended by distance from each frame's edges; the VAE
decodes with wrap-around padding. So the wrap is painted like any other
line, not patched after. `size` is the side it is painted at (1024 or
2048); `upscale` (2 or 4) then enlarges it with 4xNomosWebPhoto RealPLKSR
(CC-BY-4.0, Philip Hofmann), padded by wrapping so the tile still repeats.
- /seamless(image, seed, band, steps, reference, maps, size): a photo of a
surface made tileable while keeping its own pixels, at 1024 or 2048. Its
seams are its edges, so klein repaints a narrow strip along them and
nothing else: each edge inside a window read around it with wrap-around,
then the corner where they cross, inpainting inside the sampler (after
every step the latents outside the strip are reset to the photo's, noised
to that step's level). The painted strip is then brought to the photo's own
sharpness and colour, and the photo's pixels are put back outside it.
- /material_maps(image, directx): the maps alone, for a picture that is kept
as it is.
"""
import random
import time
import gradio as gr
import numpy as np
import spaces
import torch
import torch.nn.functional as F
from diffusers import Flux2KleinPipeline
from huggingface_hub import hf_hub_download
from PIL import Image, ImageFilter
from spandrel import ModelLoader
import maps as material
from upsampler_theme import UPSAMPLER_CSS, UPSAMPLER_THEME, footer_html, header_html
DTYPE = torch.bfloat16
DEVICE = "cuda"
SIZE = 1024
# The sides /texture paints at; klein goes up to about four megapixels.
PAINT_SIZES = (1024, 2048)
STEPS = 4
GUIDANCE = 1.0
MAX_SEED = np.iinfo(np.int32).max
REPO = "black-forest-labs/FLUX.2-klein-4B"
pipe = Flux2KleinPipeline.from_pretrained(REPO, torch_dtype=DTYPE)
pipe.to(DEVICE)
# One latent token covers this many pixels a side (VAE 8x, then 2x2 packing).
TOKEN_PX = pipe.vae_scale_factor * 2
UPSCALER_REPO = "Phips/4xNomosWebPhoto_RealPLKSR"
upscaler = ModelLoader().load_from_file(hf_hub_download(UPSCALER_REPO, "4xNomosWebPhoto_RealPLKSR.safetensors"))
upscaler.to(DEVICE).eval()
# Pixels of the opposite edge the upscaler sees past each edge.
WRAP = 32
def _log(*parts):
print("[texture]", *parts, flush=True)
def _seed(seed) -> int:
seed = int(seed) if seed is not None else -1
return random.randint(0, MAX_SEED) if seed < 0 else seed
# --- Seamless tiling (text, or photo + description) --------------------------
def _roll_tokens(tokens, height, width, dy, dx):
batch, count, channels = tokens.shape
grid = tokens.reshape(batch, height, width, channels)
return torch.roll(grid, shifts=(dy, dx), dims=(1, 2)).reshape(batch, count, channels)
def _edge_weight(size, shift, device):
"""1 in the middle of a frame shifted by `shift`, falling to 0 at its edges."""
position = (torch.arange(size, device=device) + shift) % size
distance = torch.minimum(position, size - 1 - position).float() + 0.5
return torch.clamp(distance / (size / 4), max=1.0)
class _TwoViewTiling:
def __init__(self, width, height):
self.h = int(height) // TOKEN_PX
self.w = int(width) // TOKEN_PX
def __enter__(self):
transformer = pipe.transformer
self.original = transformer.forward
h, w = self.h, self.w
dy, dx = h // 2, w // 2
device = transformer.device
straight = _edge_weight(h, 0, device)[:, None] * _edge_weight(w, 0, device)[None, :]
shifted = _edge_weight(h, dy, device)[:, None] * _edge_weight(w, dx, device)[None, :]
weight = (straight / (straight + shifted + 1e-6)).reshape(1, h * w, 1)
def forward(*args, hidden_states=None, img_ids=None, **kwargs):
n = h * w
first = self.original(*args, hidden_states=hidden_states, img_ids=img_ids, **kwargs)
reference = hidden_states[:, n:]
if reference.shape[1] > 0:
ref_ids = img_ids[0, n:] if img_ids.ndim == 3 else img_ids[n:]
rh = int(ref_ids[:, 1].max().item()) + 1
rw = int(ref_ids[:, 2].max().item()) + 1
if rh * rw == reference.shape[1]:
reference = _roll_tokens(reference, rh, rw, rh // 2, rw // 2)
moved = torch.cat([_roll_tokens(hidden_states[:, :n], h, w, dy, dx), reference], dim=1)
second = self.original(*args, hidden_states=moved, img_ids=img_ids, **kwargs)[0]
back = _roll_tokens(second[:, :n], h, w, -dy, -dx)
mix = weight.to(back.dtype)
blended = first[0][:, :n] * mix + back * (1 - mix)
return (torch.cat([blended, first[0][:, n:]], dim=1), *first[1:])
transformer.forward = forward
self.convs = [m for m in pipe.vae.decoder.modules() if isinstance(m, torch.nn.Conv2d) and max(m.padding) > 0]
for conv in self.convs:
conv.padding_mode = "circular"
return self
def __exit__(self, *exc):
pipe.transformer.forward = self.original
for conv in self.convs:
conv.padding_mode = "zeros"
return False
def _upscale_wrapped(image: Image.Image, factor: int) -> Image.Image:
"""Four times larger with the edges still meeting: the tile is padded with
its own opposite edges, enlarged, and the padding cut off. Twice is the
same, halved by averaging 2x2 blocks, which keeps the wrap exact."""
x = torch.from_numpy(np.asarray(image.convert("RGB")).copy()).to(DEVICE).permute(2, 0, 1)[None].float() / 255
x = F.pad(x, (WRAP,) * 4, mode="circular")
with torch.inference_mode():
y = upscaler(x)
cut = WRAP * upscaler.scale
y = y[..., cut:-cut, cut:-cut]
if factor < upscaler.scale:
y = F.avg_pool2d(y, upscaler.scale // factor)
out = (y[0].permute(1, 2, 0).clamp(0, 1) * 255).round().byte().cpu().numpy()
return Image.fromarray(out)
def _paint_size(size) -> int:
return int(size) if size is not None and int(size) in PAINT_SIZES else SIZE
def _maps_of(tile: Image.Image):
"""The tile's maps at the tile's size, made in the GPU run that painted it.
If the maps fail the texture still goes out, without them."""
try:
return material.compute(material.prepare(tile))
except Exception as error: # noqa: BLE001 - the colour is worth more than the reason
_log("maps failed:", repr(error))
return None
def _map_files(made):
return material.save(*made) if made else (None, None, None, None, None)
# ZeroGPU charges what is reserved, so reserve a few times what the run
# measures and no more. Measured 2026-10-05, warm: 2.6 to 3.6 s to paint at
# 1024 and about 13.4 s at 2048, 1.4 s to upscale, 1.3 to 1.8 s for the maps.
def _texture_duration(prompt, image=None, seed=-1, size=SIZE, upscale=1, maps=False):
return (12 if _paint_size(size) == SIZE else 30) + (4 if int(upscale or 1) > 1 else 0) + (6 if maps else 0)
def texture(prompt, image=None, seed=-1, size=SIZE, upscale=1, maps=False):
if not prompt or not str(prompt).strip():
raise gr.Error("Describe the surface.")
if int(upscale or 1) not in (1, 2, 4):
raise gr.Error("Upscale must be 1, 2 or 4.")
out, seed, made = _texture(str(prompt).strip(), image, _seed(seed), _paint_size(size), int(upscale or 1), bool(maps))
return (out, seed, *_map_files(made))
@spaces.GPU(duration=_texture_duration)
def _texture(prompt, image, seed, size, upscale, maps):
t0 = time.time()
kwargs = dict(
prompt=prompt,
height=size,
width=size,
num_inference_steps=STEPS,
guidance_scale=GUIDANCE,
generator=torch.Generator(device=DEVICE).manual_seed(seed),
)
if image is not None:
kwargs["image"] = [_square(image)]
with _TwoViewTiling(size, size):
out = pipe(**kwargs).images[0]
painted = time.time()
made = _maps_of(out) if maps else None
mapped = time.time()
if upscale > 1:
out = _upscale_wrapped(out, upscale)
_log("texture", size, "px in", round(painted - t0, 1), "s, maps in", round(mapped - painted, 1), "s, upscale", upscale, "in", round(time.time() - mapped, 1), "s")
return out, seed, made
# --- Seamless from a photo: inpaint along the seams ---------------------------
def _photo_side(image: Image.Image, size) -> int:
"""The side a photo is worked at: the one asked for, unless that would
enlarge the photo by more than about a third, which only makes it soft."""
side = _paint_size(size)
return side if min(image.size) * 1.35 >= side else SIZE
def _square(image: Image.Image, side: int = SIZE) -> Image.Image:
"""Centre crop to a square of `side`: a texture tile is square."""
image = image.convert("RGB")
short = min(image.size)
left, top = (image.width - short) // 2, (image.height - short) // 2
return image.crop((left, top, left + short, top + short)).resize((side, side), Image.LANCZOS)
def _even_light(image: Image.Image, strength: float = 0.6) -> Image.Image:
"""Takes out the large-scale brightness drift (one side of the photo lit
more than the other), which would otherwise repeat as a visible gradient
once the texture tiles. Detail and colour stay; only the slow trend goes."""
arr = np.asarray(image).astype(np.float32)
trend = np.asarray(image.filter(ImageFilter.GaussianBlur(image.width // 6))).astype(np.float32)
flat = arr / np.maximum(trend, 1.0) * trend.reshape(-1, 3).mean(axis=0)
out = arr * (1 - strength) + flat * strength
return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8))
def _ramp(distance: np.ndarray, half: float, feather: float) -> np.ndarray:
"""1 within `half` of the middle, falling to 0 over `feather` beyond it."""
return np.clip((half + feather - distance) / max(feather, 1e-6), 0, 1)
def _patch(height: int, width: int, band: float, length: float, feather: float) -> np.ndarray:
"""1 on a strip `band` high and `length` long through the middle of a
height × width window (a stretch of seam), soft at its sides and ends."""
across = _ramp(np.abs(np.arange(height) - (height - 1) / 2), band / 2, feather)
along = _ramp(np.abs(np.arange(width) - (width - 1) / 2), length / 2, feather)
return across[:, None] * along[None, :]
PROMPT_SEAMLESS = (
"The same surface texture continuing evenly across the whole image, with no visible lines or seams through the "
"middle; keep every material, colour, pattern and detail exactly as it is, flat and evenly lit."
)
# A seam is repainted inside a window this high, so the model sees the surface
# on both sides of it; the corner where the two seams cross, in a square this wide.
WINDOW = 512
CORNER = 768
def _like_the_photo(painted: np.ndarray, photo: np.ndarray, weight: np.ndarray) -> np.ndarray:
"""What klein paints is crisper and more saturated than a photo, which
shows as a stripe along the seam. So the painted strip is brought to the
photo's own fine detail and colour, measured on the pixels it replaces."""
region = weight > 0.5
if region.sum() < 64:
return painted
def soft(arr):
return np.asarray(Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(2))).astype(np.float32)
base = soft(painted)
detail = np.abs(painted - base)[region].mean()
wanted = np.abs(photo - soft(photo))[region].mean()
out = base + (painted - base) * float(np.clip(wanted / max(detail, 1e-3), 0.6, 1.4))
for c in range(3):
mine, theirs = out[..., c][region], photo[..., c][region]
gain = float(np.clip(theirs.std() / max(mine.std(), 1e-3), 0.75, 1.3))
out[..., c] = (out[..., c] - mine.mean()) * gain + theirs.mean()
return out
def _repaint(window: np.ndarray, arms, free: np.ndarray, band: float, seed: int, steps: int, reference: str) -> np.ndarray:
"""One window of the tile with the seam through its middle repainted by
klein, on the strip that `arms` (pixels) and `free` (tokens) mark:
inpainting inside the sampler (after every step the latents outside the
strip are reset to the photo's, noised to that step's level), then the
photo's own pixels put back outside the strip."""
height, width = window.shape[:2]
# `arms`: the strip along the seam that runs across the window, and, at the
# corner, the one along the seam that runs down it.
lying, standing = arms
weight = lying if standing is None else np.maximum(lying, standing)
image = Image.fromarray(window)
generator = torch.Generator(device=DEVICE).manual_seed(seed)
pixels = pipe.image_processor.preprocess(image, height=height, width=width).to(DEVICE, DTYPE)
known = pipe._pack_latents(pipe._encode_vae_image(pixels, generator))
noise = torch.randn(known.shape, generator=generator, device=DEVICE, dtype=DTYPE)
th, tw = height // TOKEN_PX, width // TOKEN_PX
mask = torch.from_numpy(free.astype(np.float32)).to(DEVICE, DTYPE).reshape(1, th * tw, 1)
def hold_outside(pipeline, step, timestep, kwargs):
sigma = float(pipeline.scheduler.sigmas[step + 1])
noised = (1 - sigma) * known + sigma * noise
kwargs["latents"] = kwargs["latents"] * mask + noised * (1 - mask)
return kwargs
# The model must not see the seam itself: klein copies a reference faithfully,
# seam included. "blurred" gives it the window with the strip smeared out;
# "shifted" fills the strip with the surface from beside it, moved over, so
# the model is shown stones where stones belong and not their average. It
# joined planks and a stone wall better than "blurred" and left ghosts of
# the copied marks on plain tiles (2026-10-05), so the site still asks for
# "blurred";
# "none" leaves only the latents around the strip to go on.
photo = window.astype(np.float32)
if reference == "shifted":
move = int(band + 3 * SIZE * 0.03)
a = lying[..., None]
b = 0 if standing is None else standing[..., None]
beside = (
photo * (1 - a) * (1 - b)
+ np.roll(photo, move, axis=0) * a * (1 - b)
+ np.roll(photo, move, axis=1) * b * (1 - a)
+ np.roll(photo, (move, move), axis=(0, 1)) * a * b
)
refs = [Image.fromarray(np.clip(beside, 0, 255).astype(np.uint8))]
elif reference == "blurred":
smeared = np.asarray(image.filter(ImageFilter.GaussianBlur(max(band / 2, 1)))).astype(np.float32)
hidden = weight[..., None]
refs = [Image.fromarray(np.clip(photo * (1 - hidden) + smeared * hidden, 0, 255).astype(np.uint8))]
else:
refs = [image] if reference == "rolled" else None
out = pipe(
prompt=PROMPT_SEAMLESS,
**({"image": refs} if refs else {}),
height=height,
width=width,
num_inference_steps=steps,
guidance_scale=GUIDANCE,
generator=torch.Generator(device=DEVICE).manual_seed(seed),
callback_on_step_end=hold_outside,
callback_on_step_end_tensor_inputs=["latents"],
).images[0].convert("RGB")
painted = _like_the_photo(np.asarray(out).astype(np.float32), photo, weight)
return np.clip(photo * (1 - weight[..., None]) + painted * weight[..., None], 0, 255).astype(np.uint8)
# Three windows at the 8 steps the site asks for. Measured 2026-10-05 end to
# end with the maps, warm: 14 to 16 s at 1024 and 23 to 26 s at 2048.
def _seamless_duration(image, seed, band, steps, reference, maps=False, size=SIZE):
large = image is not None and _photo_side(image, size) > SIZE
return (35 if large else 20) * (1 if int(steps or 8) <= 8 else 2) + (6 if maps else 0)
def seamless(image, seed=-1, band=0.06, steps=8, reference="blurred", maps=False, size=SIZE):
if image is None:
raise gr.Error("Add a photo of a surface.")
out, seed, made = _seamless(image, _seed(seed), band, steps, reference, bool(maps), size)
return (out, seed, *_map_files(made))
@spaces.GPU(duration=_seamless_duration)
def _seamless(image, seed, band, steps, reference, maps, size=SIZE):
t0 = time.time()
side = _photo_side(image, size)
# The strip is as wide in pixels at 2048 as at 1024 (`band` is a share of
# 1024): klein fills about 60 pixels with surface, and gives a wider strip
# back as the blur it was shown (at 2048 with twice the pixels, 2026-10-05).
band = float(np.clip(band, 0.04, 0.08)) * SIZE
steps = int(np.clip(steps, 4, 16))
feather = SIZE * 0.03
tile = np.asarray(_even_light(_square(image, side))).copy()
# The photo stays where it is; its seams are its edges, read with
# wrap-around so each is in the middle of what the model sees. First each
# edge on its own, all but the ends near the corner, in a window as long as
# the tile that starts and stops at the other seam, so that one is out of
# sight. Then the corner, where the two cross, as one piece in a square
# window: it joins what the first two painted. Repainting an edge with the
# other seam in view left that seam standing as a line (tried 2026-10-05).
# The tokens repainted are one wider on each side than the pixels kept, so
# the model has room to join both sides.
arm = CORNER / 4
length = side - 2 * arm + 2 * feather
weight = _patch(WINDOW, side, band, length, feather).astype(np.float32)
free = _patch(WINDOW // TOKEN_PX, side // TOKEN_PX, band / TOKEN_PX + 2, length / TOKEN_PX + 2, 1.5)
across = np.arange(-WINDOW // 2, WINDOW // 2) % side
for n, view in enumerate((tile, tile.transpose(1, 0, 2))):
at = np.ix_(across, np.arange(side))
view[at] = _repaint(np.ascontiguousarray(view[at]), (weight, None), free, band, seed + n, steps, reference)
lying = _patch(CORNER, CORNER, band, 2 * arm, feather).astype(np.float32)
tokens = CORNER // TOKEN_PX
one = _patch(tokens, tokens, band / TOKEN_PX + 2, 2 * arm / TOKEN_PX + 2, 1.5)
around = np.arange(-CORNER // 2, CORNER // 2) % side
at = np.ix_(around, around)
tile[at] = _repaint(np.ascontiguousarray(tile[at]), (lying, lying.T), np.maximum(one, one.T), band, seed + 2, steps, reference)
out = Image.fromarray(tile)
painted = time.time()
made = _maps_of(out) if maps else None
_log("seamless", side, "px in", round(painted - t0, 1), "s, maps in", round(time.time() - painted, 1), "s")
return out, seed, made
# --- Maps alone ---------------------------------------------------------------
@spaces.GPU(duration=10)
def _maps_only(image: Image.Image):
return material.compute(image)
def material_maps(image, directx: bool = False):
"""A picture of a surface → normal (OpenGL unless `directx`), height
(16-bit), roughness and metallic PNGs at its size (capped at 2048 px),
and a small JSON note of what the surface was taken for."""
image = material.prepare(image)
return material.save(*_maps_only(image), directx)
def _map_outputs():
with gr.Row():
outputs = [gr.Image(type="filepath", label=label, height=160) for label in ("Normal", "Height (16-bit)", "Roughness", "Metallic")]
return [*outputs, gr.JSON(label="Surface")]
with gr.Blocks(title="Seamless Texture Generator - FLUX.2 klein") as demo:
gr.HTML(header_html(
"Seamless Texture Generator",
"Tileable textures from a description, or any photo of a surface made seamless. FLUX.2 klein 4B.",
))
with gr.Tab("From a description"):
with gr.Row(equal_height=False):
with gr.Column():
t_prompt = gr.Textbox(label="Surface", placeholder="old red brick wall with worn mortar")
t_image = gr.Image(type="pil", label="Photo to restyle (optional)", height=240)
t_seed = gr.Number(value=-1, precision=0, label="Seed (-1 = random)")
t_size = gr.Radio(list(PAINT_SIZES), value=SIZE, label="Painted at (pixels)")
t_upscale = gr.Radio([1, 2, 4], value=1, label="Upscale (RealPLKSR)")
t_maps = gr.Checkbox(value=False, label="Make the maps too")
t_btn = gr.Button("Generate texture", variant="primary")
with gr.Column():
t_out = gr.Image(type="pil", label="Texture", height=420)
t_seed_out = gr.Number(label="Seed used", interactive=False)
t_map_outs = _map_outputs()
t_btn.click(texture, inputs=[t_prompt, t_image, t_seed, t_size, t_upscale, t_maps], outputs=[t_out, t_seed_out, *t_map_outs], api_name="texture")
with gr.Tab("From a photo"):
with gr.Row(equal_height=False):
with gr.Column():
s_image = gr.Image(type="pil", label="Photo of a surface", height=320)
# The slider takes what older callers send (up to 0.3); the run keeps it to 0.08.
s_band = gr.Slider(0.04, 0.3, value=0.06, step=0.01, label="Seam band (share of 1024 pixels, at most 0.08)")
s_seed = gr.Number(value=-1, precision=0, label="Seed (-1 = random)")
s_steps = gr.Slider(4, 16, value=8, step=1, label="Steps")
s_ref = gr.Radio(["blurred", "shifted", "none", "rolled"], value="blurred", label="Reference")
s_maps = gr.Checkbox(value=False, label="Make the maps too")
s_size = gr.Radio(list(PAINT_SIZES), value=SIZE, label="Size (pixels; a small photo stays at 1024)")
s_btn = gr.Button("Make seamless", variant="primary")
with gr.Column():
s_out = gr.Image(type="pil", label="Seamless texture", height=420)
s_seed_out = gr.Number(label="Seed used", interactive=False)
s_map_outs = _map_outputs()
s_btn.click(seamless, inputs=[s_image, s_seed, s_band, s_steps, s_ref, s_maps, s_size], outputs=[s_out, s_seed_out, *s_map_outs], api_name="seamless")
with gr.Tab("Maps from a picture"):
with gr.Row(equal_height=False):
with gr.Column():
m_image = gr.Image(type="pil", image_mode="RGB", label="Texture or photo of a surface", height=320)
m_directx = gr.Checkbox(value=False, label="DirectX normal map (green down, for Unreal)")
m_btn = gr.Button("Make maps", variant="primary")
with gr.Column():
m_outs = _map_outputs()
m_btn.click(material_maps, inputs=[m_image, m_directx], outputs=m_outs, api_name="material_maps")
gr.HTML(footer_html(
"Make seamless, tileable textures with FLUX.2 klein 4B: describe a surface and get a 1024 or 2048-pixel tile that "
"repeats without visible edges, with its normal, height, roughness and metallic maps, or drop a photo of brick, wood, stone or fabric and get it back seamless with "
"its own detail kept, only the seams repainted. Ready for Blender, Unity, Unreal and three.js materials.",
"https://3dvalley.com/tools/create/texture-generator",
"free seamless texture generator",
))
if __name__ == "__main__":
# gradio 6 takes `css`/`theme` on launch(), not on gr.Blocks().
# Two at a time: a run for credits (the site's token, with queue priority) then
# waits behind one free run at most, and max_size turns a flood into a
# refusal instead of a line. Each event is its own ZeroGPU reservation.
# No concurrency limit of our own: an explicit value overrides the default
# Hugging Face sets on Spaces, which runs jobs on parallel GPUs (see
# spaces/trellis-2).
demo.queue(max_size=40).launch(css=UPSAMPLER_CSS, theme=UPSAMPLER_THEME, ssr_mode=False, show_error=True, max_threads=200)