""" 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)