Longxiang-ai commited on
Commit
4a8f4e7
·
verified ·
1 Parent(s): 4bec0e7

Add TransNormal-2 Gradio demo

Browse files
.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 2 Demo
3
- emoji: 🦀
4
- colorFrom: blue
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: 1ce58f49b9c1d06cad88c8bfe67169f0f830543999ad1cd9318f52323ff60bf6
  • Pointer size: 131 Bytes
  • Size of remote file: 170 kB
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)