Spaces:
Running
Running
Add TransNormal-2 Gradio demo
Browse files- .gitattributes +1 -0
- LICENSE +28 -0
- NOTICE +22 -0
- README.md +35 -8
- app.py +244 -0
- examples/input/glass_vase.jpg +0 -0
- examples/input/houses_unsplash.jpg +3 -0
- requirements.txt +11 -0
- transnormal2/__init__.py +16 -0
- transnormal2/gsm.py +279 -0
- transnormal2/lcm.py +32 -0
- transnormal2/pipeline.py +288 -0
- transnormal2/utils.py +89 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
examples/input/houses_unsplash.jpg filter=lfs diff=lfs merge=lfs -text
|
LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 TransNormal-2 Authors
|
| 4 |
+
|
| 5 |
+
This work (the TransNormal-2 code and released model weights) is licensed
|
| 6 |
+
under the Creative Commons Attribution-NonCommercial 4.0 International
|
| 7 |
+
License.
|
| 8 |
+
|
| 9 |
+
You are free to:
|
| 10 |
+
- Share: copy and redistribute the material in any medium or format
|
| 11 |
+
- Adapt: remix, transform, and build upon the material
|
| 12 |
+
|
| 13 |
+
Under the following terms:
|
| 14 |
+
- Attribution: You must give appropriate credit, provide a link to the
|
| 15 |
+
license, and indicate if changes were made.
|
| 16 |
+
- NonCommercial: You may not use the material for commercial purposes.
|
| 17 |
+
|
| 18 |
+
No additional restrictions: You may not apply legal terms or technological
|
| 19 |
+
measures that legally restrict others from doing anything the license
|
| 20 |
+
permits.
|
| 21 |
+
|
| 22 |
+
Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode
|
| 23 |
+
|
| 24 |
+
NOTE: The released LoRA / LCM / GSM weights are derivatives of FLUX.2 [klein]
|
| 25 |
+
by Black Forest Labs and are additionally subject to the FLUX [dev]
|
| 26 |
+
Non-Commercial License v2.1. See the NOTICE file. The FLUX.2-klein-base-9B
|
| 27 |
+
base model is NOT distributed by this repository; download it from Black
|
| 28 |
+
Forest Labs under their license terms.
|
NOTICE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
NOTICE
|
| 2 |
+
|
| 3 |
+
The TransNormal-2 released weights (core-predictor LoRA, Local Continuity
|
| 4 |
+
Module, and Geometric Sharpening Module) are "Derivatives" of:
|
| 5 |
+
|
| 6 |
+
FLUX.2 [klein] (black-forest-labs/FLUX.2-klein-base-9B)
|
| 7 |
+
Copyright (c) Black Forest Labs Inc.
|
| 8 |
+
|
| 9 |
+
and are distributed in accordance with the FLUX [dev] Non-Commercial License
|
| 10 |
+
v2.1 (https://bfl.ai/legal/non-commercial-license):
|
| 11 |
+
|
| 12 |
+
1. The weights in this distribution are a MODIFIED derivative work of the
|
| 13 |
+
FLUX.2 [klein] base model (LoRA adaptation plus auxiliary heads trained
|
| 14 |
+
for surface normal estimation). They are not the original model released
|
| 15 |
+
by Black Forest Labs.
|
| 16 |
+
2. Use is limited to NON-COMMERCIAL purposes, as defined by the FLUX [dev]
|
| 17 |
+
Non-Commercial License.
|
| 18 |
+
3. The FLUX.2 [klein] base model itself is not redistributed here. Obtain
|
| 19 |
+
it directly from Black Forest Labs / Hugging Face under their license.
|
| 20 |
+
|
| 21 |
+
This repository's own code and weights are otherwise licensed under
|
| 22 |
+
CC BY-NC 4.0 (see LICENSE).
|
README.md
CHANGED
|
@@ -1,13 +1,40 @@
|
|
| 1 |
---
|
| 2 |
-
title: TransNormal
|
| 3 |
-
emoji: 🦀
|
| 4 |
-
colorFrom: blue
|
| 5 |
-
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: TransNormal-2 Demo
|
|
|
|
|
|
|
|
|
|
| 3 |
sdk: gradio
|
| 4 |
+
sdk_version: 5.49.1
|
|
|
|
| 5 |
app_file: app.py
|
| 6 |
+
python_version: 3.10.13
|
| 7 |
+
startup_duration_timeout: 1h
|
| 8 |
+
models:
|
| 9 |
+
- Longxiang-ai/TransNormal-2
|
| 10 |
+
- black-forest-labs/FLUX.2-klein-base-9B
|
| 11 |
+
tags:
|
| 12 |
+
- computer-vision
|
| 13 |
+
- monocular-normal-estimation
|
| 14 |
+
- surface-normal-estimation
|
| 15 |
+
- gradio
|
| 16 |
+
- zerogpu
|
| 17 |
+
license: cc-by-nc-4.0
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# TransNormal-2 Demo
|
| 21 |
+
|
| 22 |
+
Interactive Gradio demo for TransNormal-2 surface normal estimation.
|
| 23 |
+
|
| 24 |
+
Upload an RGB image, choose **Opaque** or **Transparent** scene mode, and run
|
| 25 |
+
the one-step FLUX.2 [klein] predictor followed by the Geometric Sharpening
|
| 26 |
+
Module (GSM).
|
| 27 |
+
|
| 28 |
+
## Runtime Notes
|
| 29 |
+
|
| 30 |
+
- This Space is intended to run on Hugging Face ZeroGPU first.
|
| 31 |
+
- Use ZeroGPU `xlarge` if available. The FLUX.2 [klein] 9B base model is large.
|
| 32 |
+
- Set `HF_TOKEN` in Space secrets after accepting the FLUX.2 [klein] license.
|
| 33 |
+
- First launch can take several minutes because the base model and TransNormal-2
|
| 34 |
+
weights need to be downloaded and cached.
|
| 35 |
+
|
| 36 |
+
## Links
|
| 37 |
+
|
| 38 |
+
- Project page: https://longxiang-ai.github.io/TransNormal-2
|
| 39 |
+
- Code and README: https://github.com/longxiang-ai/TransNormal-2
|
| 40 |
+
- Model weights: https://huggingface.co/Longxiang-ai/TransNormal-2
|
app.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
try:
|
| 2 |
+
import spaces # type: ignore
|
| 3 |
+
except ImportError: # Local runs do not require the HF Spaces helper package.
|
| 4 |
+
class _SpacesFallback:
|
| 5 |
+
@staticmethod
|
| 6 |
+
def GPU(func=None, **_kwargs):
|
| 7 |
+
if func is None:
|
| 8 |
+
return lambda wrapped: wrapped
|
| 9 |
+
return func
|
| 10 |
+
|
| 11 |
+
spaces = _SpacesFallback()
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import os
|
| 15 |
+
import time
|
| 16 |
+
from typing import Optional, Tuple
|
| 17 |
+
|
| 18 |
+
import gradio as gr
|
| 19 |
+
import numpy as np
|
| 20 |
+
import torch
|
| 21 |
+
from PIL import Image, ImageOps
|
| 22 |
+
|
| 23 |
+
from transnormal2 import TransNormal2Pipeline
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
DEFAULT_BASE_MODEL = "black-forest-labs/FLUX.2-klein-base-9B"
|
| 27 |
+
DEFAULT_WEIGHTS = "Longxiang-ai/TransNormal-2"
|
| 28 |
+
IMAGE_EXAMPLES = [
|
| 29 |
+
["examples/input/houses_unsplash.jpg", "Opaque", 768, False],
|
| 30 |
+
["examples/input/glass_vase.jpg", "Transparent", 768, False],
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
PIPELINE = None
|
| 34 |
+
APP_ARGS = None
|
| 35 |
+
LOAD_ERROR: Optional[str] = None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def parse_args():
|
| 39 |
+
parser = argparse.ArgumentParser(description="TransNormal-2 Gradio demo")
|
| 40 |
+
parser.add_argument("--weights", type=str, default=os.getenv("TRANSNORMAL2_WEIGHTS", DEFAULT_WEIGHTS))
|
| 41 |
+
parser.add_argument("--base_model", type=str, default=os.getenv("TRANSNORMAL2_BASE_MODEL", DEFAULT_BASE_MODEL))
|
| 42 |
+
parser.add_argument("--server_name", type=str, default=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"))
|
| 43 |
+
parser.add_argument("--server_port", type=int, default=int(os.getenv("GRADIO_SERVER_PORT", "7860")))
|
| 44 |
+
parser.add_argument("--share", action="store_true", default=os.getenv("GRADIO_SHARE", "0") == "1")
|
| 45 |
+
parser.add_argument("--cpu_offload", action="store_true", default=os.getenv("TRANSNORMAL2_CPU_OFFLOAD", "0") == "1")
|
| 46 |
+
parser.add_argument("--preload", action="store_true", default=os.getenv("TRANSNORMAL2_PRELOAD", "1") == "1")
|
| 47 |
+
parser.add_argument("--device", type=str, default=os.getenv("TRANSNORMAL2_DEVICE", "cuda"))
|
| 48 |
+
parser.add_argument("--dtype", choices=["bf16", "fp32"], default=os.getenv("TRANSNORMAL2_DTYPE", "bf16"))
|
| 49 |
+
return parser.parse_args()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def resolve_weights(weights: str) -> str:
|
| 53 |
+
if os.path.isdir(weights):
|
| 54 |
+
return weights
|
| 55 |
+
from huggingface_hub import snapshot_download
|
| 56 |
+
|
| 57 |
+
return snapshot_download(repo_id=weights, token=os.getenv("HF_TOKEN"))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def get_pipeline() -> TransNormal2Pipeline:
|
| 61 |
+
global PIPELINE
|
| 62 |
+
if PIPELINE is not None:
|
| 63 |
+
return PIPELINE
|
| 64 |
+
if APP_ARGS is None:
|
| 65 |
+
raise RuntimeError("Application arguments are not initialized.")
|
| 66 |
+
|
| 67 |
+
dtype = torch.bfloat16 if APP_ARGS.dtype == "bf16" else torch.float32
|
| 68 |
+
weights_dir = resolve_weights(APP_ARGS.weights)
|
| 69 |
+
device = None if APP_ARGS.cpu_offload else APP_ARGS.device
|
| 70 |
+
|
| 71 |
+
pipe = TransNormal2Pipeline.from_pretrained_transnormal2(
|
| 72 |
+
base_model=APP_ARGS.base_model,
|
| 73 |
+
weights_dir=weights_dir,
|
| 74 |
+
torch_dtype=dtype,
|
| 75 |
+
device=device,
|
| 76 |
+
)
|
| 77 |
+
pipe.set_progress_bar_config(disable=True)
|
| 78 |
+
if APP_ARGS.cpu_offload:
|
| 79 |
+
pipe.enable_model_cpu_offload(device=APP_ARGS.device)
|
| 80 |
+
pipe.local_continuity_module.to(APP_ARGS.device)
|
| 81 |
+
pipe.gsm.to(APP_ARGS.device)
|
| 82 |
+
|
| 83 |
+
PIPELINE = pipe
|
| 84 |
+
return PIPELINE
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def pil_to_tensor(image: Image.Image) -> torch.Tensor:
|
| 88 |
+
image = ImageOps.exif_transpose(image).convert("RGB")
|
| 89 |
+
arr = np.asarray(image).astype(np.float32)
|
| 90 |
+
tensor = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0)
|
| 91 |
+
return tensor / 127.5 - 1.0
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def normal_to_pil(normal: torch.Tensor) -> Image.Image:
|
| 95 |
+
arr = normal.detach().float().clamp(0, 1)[0].permute(1, 2, 0).cpu().numpy()
|
| 96 |
+
return Image.fromarray((arr * 255).round().astype(np.uint8))
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def process_res_from_choice(choice) -> Optional[int]:
|
| 100 |
+
if isinstance(choice, str) and choice.lower() == "native":
|
| 101 |
+
return None
|
| 102 |
+
return int(choice)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def is_transparent_mode(mode: str) -> bool:
|
| 106 |
+
return mode == "Transparent"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _gpu_kwargs():
|
| 110 |
+
kwargs = {
|
| 111 |
+
"duration": int(os.getenv("TRANSNORMAL2_GPU_DURATION", "240")),
|
| 112 |
+
}
|
| 113 |
+
size = os.getenv("TRANSNORMAL2_ZERO_GPU_SIZE", "xlarge")
|
| 114 |
+
if size:
|
| 115 |
+
kwargs["size"] = size
|
| 116 |
+
return kwargs
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _zero_gpu_decorator(func):
|
| 120 |
+
kwargs = _gpu_kwargs()
|
| 121 |
+
try:
|
| 122 |
+
return spaces.GPU(**kwargs)(func)
|
| 123 |
+
except TypeError:
|
| 124 |
+
kwargs.pop("size", None)
|
| 125 |
+
return spaces.GPU(**kwargs)(func)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@_zero_gpu_decorator
|
| 129 |
+
def predict(
|
| 130 |
+
image: Image.Image,
|
| 131 |
+
scene_mode: str,
|
| 132 |
+
max_edge,
|
| 133 |
+
show_raw_prediction: bool,
|
| 134 |
+
) -> Tuple[Optional[Image.Image], Optional[Image.Image], str]:
|
| 135 |
+
if image is None:
|
| 136 |
+
return None, None, "Upload an RGB image first."
|
| 137 |
+
|
| 138 |
+
start = time.time()
|
| 139 |
+
pipe = get_pipeline()
|
| 140 |
+
image_tensor = pil_to_tensor(image)
|
| 141 |
+
process_res = process_res_from_choice(max_edge)
|
| 142 |
+
domain_is_transparent = is_transparent_mode(scene_mode)
|
| 143 |
+
|
| 144 |
+
normal = pipe(
|
| 145 |
+
image_tensor,
|
| 146 |
+
domain_is_transparent=domain_is_transparent,
|
| 147 |
+
process_res=process_res,
|
| 148 |
+
apply_gsm=True,
|
| 149 |
+
output_type="pt",
|
| 150 |
+
)
|
| 151 |
+
final_image = normal_to_pil(normal)
|
| 152 |
+
|
| 153 |
+
raw_image = None
|
| 154 |
+
if show_raw_prediction:
|
| 155 |
+
raw_normal = pipe(
|
| 156 |
+
image_tensor,
|
| 157 |
+
domain_is_transparent=domain_is_transparent,
|
| 158 |
+
process_res=process_res,
|
| 159 |
+
apply_gsm=False,
|
| 160 |
+
output_type="pt",
|
| 161 |
+
)
|
| 162 |
+
raw_image = normal_to_pil(raw_normal)
|
| 163 |
+
|
| 164 |
+
if APP_ARGS and APP_ARGS.device.startswith("cuda") and torch.cuda.is_available():
|
| 165 |
+
torch.cuda.synchronize()
|
| 166 |
+
|
| 167 |
+
mode_note = scene_mode
|
| 168 |
+
if scene_mode == "Auto":
|
| 169 |
+
mode_note = "Auto (uses opaque anchoring by default; choose Transparent for glass/liquids)"
|
| 170 |
+
elapsed = time.time() - start
|
| 171 |
+
status = f"Done in {elapsed:.2f}s. Mode: {mode_note}. Output is camera-space normal encoded as RGB."
|
| 172 |
+
return final_image, raw_image, status
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def build_demo() -> gr.Blocks:
|
| 176 |
+
with gr.Blocks(title="TransNormal-2 Demo") as demo:
|
| 177 |
+
gr.Markdown(
|
| 178 |
+
"""
|
| 179 |
+
# TransNormal-2
|
| 180 |
+
Upload an RGB image to estimate a camera-space surface normal map.
|
| 181 |
+
Use **Transparent** for glass, liquids, or transparent-object scenes.
|
| 182 |
+
"""
|
| 183 |
+
)
|
| 184 |
+
with gr.Row():
|
| 185 |
+
with gr.Column(scale=1):
|
| 186 |
+
input_image = gr.Image(label="Input RGB", type="pil")
|
| 187 |
+
scene_mode = gr.Radio(
|
| 188 |
+
choices=["Opaque", "Transparent", "Auto"],
|
| 189 |
+
value="Opaque",
|
| 190 |
+
label="Scene mode",
|
| 191 |
+
)
|
| 192 |
+
max_edge = gr.Dropdown(
|
| 193 |
+
choices=[576, 768, 1024, "Native"],
|
| 194 |
+
value=768,
|
| 195 |
+
label="Max processing edge",
|
| 196 |
+
)
|
| 197 |
+
show_raw = gr.Checkbox(
|
| 198 |
+
value=False,
|
| 199 |
+
label="Also show raw prediction before GSM",
|
| 200 |
+
)
|
| 201 |
+
run_btn = gr.Button("Run TransNormal-2", variant="primary")
|
| 202 |
+
with gr.Column(scale=1):
|
| 203 |
+
final_output = gr.Image(label="Predicted normal (GSM refined)", type="pil")
|
| 204 |
+
raw_output = gr.Image(label="Raw prediction before GSM", type="pil")
|
| 205 |
+
status = gr.Textbox(label="Status", interactive=False)
|
| 206 |
+
|
| 207 |
+
gr.Examples(
|
| 208 |
+
examples=[e for e in IMAGE_EXAMPLES if os.path.exists(e[0])],
|
| 209 |
+
inputs=[input_image, scene_mode, max_edge, show_raw],
|
| 210 |
+
)
|
| 211 |
+
gr.Markdown(
|
| 212 |
+
"""
|
| 213 |
+
The normal map is encoded as `(n + 1) / 2`: X in red, Y in green, Z in blue.
|
| 214 |
+
First launch on Hugging Face Spaces may take several minutes because FLUX.2 [klein] is large.
|
| 215 |
+
"""
|
| 216 |
+
)
|
| 217 |
+
run_btn.click(
|
| 218 |
+
fn=predict,
|
| 219 |
+
inputs=[input_image, scene_mode, max_edge, show_raw],
|
| 220 |
+
outputs=[final_output, raw_output, status],
|
| 221 |
+
)
|
| 222 |
+
return demo
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def main():
|
| 226 |
+
global APP_ARGS, LOAD_ERROR
|
| 227 |
+
APP_ARGS = parse_args()
|
| 228 |
+
if APP_ARGS.preload:
|
| 229 |
+
try:
|
| 230 |
+
get_pipeline()
|
| 231 |
+
except Exception as exc: # Keep the UI visible and retry on first request.
|
| 232 |
+
LOAD_ERROR = str(exc)
|
| 233 |
+
print(f"[TransNormal-2 demo] Preload failed, will retry on demand: {LOAD_ERROR}")
|
| 234 |
+
|
| 235 |
+
demo = build_demo()
|
| 236 |
+
demo.queue(max_size=8).launch(
|
| 237 |
+
server_name=APP_ARGS.server_name,
|
| 238 |
+
server_port=APP_ARGS.server_port,
|
| 239 |
+
share=APP_ARGS.share,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
if __name__ == "__main__":
|
| 244 |
+
main()
|
examples/input/glass_vase.jpg
ADDED
|
examples/input/houses_unsplash.jpg
ADDED
|
Git LFS Details
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.5
|
| 2 |
+
diffusers>=0.37.1
|
| 3 |
+
transformers>=4.55
|
| 4 |
+
peft>=0.17
|
| 5 |
+
safetensors>=0.4
|
| 6 |
+
accelerate>=1.0
|
| 7 |
+
huggingface_hub>=0.34
|
| 8 |
+
numpy
|
| 9 |
+
Pillow
|
| 10 |
+
gradio>=5.0
|
| 11 |
+
spaces
|
transnormal2/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TransNormal-2: one-step rectified-flow surface normal estimation."""
|
| 2 |
+
|
| 3 |
+
from .gsm import GSM
|
| 4 |
+
from .lcm import LocalContinuityModule
|
| 5 |
+
from .pipeline import TransNormal2Pipeline
|
| 6 |
+
from .utils import load_image, save_normal_map
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"GSM",
|
| 10 |
+
"LocalContinuityModule",
|
| 11 |
+
"TransNormal2Pipeline",
|
| 12 |
+
"load_image",
|
| 13 |
+
"save_normal_map",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
__version__ = "1.0.0"
|
transnormal2/gsm.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Geometric Sharpening Module (GSM).
|
| 2 |
+
|
| 3 |
+
A lightweight (~0.4M param) edge-aware refinement head applied to the
|
| 4 |
+
VAE-decoded normal map. The VAE encoder/decoder bottleneck (8x downsample)
|
| 5 |
+
inherently blurs sharp geometric edges; the GSM compensates by predicting a
|
| 6 |
+
small residual on top of an explicit anchor:
|
| 7 |
+
|
| 8 |
+
- opaque scenes use a full-RGB guided-filter anchor (He et al. 2010);
|
| 9 |
+
- transparent scenes use the identity anchor (no RGB-guided smoothing, since
|
| 10 |
+
RGB edges behind glass do not correspond to geometry edges).
|
| 11 |
+
|
| 12 |
+
The residual head is zero-initialized at training time, so the module starts
|
| 13 |
+
exactly at the anchor. At inference the residual is scaled by
|
| 14 |
+
``residual_scale`` and clipped to ``residual_clip_deg`` degrees before the
|
| 15 |
+
final L2 normalization.
|
| 16 |
+
|
| 17 |
+
This file keeps only the inference path of the released model (the
|
| 18 |
+
``classical_anchor`` architecture); training losses and unused architecture
|
| 19 |
+
variants are intentionally not part of the release. State-dict keys are fully
|
| 20 |
+
compatible with the research checkpoints.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from typing import List, Optional, Union
|
| 24 |
+
|
| 25 |
+
import torch
|
| 26 |
+
import torch.nn as nn
|
| 27 |
+
import torch.nn.functional as F
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ResBlock(nn.Module):
|
| 31 |
+
"""Residual conv block with optional dilation."""
|
| 32 |
+
|
| 33 |
+
def __init__(self, channels: int, dilation: int = 1):
|
| 34 |
+
super().__init__()
|
| 35 |
+
self.conv1 = nn.Conv2d(channels, channels, 3, padding=dilation, dilation=dilation)
|
| 36 |
+
self.conv2 = nn.Conv2d(channels, channels, 3, padding=dilation, dilation=dilation)
|
| 37 |
+
self.norm1 = nn.GroupNorm(8, channels)
|
| 38 |
+
self.norm2 = nn.GroupNorm(8, channels)
|
| 39 |
+
self.act = nn.GELU()
|
| 40 |
+
|
| 41 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 42 |
+
residual = x
|
| 43 |
+
out = self.act(self.norm1(self.conv1(x)))
|
| 44 |
+
out = self.norm2(self.conv2(out))
|
| 45 |
+
return self.act(out + residual)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _build_edge_encoder(edge_channels: int) -> nn.Sequential:
|
| 49 |
+
return nn.Sequential(
|
| 50 |
+
nn.Conv2d(3, edge_channels, 3, padding=1),
|
| 51 |
+
nn.GELU(),
|
| 52 |
+
nn.Conv2d(edge_channels, edge_channels, 3, padding=1),
|
| 53 |
+
nn.GELU(),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _resolve_dilations(num_blocks: int, dilations: Optional[List[int]]) -> List[int]:
|
| 58 |
+
if dilations is None or len(dilations) == 0:
|
| 59 |
+
return [1] * num_blocks
|
| 60 |
+
if len(dilations) != num_blocks:
|
| 61 |
+
raise ValueError(
|
| 62 |
+
f"dilations length ({len(dilations)}) must equal num_blocks ({num_blocks})"
|
| 63 |
+
)
|
| 64 |
+
return [int(d) for d in dilations]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _box_mean(x: torch.Tensor, radius: int) -> torch.Tensor:
|
| 68 |
+
kernel = 2 * int(radius) + 1
|
| 69 |
+
return F.avg_pool2d(
|
| 70 |
+
x.float(), kernel_size=kernel, stride=1, padding=int(radius),
|
| 71 |
+
count_include_pad=False,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def guided_filter_torch(
|
| 76 |
+
coarse_normal: torch.Tensor,
|
| 77 |
+
rgb: torch.Tensor,
|
| 78 |
+
radius: int = 4,
|
| 79 |
+
eps: float = 0.01,
|
| 80 |
+
) -> torch.Tensor:
|
| 81 |
+
"""Full-RGB guided filter anchor for normal maps.
|
| 82 |
+
|
| 83 |
+
Follows the multi-channel guided-filter solve used by OpenCV's ximgproc
|
| 84 |
+
guidedFilter: a local linear model maps RGB guide values to each normal
|
| 85 |
+
channel, then the filtered normal is L2-normalized.
|
| 86 |
+
"""
|
| 87 |
+
if coarse_normal.shape[1] != 3 or rgb.shape[1] != 3:
|
| 88 |
+
raise ValueError("guided_filter_torch expects 3-channel normal and RGB")
|
| 89 |
+
|
| 90 |
+
out_dtype = coarse_normal.dtype
|
| 91 |
+
I = rgb.float()
|
| 92 |
+
p = coarse_normal.float()
|
| 93 |
+
B, p_ch, H, W = p.shape
|
| 94 |
+
i_ch = I.shape[1]
|
| 95 |
+
|
| 96 |
+
mean_I = _box_mean(I, radius)
|
| 97 |
+
mean_p = _box_mean(p, radius)
|
| 98 |
+
|
| 99 |
+
cov_Ip = []
|
| 100 |
+
for pc in range(p_ch):
|
| 101 |
+
row = []
|
| 102 |
+
for ic in range(i_ch):
|
| 103 |
+
mean_Ip = _box_mean(I[:, ic:ic + 1] * p[:, pc:pc + 1], radius)
|
| 104 |
+
row.append(mean_Ip - mean_I[:, ic:ic + 1] * mean_p[:, pc:pc + 1])
|
| 105 |
+
cov_Ip.append(torch.cat(row, dim=1))
|
| 106 |
+
cov_Ip = torch.stack(cov_Ip, dim=1) # (B, 3 normal, 3 rgb, H, W)
|
| 107 |
+
|
| 108 |
+
var_rows = []
|
| 109 |
+
for i in range(i_ch):
|
| 110 |
+
cols = []
|
| 111 |
+
for j in range(i_ch):
|
| 112 |
+
mean_II = _box_mean(I[:, i:i + 1] * I[:, j:j + 1], radius)
|
| 113 |
+
cols.append(mean_II - mean_I[:, i:i + 1] * mean_I[:, j:j + 1])
|
| 114 |
+
var_rows.append(torch.cat(cols, dim=1))
|
| 115 |
+
var_I = torch.stack(var_rows, dim=1) # (B, 3 row, 3 col, H, W)
|
| 116 |
+
|
| 117 |
+
sigma = var_I.permute(0, 3, 4, 1, 2).reshape(-1, i_ch, i_ch)
|
| 118 |
+
eye = torch.eye(i_ch, device=sigma.device, dtype=sigma.dtype).unsqueeze(0)
|
| 119 |
+
sigma = sigma + float(eps) * eye
|
| 120 |
+
rhs = cov_Ip.permute(0, 3, 4, 2, 1).reshape(-1, i_ch, p_ch)
|
| 121 |
+
|
| 122 |
+
coeff = torch.linalg.solve(sigma, rhs)
|
| 123 |
+
a = coeff.reshape(B, H, W, i_ch, p_ch).permute(0, 4, 3, 1, 2)
|
| 124 |
+
b = mean_p - (a * mean_I.unsqueeze(1)).sum(dim=2)
|
| 125 |
+
|
| 126 |
+
mean_a = _box_mean(a.reshape(B, p_ch * i_ch, H, W), radius)
|
| 127 |
+
mean_a = mean_a.reshape(B, p_ch, i_ch, H, W)
|
| 128 |
+
mean_b = _box_mean(b, radius)
|
| 129 |
+
q = (mean_a * I.unsqueeze(1)).sum(dim=2) + mean_b
|
| 130 |
+
q = F.normalize(q, p=2, dim=1)
|
| 131 |
+
return q.to(dtype=out_dtype)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _domain_mask(
|
| 135 |
+
domain_is_transparent: Optional[Union[bool, torch.Tensor]],
|
| 136 |
+
coarse_normal: torch.Tensor,
|
| 137 |
+
) -> torch.Tensor:
|
| 138 |
+
B, _, H, W = coarse_normal.shape
|
| 139 |
+
if domain_is_transparent is None:
|
| 140 |
+
value = torch.zeros(B, 1, 1, 1, device=coarse_normal.device, dtype=coarse_normal.dtype)
|
| 141 |
+
elif isinstance(domain_is_transparent, bool):
|
| 142 |
+
value = torch.full(
|
| 143 |
+
(B, 1, 1, 1), float(domain_is_transparent),
|
| 144 |
+
device=coarse_normal.device, dtype=coarse_normal.dtype,
|
| 145 |
+
)
|
| 146 |
+
else:
|
| 147 |
+
value = domain_is_transparent.to(device=coarse_normal.device, dtype=coarse_normal.dtype)
|
| 148 |
+
if value.ndim == 0:
|
| 149 |
+
value = value.view(1, 1, 1, 1).expand(B, 1, 1, 1)
|
| 150 |
+
elif value.ndim in (1, 2):
|
| 151 |
+
value = value.view(B, 1, 1, 1)
|
| 152 |
+
elif value.ndim == 3:
|
| 153 |
+
value = value.unsqueeze(1)
|
| 154 |
+
elif value.ndim == 4:
|
| 155 |
+
value = value[:, :1]
|
| 156 |
+
else:
|
| 157 |
+
raise ValueError(f"Unsupported domain flag shape: {tuple(value.shape)}")
|
| 158 |
+
return value.expand(B, 1, H, W)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
class GSM(nn.Module):
|
| 162 |
+
"""Geometric Sharpening Module (release inference path).
|
| 163 |
+
|
| 164 |
+
Interface: ``refined = gsm(coarse_normal, rgb, domain_is_transparent)``
|
| 165 |
+
where ``coarse_normal`` is the VAE-decoded normal map in [-1, 1] and
|
| 166 |
+
``rgb`` is the input image in [-1, 1], both (B, 3, H, W). The output is a
|
| 167 |
+
unit-normalized normal map in [-1, 1].
|
| 168 |
+
|
| 169 |
+
Args mirror the released checkpoint configuration (see weights
|
| 170 |
+
``config.json``); state-dict keys match the research checkpoints.
|
| 171 |
+
"""
|
| 172 |
+
|
| 173 |
+
def __init__(
|
| 174 |
+
self,
|
| 175 |
+
hidden_channels: int = 64,
|
| 176 |
+
edge_channels: int = 32,
|
| 177 |
+
num_blocks: int = 4,
|
| 178 |
+
use_rgb: bool = True,
|
| 179 |
+
dilations: Optional[List[int]] = None,
|
| 180 |
+
anchor_radius: int = 4,
|
| 181 |
+
anchor_eps: float = 0.01,
|
| 182 |
+
residual_scale: float = 1.0,
|
| 183 |
+
residual_clip_deg: float = 0.0,
|
| 184 |
+
initial_alpha: float = 0.5,
|
| 185 |
+
):
|
| 186 |
+
super().__init__()
|
| 187 |
+
self.use_rgb = bool(use_rgb)
|
| 188 |
+
self.dilations = _resolve_dilations(num_blocks, dilations)
|
| 189 |
+
self.anchor_radius = int(anchor_radius)
|
| 190 |
+
self.anchor_eps = float(anchor_eps)
|
| 191 |
+
self.residual_scale = float(residual_scale)
|
| 192 |
+
self.residual_clip_deg = float(residual_clip_deg)
|
| 193 |
+
|
| 194 |
+
if self.use_rgb:
|
| 195 |
+
self.edge_encoder = _build_edge_encoder(edge_channels)
|
| 196 |
+
main_in_ch = 10 # coarse normal + anchor normal + RGB + domain flag
|
| 197 |
+
combined_ch = hidden_channels + edge_channels
|
| 198 |
+
else:
|
| 199 |
+
self.edge_encoder = None
|
| 200 |
+
main_in_ch = 7 # coarse normal + anchor normal + domain flag
|
| 201 |
+
combined_ch = hidden_channels
|
| 202 |
+
|
| 203 |
+
self.main_encoder = nn.Sequential(
|
| 204 |
+
nn.Conv2d(main_in_ch, hidden_channels, 3, padding=1),
|
| 205 |
+
nn.GELU(),
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
self.blocks = nn.ModuleList()
|
| 209 |
+
self.projections = nn.ModuleList()
|
| 210 |
+
for d in self.dilations:
|
| 211 |
+
self.projections.append(nn.Conv2d(combined_ch, hidden_channels, 1))
|
| 212 |
+
self.blocks.append(ResBlock(hidden_channels, dilation=d))
|
| 213 |
+
|
| 214 |
+
self.feature_proj = nn.Sequential(
|
| 215 |
+
nn.Conv2d(combined_ch, hidden_channels, 3, padding=1),
|
| 216 |
+
nn.GELU(),
|
| 217 |
+
)
|
| 218 |
+
self.delta_head = nn.Conv2d(hidden_channels, 3, 3, padding=1)
|
| 219 |
+
self.alpha_head = nn.Conv2d(hidden_channels, 1, 1)
|
| 220 |
+
|
| 221 |
+
# Zero-init residual head so an untrained module returns the anchor.
|
| 222 |
+
nn.init.zeros_(self.delta_head.weight)
|
| 223 |
+
nn.init.zeros_(self.delta_head.bias)
|
| 224 |
+
init_alpha = min(max(float(initial_alpha), 1e-4), 1.0 - 1e-4)
|
| 225 |
+
nn.init.zeros_(self.alpha_head.weight)
|
| 226 |
+
nn.init.constant_(self.alpha_head.bias, torch.logit(torch.tensor(init_alpha)).item())
|
| 227 |
+
|
| 228 |
+
@torch.no_grad()
|
| 229 |
+
def forward(
|
| 230 |
+
self,
|
| 231 |
+
coarse_normal: torch.Tensor,
|
| 232 |
+
rgb: Optional[torch.Tensor] = None,
|
| 233 |
+
domain_is_transparent: Optional[Union[bool, torch.Tensor]] = None,
|
| 234 |
+
) -> torch.Tensor:
|
| 235 |
+
if self.use_rgb and rgb is None:
|
| 236 |
+
raise ValueError("GSM requires the RGB input when use_rgb=True.")
|
| 237 |
+
|
| 238 |
+
transparent_anchor = F.normalize(coarse_normal.float(), p=2, dim=1).to(coarse_normal.dtype)
|
| 239 |
+
domain = _domain_mask(domain_is_transparent, coarse_normal)
|
| 240 |
+
if self.use_rgb:
|
| 241 |
+
opaque_anchor = guided_filter_torch(
|
| 242 |
+
coarse_normal, rgb, radius=self.anchor_radius, eps=self.anchor_eps
|
| 243 |
+
)
|
| 244 |
+
anchor = torch.where(domain > 0.5, transparent_anchor, opaque_anchor)
|
| 245 |
+
else:
|
| 246 |
+
anchor = transparent_anchor
|
| 247 |
+
|
| 248 |
+
edge_feat = self.edge_encoder(rgb) if self.use_rgb else None
|
| 249 |
+
if self.use_rgb:
|
| 250 |
+
x_in = torch.cat([coarse_normal, anchor, rgb, domain], dim=1)
|
| 251 |
+
else:
|
| 252 |
+
x_in = torch.cat([coarse_normal, anchor, domain], dim=1)
|
| 253 |
+
x = self.main_encoder(x_in)
|
| 254 |
+
|
| 255 |
+
for proj, block in zip(self.projections, self.blocks):
|
| 256 |
+
x = proj(torch.cat([x, edge_feat], dim=1) if edge_feat is not None else x)
|
| 257 |
+
x = block(x)
|
| 258 |
+
|
| 259 |
+
feat = self.feature_proj(torch.cat([x, edge_feat], dim=1) if edge_feat is not None else x)
|
| 260 |
+
delta = self.delta_head(feat)
|
| 261 |
+
alpha = torch.sigmoid(self.alpha_head(feat))
|
| 262 |
+
residual = self.residual_scale * alpha * delta
|
| 263 |
+
|
| 264 |
+
if self.residual_clip_deg > 0:
|
| 265 |
+
# Clip the tangent-plane magnitude of the residual to a max angle.
|
| 266 |
+
max_norm = torch.tan(torch.deg2rad(torch.tensor(
|
| 267 |
+
self.residual_clip_deg, device=residual.device, dtype=torch.float32
|
| 268 |
+
)))
|
| 269 |
+
residual_norm = torch.linalg.vector_norm(
|
| 270 |
+
residual.float(), ord=2, dim=1, keepdim=True
|
| 271 |
+
).clamp_min(1e-6)
|
| 272 |
+
clip = (max_norm / residual_norm).clamp(max=1.0).to(residual.dtype)
|
| 273 |
+
residual = residual * clip
|
| 274 |
+
|
| 275 |
+
refined = F.normalize(anchor + residual, p=2, dim=1)
|
| 276 |
+
return refined
|
| 277 |
+
|
| 278 |
+
def count_parameters(self) -> int:
|
| 279 |
+
return sum(p.numel() for p in self.parameters())
|
transnormal2/lcm.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local Continuity Module (LCM).
|
| 2 |
+
|
| 3 |
+
A small residual convolutional head applied to the predicted normal latent
|
| 4 |
+
before VAE decoding. It enforces local smoothness in latent space and is
|
| 5 |
+
trained jointly with the core predictor LoRA.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn as nn
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class LocalContinuityModule(nn.Module):
|
| 13 |
+
"""Residual 2-layer conv head operating on raw VAE latents.
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
num_channels: Latent channel count (``transformer.in_channels // 4``,
|
| 17 |
+
i.e. 32 for FLUX.2 [klein]).
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(self, num_channels: int):
|
| 21 |
+
super().__init__()
|
| 22 |
+
self.lcm = nn.Sequential(
|
| 23 |
+
nn.Conv2d(num_channels, num_channels * 2, kernel_size=3, padding=1),
|
| 24 |
+
nn.GELU(),
|
| 25 |
+
nn.Conv2d(num_channels * 2, num_channels, kernel_size=3, padding=1),
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 29 |
+
lcm_dtype = next(self.lcm.parameters()).dtype
|
| 30 |
+
if x.dtype != lcm_dtype:
|
| 31 |
+
x = x.to(dtype=lcm_dtype)
|
| 32 |
+
return x + self.lcm(x)
|
transnormal2/pipeline.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TransNormal-2 inference pipeline.
|
| 2 |
+
|
| 3 |
+
Single-step rectified-flow normal estimation on the FLUX.2 [klein] backbone:
|
| 4 |
+
|
| 5 |
+
RGB image -> VAE encode -> core-predictor LoRA (1 transformer step)
|
| 6 |
+
-> LCM (latent smoothing) -> VAE decode
|
| 7 |
+
-> GSM (edge-aware refinement) -> unit normal map
|
| 8 |
+
|
| 9 |
+
The released model predicts the normal latent in a single deterministic
|
| 10 |
+
transformer evaluation (no diffusion sampling loop, no random noise), so the
|
| 11 |
+
output is reproducible bit-for-bit for a fixed input and dtype.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
from typing import List, Optional, Union
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
import torch.nn.functional as F
|
| 20 |
+
|
| 21 |
+
from diffusers import Flux2KleinPipeline
|
| 22 |
+
from diffusers.models import Flux2Transformer2DModel
|
| 23 |
+
|
| 24 |
+
from .gsm import GSM
|
| 25 |
+
from .lcm import LocalContinuityModule
|
| 26 |
+
from .utils import resize_image_first, tensor_to_output
|
| 27 |
+
|
| 28 |
+
# LoRA target modules of the FLUX.2 [klein] transformer (must match training).
|
| 29 |
+
FLUX2_LORA_TARGET_MODULES = "|".join([
|
| 30 |
+
# Double-stream: image attention
|
| 31 |
+
r".*\.attn\.to_k$",
|
| 32 |
+
r".*\.attn\.to_q$",
|
| 33 |
+
r".*\.attn\.to_v$",
|
| 34 |
+
r".*\.attn\.to_out\.0$",
|
| 35 |
+
# Double-stream: text attention
|
| 36 |
+
r".*\.attn\.add_k_proj$",
|
| 37 |
+
r".*\.attn\.add_q_proj$",
|
| 38 |
+
r".*\.attn\.add_v_proj$",
|
| 39 |
+
r".*\.attn\.to_add_out$",
|
| 40 |
+
# Double-stream: image FF (SwiGLU)
|
| 41 |
+
r".*\.ff\.linear_in$",
|
| 42 |
+
r".*\.ff\.linear_out$",
|
| 43 |
+
# Double-stream: text FF (SwiGLU)
|
| 44 |
+
r".*\.ff_context\.linear_in$",
|
| 45 |
+
r".*\.ff_context\.linear_out$",
|
| 46 |
+
# Single-stream: fused QKV+FF input projection
|
| 47 |
+
r".*\.attn\.to_qkv_mlp_proj$",
|
| 48 |
+
# Single-stream: output projection
|
| 49 |
+
r"single_transformer_blocks\.\d+\.attn\.to_out$",
|
| 50 |
+
# Context embedder
|
| 51 |
+
r"context_embedder$",
|
| 52 |
+
])
|
| 53 |
+
|
| 54 |
+
_WEIGHT_FILES = {
|
| 55 |
+
"lora": ("lora_core_predictor.safetensors",),
|
| 56 |
+
"lcm": ("lcm_normal.safetensors", "lcm_normal.pt"),
|
| 57 |
+
"gsm": ("gsm.safetensors", "gsm.pt"),
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _load_state_dict(path: str) -> dict:
|
| 62 |
+
if path.endswith(".safetensors"):
|
| 63 |
+
from safetensors.torch import load_file
|
| 64 |
+
return load_file(path)
|
| 65 |
+
return torch.load(path, map_location="cpu", weights_only=True)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _find_weight(weights_dir: str, kind: str) -> str:
|
| 69 |
+
for name in _WEIGHT_FILES[kind]:
|
| 70 |
+
p = os.path.join(weights_dir, name)
|
| 71 |
+
if os.path.exists(p):
|
| 72 |
+
return p
|
| 73 |
+
raise FileNotFoundError(
|
| 74 |
+
f"Could not find {kind} weights in {weights_dir} "
|
| 75 |
+
f"(looked for {', '.join(_WEIGHT_FILES[kind])})"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class TransNormal2Pipeline(Flux2KleinPipeline):
|
| 80 |
+
"""FLUX.2 [klein] pipeline specialized for one-step normal estimation."""
|
| 81 |
+
|
| 82 |
+
# Set by from_pretrained_transnormal2().
|
| 83 |
+
local_continuity_module: Optional[LocalContinuityModule] = None
|
| 84 |
+
gsm: Optional[GSM] = None
|
| 85 |
+
|
| 86 |
+
# ── latent helpers (FLUX.2 BatchNorm-normalized patchified latents) ──
|
| 87 |
+
|
| 88 |
+
def _encode_vae_to_raw(self, pixel_values: torch.Tensor) -> torch.Tensor:
|
| 89 |
+
"""Encode pixel values to raw VAE latents (before patchify/BN)."""
|
| 90 |
+
return self.vae.encode(pixel_values.to(dtype=self.vae.dtype)).latent_dist.mode()
|
| 91 |
+
|
| 92 |
+
def _raw_to_transformer_input(self, raw_latents: torch.Tensor) -> torch.Tensor:
|
| 93 |
+
"""Raw VAE latents -> patchified + BN-normalized transformer input."""
|
| 94 |
+
patchified = self._patchify_latents(raw_latents)
|
| 95 |
+
bn_mean = self.vae.bn.running_mean.view(1, -1, 1, 1).to(patchified.device, patchified.dtype)
|
| 96 |
+
bn_std = torch.sqrt(
|
| 97 |
+
self.vae.bn.running_var.view(1, -1, 1, 1) + self.vae.config.batch_norm_eps
|
| 98 |
+
).to(patchified.device, patchified.dtype)
|
| 99 |
+
return (patchified - bn_mean) / bn_std
|
| 100 |
+
|
| 101 |
+
def _transformer_output_to_raw(self, latents_norm: torch.Tensor) -> torch.Tensor:
|
| 102 |
+
"""Transformer output (patchified + BN-normalized) -> raw VAE latents."""
|
| 103 |
+
bn_mean = self.vae.bn.running_mean.view(1, -1, 1, 1).to(latents_norm.device, latents_norm.dtype)
|
| 104 |
+
bn_std = torch.sqrt(
|
| 105 |
+
self.vae.bn.running_var.view(1, -1, 1, 1) + self.vae.config.batch_norm_eps
|
| 106 |
+
).to(latents_norm.device, latents_norm.dtype)
|
| 107 |
+
denorm = latents_norm * bn_std + bn_mean
|
| 108 |
+
return self._unpatchify_latents(denorm)
|
| 109 |
+
|
| 110 |
+
def _decode_raw_latents(self, raw_latents: torch.Tensor) -> torch.Tensor:
|
| 111 |
+
"""Decode raw VAE latents to pixel space ([-1, 1])."""
|
| 112 |
+
return self.vae.decode(raw_latents.to(dtype=self.vae.dtype), return_dict=False)[0]
|
| 113 |
+
|
| 114 |
+
# ── loading ──
|
| 115 |
+
|
| 116 |
+
@classmethod
|
| 117 |
+
def from_pretrained_transnormal2(
|
| 118 |
+
cls,
|
| 119 |
+
base_model: str = "black-forest-labs/FLUX.2-klein-base-9B",
|
| 120 |
+
weights_dir: str = "weights/transnormal2",
|
| 121 |
+
torch_dtype: torch.dtype = torch.bfloat16,
|
| 122 |
+
device: Optional[Union[str, torch.device]] = None,
|
| 123 |
+
) -> "TransNormal2Pipeline":
|
| 124 |
+
"""One-call loader: base model + core-predictor LoRA + LCM + GSM.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
base_model: HF id or local path of ``FLUX.2-klein-base-9B``.
|
| 128 |
+
weights_dir: Local directory (or HF snapshot) containing
|
| 129 |
+
``lora_core_predictor.safetensors``, ``lcm_normal.*``,
|
| 130 |
+
``gsm.*`` and ``config.json``.
|
| 131 |
+
torch_dtype: bf16 strongly recommended (fp16 can produce NaNs).
|
| 132 |
+
device: Optional device to move the pipeline to (e.g. "cuda").
|
| 133 |
+
"""
|
| 134 |
+
from peft import LoraConfig, set_peft_model_state_dict
|
| 135 |
+
|
| 136 |
+
with open(os.path.join(weights_dir, "config.json")) as f:
|
| 137 |
+
config = json.load(f)
|
| 138 |
+
|
| 139 |
+
transformer = Flux2Transformer2DModel.from_pretrained(base_model, subfolder="transformer")
|
| 140 |
+
transformer.requires_grad_(False)
|
| 141 |
+
if device is not None:
|
| 142 |
+
transformer.to(device=device, dtype=torch_dtype)
|
| 143 |
+
else:
|
| 144 |
+
transformer.to(dtype=torch_dtype)
|
| 145 |
+
|
| 146 |
+
lora_cfg = config["lora"]
|
| 147 |
+
adapter_name = lora_cfg.get("adapter_name", "core_predictor")
|
| 148 |
+
transformer.add_adapter(
|
| 149 |
+
LoraConfig(
|
| 150 |
+
r=int(lora_cfg["rank"]),
|
| 151 |
+
lora_alpha=float(lora_cfg["alpha"]),
|
| 152 |
+
init_lora_weights="gaussian",
|
| 153 |
+
target_modules=FLUX2_LORA_TARGET_MODULES,
|
| 154 |
+
),
|
| 155 |
+
adapter_name=adapter_name,
|
| 156 |
+
)
|
| 157 |
+
lora_state = _load_state_dict(_find_weight(weights_dir, "lora"))
|
| 158 |
+
set_peft_model_state_dict(transformer, lora_state, adapter_name=adapter_name)
|
| 159 |
+
transformer.set_adapter(adapter_name)
|
| 160 |
+
|
| 161 |
+
pipe = cls.from_pretrained(
|
| 162 |
+
base_model,
|
| 163 |
+
transformer=transformer,
|
| 164 |
+
torch_dtype=torch_dtype,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
lcm = LocalContinuityModule(transformer.config.in_channels // 4)
|
| 168 |
+
lcm.load_state_dict(_load_state_dict(_find_weight(weights_dir, "lcm")))
|
| 169 |
+
lcm.requires_grad_(False)
|
| 170 |
+
|
| 171 |
+
gsm = GSM(**config["gsm"])
|
| 172 |
+
gsm.load_state_dict(_load_state_dict(_find_weight(weights_dir, "gsm")), strict=True)
|
| 173 |
+
gsm.requires_grad_(False)
|
| 174 |
+
gsm.eval()
|
| 175 |
+
|
| 176 |
+
pipe.local_continuity_module = lcm
|
| 177 |
+
pipe.gsm = gsm
|
| 178 |
+
if device is not None:
|
| 179 |
+
pipe.to(device)
|
| 180 |
+
lcm.to(device=device, dtype=torch_dtype)
|
| 181 |
+
gsm.to(device=device, dtype=torch_dtype)
|
| 182 |
+
else:
|
| 183 |
+
lcm.to(dtype=torch_dtype)
|
| 184 |
+
gsm.to(dtype=torch_dtype)
|
| 185 |
+
return pipe
|
| 186 |
+
|
| 187 |
+
def to(self, *args, **kwargs):
|
| 188 |
+
result = super().to(*args, **kwargs)
|
| 189 |
+
# Keep the auxiliary heads on the same device/dtype as the pipeline.
|
| 190 |
+
for module in (self.local_continuity_module, self.gsm):
|
| 191 |
+
if module is not None:
|
| 192 |
+
module.to(*args, **kwargs)
|
| 193 |
+
return result
|
| 194 |
+
|
| 195 |
+
# ── inference ──
|
| 196 |
+
|
| 197 |
+
@torch.no_grad()
|
| 198 |
+
def __call__( # type: ignore[override]
|
| 199 |
+
self,
|
| 200 |
+
image: torch.Tensor,
|
| 201 |
+
domain_is_transparent: Union[bool, torch.Tensor] = False,
|
| 202 |
+
process_res: Optional[int] = None,
|
| 203 |
+
output_type: str = "pt",
|
| 204 |
+
apply_gsm: bool = True,
|
| 205 |
+
) -> Union[torch.Tensor, "np.ndarray", List]:
|
| 206 |
+
"""Predict a surface normal map from an RGB image.
|
| 207 |
+
|
| 208 |
+
Args:
|
| 209 |
+
image: RGB tensor (B, 3, H, W) normalized to [-1, 1].
|
| 210 |
+
domain_is_transparent: True for transparent-dominant scenes
|
| 211 |
+
(glass/liquids): the GSM then anchors on the raw prediction
|
| 212 |
+
instead of the RGB guided filter. Default False (opaque).
|
| 213 |
+
process_res: Optional max processing edge; None keeps the input
|
| 214 |
+
resolution (snapped to a multiple of 16 internally).
|
| 215 |
+
output_type: "pt" (B, 3, H, W) in [0, 1], "np" (B, H, W, 3) in
|
| 216 |
+
[0, 1], or "pil".
|
| 217 |
+
apply_gsm: Disable to inspect the raw (pre-GSM) prediction.
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
Normal map(s) encoded as ``(n + 1) / 2`` in [0, 1], resized back
|
| 221 |
+
to the input resolution.
|
| 222 |
+
"""
|
| 223 |
+
if image.dim() != 4 or image.shape[1] != 3:
|
| 224 |
+
raise ValueError(f"Expected image of shape (B, 3, H, W), got {tuple(image.shape)}")
|
| 225 |
+
|
| 226 |
+
input_size = image.shape[2:]
|
| 227 |
+
rgb_in = resize_image_first(image, process_res)
|
| 228 |
+
|
| 229 |
+
device = self._execution_device
|
| 230 |
+
rgb_in = rgb_in.to(device=device, dtype=self.dtype)
|
| 231 |
+
batch_size = rgb_in.shape[0]
|
| 232 |
+
|
| 233 |
+
# 1. Empty-prompt conditioning (the released model is prompt-free).
|
| 234 |
+
prompt_embeds, text_ids = self.encode_prompt(prompt="", device=device)
|
| 235 |
+
# The Qwen3 text encoder may produce NaNs for the empty prompt in bf16.
|
| 236 |
+
if torch.isnan(prompt_embeds).any():
|
| 237 |
+
prompt_embeds = torch.nan_to_num(prompt_embeds, nan=0.0)
|
| 238 |
+
if prompt_embeds.shape[0] != batch_size:
|
| 239 |
+
prompt_embeds = prompt_embeds.expand(batch_size, -1, -1)
|
| 240 |
+
text_ids = text_ids.expand(batch_size, -1, -1)
|
| 241 |
+
|
| 242 |
+
# 2. Encode RGB to transformer-ready latents.
|
| 243 |
+
raw_rgb_latents = self._encode_vae_to_raw(rgb_in)
|
| 244 |
+
rgb_latents_norm = self._raw_to_transformer_input(raw_rgb_latents)
|
| 245 |
+
latent_ids = self._prepare_latent_ids(rgb_latents_norm).to(device)
|
| 246 |
+
packed_rgb_latents = self._pack_latents(rgb_latents_norm)
|
| 247 |
+
|
| 248 |
+
# 3. Klein base has guidance_embeds=False; keep the guard for safety.
|
| 249 |
+
if self.transformer.config.guidance_embeds:
|
| 250 |
+
guidance = torch.full([1], 1.0, device=device, dtype=torch.float32)
|
| 251 |
+
guidance = guidance.expand(batch_size)
|
| 252 |
+
else:
|
| 253 |
+
guidance = None
|
| 254 |
+
|
| 255 |
+
# 4. Single-step core predictor (timestep fixed at 1/1000).
|
| 256 |
+
timestep = torch.tensor(1, device=device, dtype=self.dtype).expand(batch_size)
|
| 257 |
+
latents_out = self.transformer(
|
| 258 |
+
hidden_states=packed_rgb_latents,
|
| 259 |
+
timestep=timestep / 1000,
|
| 260 |
+
guidance=guidance,
|
| 261 |
+
encoder_hidden_states=prompt_embeds,
|
| 262 |
+
txt_ids=text_ids,
|
| 263 |
+
img_ids=latent_ids,
|
| 264 |
+
joint_attention_kwargs={},
|
| 265 |
+
return_dict=False,
|
| 266 |
+
)[0]
|
| 267 |
+
latents_out = latents_out[:, :packed_rgb_latents.size(1)]
|
| 268 |
+
|
| 269 |
+
# 5. Unpack -> denormalize -> LCM -> decode.
|
| 270 |
+
latents_patched = self._unpack_latents_with_ids(latents_out, latent_ids)
|
| 271 |
+
latents_raw = self._transformer_output_to_raw(latents_patched)
|
| 272 |
+
latents_raw = self.local_continuity_module(latents_raw)
|
| 273 |
+
latents_raw = latents_raw.to(dtype=self.dtype)
|
| 274 |
+
normal = self._decode_raw_latents(latents_raw)
|
| 275 |
+
|
| 276 |
+
# 6. GSM edge-aware refinement in pixel space.
|
| 277 |
+
if apply_gsm and self.gsm is not None:
|
| 278 |
+
rgb_resized = F.interpolate(
|
| 279 |
+
rgb_in, size=normal.shape[2:], mode="bilinear", align_corners=False
|
| 280 |
+
)
|
| 281 |
+
normal = self.gsm(normal, rgb_resized, domain_is_transparent=domain_is_transparent)
|
| 282 |
+
|
| 283 |
+
# 7. [-1, 1] -> [0, 1], resize back to the input resolution.
|
| 284 |
+
normal = self.image_processor.postprocess(normal, output_type="pt")
|
| 285 |
+
normal = F.interpolate(normal, size=input_size, mode="bilinear", align_corners=False)
|
| 286 |
+
|
| 287 |
+
self.maybe_free_model_hooks()
|
| 288 |
+
return tensor_to_output(normal, output_type)
|
transnormal2/utils.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image IO and resize helpers for TransNormal-2 inference."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional, Union
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn.functional as F
|
| 8 |
+
from PIL import Image
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def load_image(path: str) -> torch.Tensor:
|
| 12 |
+
"""Load an image file as a (1, 3, H, W) float tensor in [-1, 1]."""
|
| 13 |
+
img = Image.open(path).convert("RGB")
|
| 14 |
+
arr = np.asarray(img).astype(np.float32)
|
| 15 |
+
ts = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0)
|
| 16 |
+
return ts / 127.5 - 1.0
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def resize_to_multiple_of_16(image_tensor: torch.Tensor) -> torch.Tensor:
|
| 20 |
+
"""Rescale (B, C, H, W) so both sides are multiples of 16 (aspect kept)."""
|
| 21 |
+
h, w = image_tensor.shape[2], image_tensor.shape[3]
|
| 22 |
+
min_side = min(h, w)
|
| 23 |
+
scale = (min_side // 16) * 16 / min_side
|
| 24 |
+
|
| 25 |
+
new_h = (int(h * scale) // 16) * 16
|
| 26 |
+
new_w = (int(w * scale) // 16) * 16
|
| 27 |
+
|
| 28 |
+
if (new_h, new_w) == (h, w):
|
| 29 |
+
return image_tensor
|
| 30 |
+
return F.interpolate(
|
| 31 |
+
image_tensor, size=(new_h, new_w), mode="bilinear", align_corners=False
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def resize_image_first(image_tensor: torch.Tensor, process_res: Optional[int] = None) -> torch.Tensor:
|
| 36 |
+
"""Optionally cap the max edge at ``process_res``, then snap to /16."""
|
| 37 |
+
if process_res:
|
| 38 |
+
max_edge = max(image_tensor.shape[2], image_tensor.shape[3])
|
| 39 |
+
if max_edge > process_res:
|
| 40 |
+
scale = process_res / max_edge
|
| 41 |
+
new_height = int(image_tensor.shape[2] * scale)
|
| 42 |
+
new_width = int(image_tensor.shape[3] * scale)
|
| 43 |
+
image_tensor = F.interpolate(
|
| 44 |
+
image_tensor, size=(new_height, new_width), mode="bilinear", align_corners=False
|
| 45 |
+
)
|
| 46 |
+
return resize_to_multiple_of_16(image_tensor)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def tensor_to_output(
|
| 50 |
+
normal_01: torch.Tensor, output_type: str = "pt"
|
| 51 |
+
) -> Union[torch.Tensor, np.ndarray, List[Image.Image]]:
|
| 52 |
+
"""Convert a (B, 3, H, W) [0, 1] tensor to the requested output format."""
|
| 53 |
+
if output_type == "pt":
|
| 54 |
+
return normal_01
|
| 55 |
+
arr = normal_01.float().clamp(0, 1).permute(0, 2, 3, 1).cpu().numpy()
|
| 56 |
+
if output_type == "np":
|
| 57 |
+
return arr
|
| 58 |
+
if output_type == "pil":
|
| 59 |
+
return [Image.fromarray((a * 255).round().astype(np.uint8)) for a in arr]
|
| 60 |
+
raise ValueError(f"Unsupported output_type: {output_type} (use 'pt', 'np' or 'pil')")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def save_normal_map(
|
| 64 |
+
normal: Union[torch.Tensor, np.ndarray],
|
| 65 |
+
path: str,
|
| 66 |
+
save_npy: Optional[str] = None,
|
| 67 |
+
) -> None:
|
| 68 |
+
"""Save a normal map prediction as a PNG (and optionally raw .npy).
|
| 69 |
+
|
| 70 |
+
Accepts (3, H, W) / (1, 3, H, W) tensors or (H, W, 3) arrays in [0, 1]
|
| 71 |
+
(the ``(n + 1) / 2`` encoding: camera-space X right, Y up, Z toward
|
| 72 |
+
camera mapped to RGB).
|
| 73 |
+
"""
|
| 74 |
+
if isinstance(normal, torch.Tensor):
|
| 75 |
+
t = normal.detach().float().cpu()
|
| 76 |
+
if t.dim() == 4:
|
| 77 |
+
t = t[0]
|
| 78 |
+
if t.dim() == 3 and t.shape[0] == 3:
|
| 79 |
+
t = t.permute(1, 2, 0)
|
| 80 |
+
arr = t.numpy()
|
| 81 |
+
else:
|
| 82 |
+
arr = np.asarray(normal, dtype=np.float32)
|
| 83 |
+
if arr.ndim == 4:
|
| 84 |
+
arr = arr[0]
|
| 85 |
+
|
| 86 |
+
arr = np.clip(arr, 0.0, 1.0)
|
| 87 |
+
Image.fromarray((arr * 255).round().astype(np.uint8)).save(path)
|
| 88 |
+
if save_npy:
|
| 89 |
+
np.save(save_npy, arr)
|