Spaces:
Running on Zero
Running on Zero
Download app.py from Upsampler/seamless-texture: direct link, hf CLI and curl.
- Browser
- Download file 24.2 kB
-
https://huggingface.co/spaces/Upsampler/seamless-texture/resolve/main/app.py
- Command line
-
hf download hf://spaces/Upsampler/seamless-texture/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Upsampler/seamless-texture/resolve/main/app.py
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)) | |
| 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)) | |
| 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 --------------------------------------------------------------- | |
| 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) | |