jasong commited on
Commit
e0a426a
·
verified ·
1 Parent(s): d06b19d

Initial commit

Browse files

Copied chord-demo and modified for Minecraft PBR material standards.

.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
+ *.jpg filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ dist/
7
+ build/
8
+
9
+ # Environment
10
+ .env
11
+ .venv/
12
+ venv/
13
+
14
+ # IDE
15
+ .vscode/
16
+ .idea/
17
+
18
+ # Claude Code
19
+ .claude/
20
+
21
+ # Project specific
22
+ output/
23
+ *.safetensors
24
+ test_*.png
25
+
26
+ # OS
27
+ .DS_Store
28
+ Thumbs.db
LICENSE ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Ubisoft Machine Learning License (Research-Only - Copyleft)
2
+
3
+ This license governs the use, reproduction, and distribution of the Licensed
4
+ Materials, including AI Models and associated source code for the sole purpose
5
+ of scientific research. By accessing, downloading or using the Licensed
6
+ Materials, you hereby accept to be bound by this [Ubisoft Machine Learning
7
+ License (Research-Only - Copyleft)] agreement (hereinafter the “License”).
8
+
9
+ 1. Licensed Materials
10
+
11
+ - AI Models
12
+ - Source Code
13
+
14
+ 2. Definitions
15
+
16
+ “Licensed Materials”: Refers to the AI Models and/or Source Code licensed under
17
+ this agreement.
18
+ "Source Code" means the preferred form of the work for making modifications to
19
+ it corresponding to text written using human-readable programming language.
20
+ "Object Code" means any non-source form of a work.
21
+ “AI Model” means any machine learning based assembly or assemblies (including
22
+ checkpoints), consisting of learnt weights, parameters (including optimizer
23
+ states), corresponding to the model architecture as embodied in the Source Code.
24
+ “Output” means the results of operating an AI Model as embodied in
25
+ informational content resulting therefrom.
26
+ “Derivative”: Any work derived from or based upon the Licensed Materials,
27
+ including modifications.
28
+ “Permitted Purpose”: Use for academic or research purposes only. Commercial
29
+ use is strictly prohibited.
30
+ “Distribution”: Any sharing of the Licensed Materials or Derivatives with third
31
+ parties, including hosting as a service.
32
+ “Licensor”: The rights holder or authorized entity granting this License.
33
+ “You”: The individual or entity receiving and exercising rights under this
34
+ License.
35
+
36
+ 3. Grant of Rights
37
+
38
+ Subject to compliance with the terms of this License, You are granted a
39
+ worldwide, royalty-free, non-exclusive License to use, study, reproduce,
40
+ modify, and distribute the Licensed Materials and Derivatives solely for the
41
+ Permitted Purpose. As between You and Licensor, Licensor claims no rights in
42
+ the Outputs You generate using the AI Models used in accordance with the
43
+ Permitted Purpose.
44
+
45
+ 4. Distribution of Licensed Materials and Derivatives
46
+
47
+ Any Distribution of the Derivatives of the Licensed Materials, or the Licensed
48
+ Materials shall be licensed under the same exact terms as this License.
49
+ Redistribution shall include this License and retain all notices of author
50
+ attribution and all modifications shall be clearly marked.
51
+
52
+ 5. Use Restrictions
53
+
54
+ You shall not use the Licensed Materials or its Derivatives for:
55
+ - any other purposes than the Permitted Purpose, including for commercial
56
+ purposes such as using the Licensed Materials in any activity intended for
57
+ commercial advantage or monetary compensation directly or indirectly;
58
+ - weaponry, warfare, military applications, surveillance, or any activity that
59
+ may cause harm or violate human rights;
60
+ - engaging or enabling fully automated decision-making that may adversely
61
+ impacts a natural person's legal rights;
62
+ - providing medical advice or making clinical decisions;
63
+ - generating content that promotes or incites hatred, violence, discrimination,
64
+ or harm based on race, ethnicity, religion, gender, sexual orientation, or
65
+ any other protected characteristic;
66
+ - generating content that includes depictions of sexual abuse, sexual
67
+ violence, explicit pornography, or any form of non-consensual acts and/or
68
+ generating content that includes depictions of child nudity, child
69
+ pornography, or any form of child exploitation;
70
+
71
+ 6. Disclaimer of Warranty
72
+
73
+ THE LICENSED MATERIALS IS PROVIDED "AS IS" AND “AS AVAILABLE” WITHOUT
74
+ WARRANTIES OF ANY KIND WHETHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION
75
+ THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
76
+ NON-INFRINGEMENT, CORRECTNESS, ACCURACY, OR RELIABILITY. THE LICENSOR DISCLAIMS
77
+ ALL LIABILITY FOR DAMAGES RESULTING FROM THE USE OR INABILITY TO USE THE
78
+ LICENSED MATERIALS. THE USE OF THE LICENSED MATERIALS AND ANY OUTPUTS YOU MAY
79
+ GENERATE SHALL BE AT YOUR OWN RISK.
80
+
81
+ 7. Termination
82
+
83
+ This License terminates automatically if You violate any of its terms. Upon
84
+ termination, You shall cease all use and distribution of the Licensed
85
+ Materials and its Derivatives.
86
+
87
+ 8. Governing Law
88
+
89
+ The validity of this Agreement and any of its terms and provisions, as well as
90
+ the rights and duties of the parties hereunder, shall be governed, interpreted
91
+ and enforced in accordance with the laws of France.
92
+
93
+ 9. Miscellaneous
94
+
95
+ If any provision of this License is held to be invalid, illegal or
96
+ unenforceable, the remaining provisions shall be unaffected thereby and remain
97
+ valid as if such provision had not been set forth herein.
98
+
99
+ Copyright (C) 2025 UBISOFT ENTERTAINMENT. All Rights Reserved.
README.md CHANGED
@@ -1,14 +1,44 @@
1
  ---
2
- title: Chord Minecraft Pbr
3
- emoji: 🏆
4
- colorFrom: blue
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.3.0
8
  app_file: app.py
9
  pinned: false
10
- license: apache-2.0
11
- short_description: Chord for Minecraft shader PBR textures (Java and Bedrock)
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Chord Demo
3
+ emoji: 🐠
4
+ colorFrom: yellow
5
+ colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 5.49.1
8
  app_file: app.py
9
  pinned: false
10
+ python_version: 3.12
 
11
  ---
12
 
13
+ <div align="center">
14
+ <h1>Chord: Chain of Rendering Decomposition for PBR Material Estimation from Generated Texture Images</h1>
15
+
16
+ <a href="https://arxiv.org/abs/2509.09952"><img src="https://img.shields.io/badge/arXiv-2509.09952-B31B1B?logo=arxiv&logoColor=white&style=flat-square" alt="arXiv"></a>
17
+ <a href="https://ubisoft-laforge.github.io/world/chord/"><img src="https://img.shields.io/badge/Project-Page-brightgreen?logo=ubisoft&logoColor=white&style=flat-square" alt="Project Page"></a>
18
+
19
+ [Zhi Ying](https://orcid.org/0009-0008-8390-3366)\*, [Boxiang Rong](https://ribosome-rbx.github.io/)\*, [Jingyu Wang](https://ccetaw.github.io/), [Maoyuan Xu](https://ultraman-blazar.github.io/)
20
+ </div>
21
+
22
+ Official implementation of the paper "**Chord: Chain of Rendering Decomposition for PBR Material Estimation from Generated Texture Images**".
23
+
24
+ ## License
25
+
26
+ This project is released under the **Ubisoft Machine Learning License (Research-Only - Copyleft)**. See the full terms in the [LICENSE](LICENSE) file.
27
+
28
+ ## Citation
29
+
30
+ If you find our work useful, please consider citing:
31
+
32
+ ```
33
+ @misc{ying2025chord,
34
+ title={Chord: Chain of Rendering Decomposition for PBR Material Estimation from Generated Texture Images},
35
+ author={Zhi Ying and Boxiang Rong and Jingyu Wang and Maoyuan Xu},
36
+ year={2025},
37
+ eprint={2509.09952},
38
+ archivePrefix={arXiv},
39
+ primaryClass={cs.GR},
40
+ url={https://arxiv.org/abs/2509.09952},
41
+ }
42
+ ```
43
+
44
+ © [2025] Ubisoft Entertainment. All Rights Reserved.
app.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import os
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+ from PIL import Image
8
+ import torch
9
+ import copy
10
+ from omegaconf import OmegaConf
11
+ from torchvision.transforms import v2
12
+ from torchvision.transforms.functional import to_pil_image
13
+ from huggingface_hub import hf_hub_download, login
14
+ import spaces
15
+
16
+ from chord import ChordModel
17
+ from chord.module import make
18
+ from chord.util import get_positions, rgb_to_srgb
19
+ from chord.io import load_torch_file
20
+ from chord.minecraft_pbr import convert_to_labpbr, convert_to_bedrock, LABPBR_METAL_CHOICES
21
+
22
+
23
+ def _load_examples(directory: str) -> list:
24
+ """Load example images from a directory, returning empty list if not found."""
25
+ if os.path.isdir(directory):
26
+ return [[f"{directory}/{f}"] for f in sorted(os.listdir(directory))]
27
+ return []
28
+
29
+
30
+ EXAMPLES_USECASE_1 = _load_examples("examples/generated")
31
+ EXAMPLES_USECASE_2 = _load_examples("examples/in_the_wild")
32
+ EXAMPLES_USECASE_3 = _load_examples("examples/specular")
33
+
34
+ MODEL_OBJ = None
35
+
36
+ # Use local model if available, otherwise download from HuggingFace
37
+ LOCAL_MODEL_PATH = "chord_v1.safetensors"
38
+ if os.path.exists(LOCAL_MODEL_PATH):
39
+ MODEL_CKPT_PATH = LOCAL_MODEL_PATH
40
+ print(f"Using local model: {MODEL_CKPT_PATH}")
41
+ else:
42
+ hf_token = os.environ.get("HF_TOKEN")
43
+ if not hf_token:
44
+ raise EnvironmentError(
45
+ "HF_TOKEN environment variable is required to download the model. "
46
+ "Set it in a .env file or export it in your shell."
47
+ )
48
+ login(token=hf_token)
49
+ MODEL_CKPT_PATH = hf_hub_download(repo_id="Ubisoft/ubisoft-laforge-chord", filename="chord_v1.safetensors")
50
+ print(f"Downloaded model to: {MODEL_CKPT_PATH}")
51
+
52
+
53
+ def load_model(ckpt_path):
54
+ print("Loading model from:", ckpt_path)
55
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
56
+ config = OmegaConf.load("config/chord.yaml")
57
+ model = ChordModel(config)
58
+ state_dict = load_torch_file(ckpt_path)
59
+ model.load_state_dict(state_dict)
60
+ model.eval()
61
+ model.to(device)
62
+ return model
63
+
64
+ def run_model(model, img: Image.Image):
65
+ device = next(model.parameters()).device
66
+ to_tensor = v2.Compose([v2.ToImage(), v2.ToDtype(torch.float32, scale=True)])
67
+ image = to_tensor(img).to(device)
68
+ x = v2.Resize(size=(1024, 1024), antialias=True)(image).unsqueeze(0)
69
+ with torch.no_grad(), torch.autocast(device_type=device.type):
70
+ output = model(x)
71
+ return output
72
+
73
+ def relit(model, maps):
74
+ maps['metallic'] = maps.get('metalness', torch.zeros_like(maps['basecolor']))
75
+ device = next(model.parameters()).device
76
+ h, w = maps["basecolor"].shape[-2:]
77
+ light = make("point-light", {"position": [0, 0, 10]}).to(device)
78
+ pos = get_positions(h, w, 10).to(device)
79
+ camera = torch.tensor([0, 0, 10.0]).to(device)
80
+ for key in maps:
81
+ if maps[key].dim() == 3:
82
+ maps[key] = maps[key].unsqueeze(0)
83
+ maps[key] = maps[key].permute(0,2,3,1) # BxCxHxW -> BxHxWxC
84
+ rgb = model.model.compute_render(maps, camera, pos, light).squeeze(0).permute(0,3,1,2) # GxBxHxWxC -> BxCxHxW
85
+ return torch.clamp(rgb_to_srgb(rgb), 0, 1)
86
+
87
+ @spaces.GPU
88
+ def inference(
89
+ img,
90
+ output_format,
91
+ seamless,
92
+ ao_strength,
93
+ ao_blur,
94
+ compute_porosity,
95
+ normalize_porosity,
96
+ compute_sss,
97
+ sss_curvature_weight,
98
+ sss_ao_weight,
99
+ sss_blur,
100
+ compute_emission,
101
+ emission_threshold,
102
+ emission_knee,
103
+ emission_bloom,
104
+ hardcoded_metal,
105
+ ):
106
+ """
107
+ Run Chord model and output shader-compatible textures.
108
+
109
+ Args:
110
+ output_format: "labpbr" for Java Edition shaders, "bedrock" for Bedrock RTX
111
+
112
+ Returns:
113
+ albedo: RGB albedo/basecolor texture
114
+ packed: Specular (_s) for LabPBR or MER/MERS for Bedrock
115
+ normal: Normal texture (_n for LabPBR, _normal for Bedrock)
116
+ render: Relit preview image
117
+ """
118
+ global MODEL_OBJ
119
+
120
+ if MODEL_OBJ is None or getattr(MODEL_OBJ, "_ckpt", None) != MODEL_CKPT_PATH:
121
+ MODEL_OBJ = load_model(MODEL_CKPT_PATH)
122
+ MODEL_OBJ._ckpt = MODEL_CKPT_PATH # store path inside object
123
+
124
+ if img is None:
125
+ return None, None, None, None
126
+
127
+ ori_h, ori_w = img.size[1], img.size[0]
128
+ out = run_model(MODEL_OBJ, img)
129
+ maps = copy.deepcopy(out)
130
+ rendered = relit(MODEL_OBJ, maps)
131
+ resize_back = v2.Resize(size=(ori_h, ori_w), antialias=True)
132
+
133
+ # Resize all maps to original resolution
134
+ basecolor = resize_back(out["basecolor"])
135
+ normal = resize_back(out["normal"])
136
+ roughness = resize_back(out["roughness"].unsqueeze(0) if out["roughness"].dim() == 2 else out["roughness"])
137
+ metalness = resize_back(out["metalness"].unsqueeze(0) if out["metalness"].dim() == 2 else out["metalness"])
138
+
139
+ if output_format == "bedrock":
140
+ # Convert to Bedrock RTX format (MER/MERS)
141
+ result = convert_to_bedrock(
142
+ basecolor=basecolor,
143
+ normal=normal,
144
+ roughness=roughness,
145
+ metalness=metalness,
146
+ compute_sss=compute_sss,
147
+ sss_curvature_weight=sss_curvature_weight,
148
+ sss_ao_weight=sss_ao_weight,
149
+ sss_blur=int(sss_blur),
150
+ compute_emission=compute_emission,
151
+ emission_threshold=emission_threshold,
152
+ emission_knee=emission_knee,
153
+ emission_bloom=int(emission_bloom),
154
+ )
155
+ return (
156
+ result['albedo'],
157
+ result['mer'],
158
+ result['normal'],
159
+ to_pil_image(resize_back(rendered).squeeze(0)),
160
+ )
161
+ else:
162
+ # Convert to LabPBR 1.3 format (default)
163
+ result = convert_to_labpbr(
164
+ basecolor=basecolor,
165
+ normal=normal,
166
+ roughness=roughness,
167
+ metalness=metalness,
168
+ derive_ao_height=True,
169
+ seamless=seamless,
170
+ ao_strength=ao_strength,
171
+ ao_blur=int(ao_blur),
172
+ compute_porosity=compute_porosity,
173
+ normalize_porosity=normalize_porosity,
174
+ compute_sss=compute_sss,
175
+ sss_curvature_weight=sss_curvature_weight,
176
+ sss_ao_weight=sss_ao_weight,
177
+ sss_blur=int(sss_blur),
178
+ compute_emission=compute_emission,
179
+ emission_threshold=emission_threshold,
180
+ emission_knee=emission_knee,
181
+ emission_bloom=int(emission_bloom),
182
+ hardcoded_metal=hardcoded_metal,
183
+ )
184
+ return (
185
+ result['albedo'],
186
+ result['specular'],
187
+ result['normal'],
188
+ to_pil_image(resize_back(rendered).squeeze(0)),
189
+ )
190
+
191
+ with gr.Blocks(title="Chord - PBR Material Estimation") as demo:
192
+
193
+ gr.Markdown("# **Chord: PBR Material Estimation → Minecraft Shader Formats**")
194
+ gr.Markdown("""
195
+ Upload an image to estimate PBR materials and export for Minecraft shaders.
196
+
197
+ **Supported Formats:**
198
+ - **LabPBR 1.3** (Java Edition): Specular (_s) + Normal (_n) with AO/height
199
+ - **Bedrock RTX**: MER/MERS (_mer/_mers) + Normal (_normal)
200
+ """)
201
+
202
+ with gr.Row():
203
+ with gr.Column():
204
+ input_img = gr.Image(type="pil", label="Input Image", height=512)
205
+
206
+ with gr.Accordion("Output Settings", open=True):
207
+ gr.Markdown("#### Output Format")
208
+ output_format = gr.Radio(
209
+ choices=[("LabPBR 1.3 (Java Edition)", "labpbr"), ("Bedrock RTX", "bedrock")],
210
+ value="labpbr",
211
+ label="Format",
212
+ info="Choose shader format for your Minecraft edition"
213
+ )
214
+
215
+ gr.Markdown("#### General (LabPBR only)")
216
+ seamless = gr.Checkbox(label="Seamless/Tileable", value=False,
217
+ info="Enable for textures that should tile seamlessly")
218
+
219
+ gr.Markdown("#### AO & Height Derivation (LabPBR only)")
220
+ ao_strength = gr.Slider(minimum=0.5, maximum=5.0, value=2.0, step=0.1,
221
+ label="AO Strength", info="AO contrast multiplier")
222
+ ao_blur = gr.Slider(minimum=0, maximum=15, value=5, step=1,
223
+ label="AO Blur", info="Gaussian blur radius for AO smoothing")
224
+
225
+ gr.Markdown("#### Porosity (LabPBR Blue 0-64)")
226
+ compute_porosity = gr.Checkbox(label="Compute Porosity", value=False,
227
+ info="Calculate porosity from AO, smoothness, and F0 (LabPBR only)")
228
+ normalize_porosity = gr.Checkbox(label="Normalize Porosity", value=True,
229
+ info="Normalize to full range before LabPBR scaling")
230
+
231
+ gr.Markdown("#### Subsurface Scattering")
232
+ compute_sss = gr.Checkbox(label="Compute SSS", value=False,
233
+ info="Calculate SSS thickness from normal curvature (LabPBR: blue 65-255, Bedrock: MERS alpha)")
234
+ sss_curvature_weight = gr.Slider(minimum=0.0, maximum=1.0, value=0.7, step=0.05,
235
+ label="Curvature Weight", info="Weight for curvature contribution")
236
+ sss_ao_weight = gr.Slider(minimum=0.0, maximum=1.0, value=0.3, step=0.05,
237
+ label="AO Weight", info="Weight for inverted AO contribution")
238
+ sss_blur = gr.Slider(minimum=0, maximum=10, value=2, step=1,
239
+ label="SSS Blur", info="Gaussian blur for soft SSS look")
240
+
241
+ gr.Markdown("#### Emission")
242
+ compute_emission = gr.Checkbox(label="Compute Emission", value=False,
243
+ info="Extract emission from bright areas of basecolor")
244
+ emission_threshold = gr.Slider(minimum=0.5, maximum=1.0, value=0.85, step=0.01,
245
+ label="Emission Threshold", info="Luminance threshold for detection")
246
+ emission_knee = gr.Slider(minimum=0.0, maximum=0.3, value=0.1, step=0.01,
247
+ label="Emission Knee", info="Soft knee width (0 = hard threshold)")
248
+ emission_bloom = gr.Slider(minimum=0, maximum=21, value=0, step=1,
249
+ label="Emission Bloom", info="Gaussian blur radius for bloom effect (0 = disabled)")
250
+
251
+ gr.Markdown("#### Hardcoded Metal (LabPBR only)")
252
+ hardcoded_metal = gr.Dropdown(
253
+ choices=LABPBR_METAL_CHOICES,
254
+ value="none",
255
+ label="Metal Type",
256
+ info="Use predefined metal F0 values (230-237) for metallic areas"
257
+ )
258
+
259
+ gr.Markdown("### Example Inputs — Generated Textures")
260
+ gr.Examples(
261
+ examples=EXAMPLES_USECASE_1,
262
+ inputs=[input_img],
263
+ label="Examples (Generated Textures)"
264
+ )
265
+
266
+ gr.Markdown("### Example Inputs — In The Wild Photographs")
267
+ gr.Examples(
268
+ examples=EXAMPLES_USECASE_2,
269
+ inputs=[input_img],
270
+ label="Examples (In The Wild Photographs)"
271
+ )
272
+
273
+ gr.Markdown("### Example Inputs — Specular Textures")
274
+ gr.Examples(
275
+ examples=EXAMPLES_USECASE_3,
276
+ inputs=[input_img],
277
+ label="Examples (Specular Textures)"
278
+ )
279
+
280
+ run_button = gr.Button("Run Estimation", variant="primary")
281
+
282
+ with gr.Column():
283
+ gr.Markdown("### Output Textures")
284
+ albedo_out = gr.Image(label="Albedo (basecolor)", height=340, format="png")
285
+ packed_out = gr.Image(label="Specular/MER — LabPBR: _s (smoothness,F0,porosity,emission) | Bedrock: _mer (M,E,R,[S])", height=340, format="png")
286
+ normal_out = gr.Image(label="Normal — LabPBR: _n (XY,AO,height) | Bedrock: _normal (XYZ DirectX)", height=340, format="png")
287
+
288
+ gr.Markdown("### Preview")
289
+ render_out = gr.Image(label="Relit Preview (Point Light)", height=340, format="png")
290
+
291
+ run_button.click(
292
+ inference,
293
+ inputs=[
294
+ input_img,
295
+ output_format,
296
+ seamless,
297
+ ao_strength,
298
+ ao_blur,
299
+ compute_porosity,
300
+ normalize_porosity,
301
+ compute_sss,
302
+ sss_curvature_weight,
303
+ sss_ao_weight,
304
+ sss_blur,
305
+ compute_emission,
306
+ emission_threshold,
307
+ emission_knee,
308
+ emission_bloom,
309
+ hardcoded_metal,
310
+ ],
311
+ outputs=[albedo_out, packed_out, normal_out, render_out]
312
+ )
313
+
314
+ if __name__ == "__main__":
315
+ demo.launch()
chord/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from chord.module import make
4
+ from chord.module.chord import post_decoder
5
+
6
+ class ChordModel(nn.Module):
7
+ def __init__(self, config):
8
+ super().__init__()
9
+ self.model = make(config.model.name, config.model)
10
+
11
+ def forward(self, x: torch.Tensor):
12
+ x = {"render": x}
13
+ pred = self.model(x)
14
+ return post_decoder(pred)
chord/io.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import imageio.v3 as imageio
3
+ import numpy as np
4
+ import warnings
5
+ import os
6
+ import safetensors
7
+
8
+ import torchvision.transforms.functional as F
9
+
10
+ def read_image(filename: str, out: torch.Tensor=None) -> torch.Tensor:
11
+ '''
12
+ Read a local image file into a float tensor (pixel values are normalized to [0, 1], CxHxW)
13
+
14
+ Args:
15
+ filename: Image file path.
16
+ out: Fill in this tensor rather than return a new tensor if provided.
17
+
18
+ Returns:
19
+ Loaded image tensor.
20
+ '''
21
+ with warnings.catch_warnings():
22
+ warnings.simplefilter("ignore") # ignore PIL's user warning that reads fp16 img as fp32
23
+ img: np.ndarray = imageio.imread(filename)
24
+
25
+ # Convert the image array to float tensor according to its data type
26
+ res = None
27
+ if img.dtype == np.uint8:
28
+ img = img.astype(np.float32) / 255.0
29
+ elif img.dtype == np.uint16 or img.dtype == np.int32:
30
+ img = img.astype(np.float32) / 65535.0
31
+ else:
32
+ raise ValueError(f'Unrecognized image pixel value type: {img.dtype}')
33
+ if img.ndim == 2:
34
+ res = torch.from_numpy(img).unsqueeze(0) # 1xHxW for grayscale images
35
+ elif img.ndim == 3:
36
+ res = torch.from_numpy(img).movedim(2, 0)[:3] # HxWxC to CxHxW
37
+ else:
38
+ raise ValueError(f'Unrecognized image dimension: {img.shape}')
39
+
40
+ if out is None:
41
+ return res
42
+ out.copy_(res)
43
+
44
+ def create_img(img: torch.Tensor):
45
+ '''
46
+ Convert tensor to PIL image
47
+
48
+ Args:
49
+ path: Image tensor CxHxW. Squeeze if BxCxHxW and B==1
50
+
51
+ Returns:
52
+ PIL image
53
+ '''
54
+ if img.dim() == 4:
55
+ assert img.shape[0] == 1
56
+ img = img.squeeze(0)
57
+
58
+ if img.shape[0] == 4:
59
+ out_img = F.to_pil_image(img, mode="CMYK")
60
+ out_img = out_img.convert('RGB')
61
+ elif img.shape[0] == 3:
62
+ out_img = F.to_pil_image(img, mode="RGB")
63
+ elif img.shape[0] == 1:
64
+ out_img = F.to_pil_image(img, mode="L")
65
+ else:
66
+ raise ValueError("Unsupported image dimension.")
67
+ return out_img
68
+
69
+ def save_maps(path: str, maps: dict):
70
+ '''
71
+ Save SVBRDF maps to a given path.
72
+
73
+ Args:
74
+ path: Output path.
75
+ maps: Named maps of tensor images.
76
+ '''
77
+ if not os.path.exists(path):
78
+ os.makedirs(path)
79
+ for name, image in maps.items():
80
+ out_img = create_img(image)
81
+ out_img.save(os.path.join(path, name+".png"))
82
+
83
+ def load_torch_file(ckpt, device=None):
84
+ if device is None:
85
+ device = torch.device("cpu")
86
+ if ckpt.lower().endswith(".safetensors") or ckpt.lower().endswith(".sft"):
87
+ with safetensors.safe_open(ckpt, framework="pt", device=device.type) as f:
88
+ state_dict = {}
89
+ for k in f.keys():
90
+ tensor = f.get_tensor(k)
91
+ state_dict[k] = tensor
92
+ else:
93
+ torch_args = {}
94
+ ckpt = torch.load(ckpt, map_location=device, weights_only=True, **torch_args)
95
+
96
+ if "state_dict" in ckpt:
97
+ state_dict = ckpt["state_dict"]
98
+ else:
99
+ state_dict = ckpt
100
+ return state_dict
chord/minecraft_pbr.py ADDED
@@ -0,0 +1,981 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PBR Material Format Conversion
3
+
4
+ Converts Chord model outputs to shader-compatible texture formats.
5
+
6
+ Supported formats:
7
+ 1. LabPBR 1.3 (Minecraft Java Edition shaders)
8
+ Reference: https://shaderlabs.org/wiki/LabPBR_Material_Standard
9
+ - Specular (_s): R=smoothness, G=F0/metal, B=porosity/SSS, A=emission
10
+ - Normal (_n): R=normal.x, G=normal.y (DirectX), B=AO, A=height
11
+
12
+ 2. Bedrock RTX (Minecraft Bedrock Edition shaders)
13
+ - Normal (_normal): DirectX normal map (RGB)
14
+ - MER (_mer): R=metalness, G=emissive, B=roughness
15
+ - MERS (_mers): R=metalness, G=emissive, B=roughness, A=SSS
16
+ Note: Metalness and SSS are mutually exclusive per-pixel (metalness takes priority)
17
+ """
18
+
19
+ import torch
20
+ import torch.nn.functional as Fn
21
+ from PIL import Image
22
+ import numpy as np
23
+
24
+ from .normal_utils import derive_ao_and_height
25
+
26
+
27
+ # LabPBR Hardcoded Metal Values (Green channel of specular texture)
28
+ # Reference: https://shaderlabs.org/wiki/LabPBR_Material_Standard
29
+ LABPBR_METALS = {
30
+ "none": None, # Use default behavior (dielectric/custom metal based on metalness)
31
+ "custom": 255, # Custom metal - shader uses albedo as F0
32
+ "iron": 230,
33
+ "gold": 231,
34
+ "aluminum": 232,
35
+ "chrome": 233,
36
+ "copper": 234,
37
+ "lead": 235,
38
+ "platinum": 236,
39
+ "silver": 237,
40
+ }
41
+
42
+ # Display names for UI
43
+ LABPBR_METAL_CHOICES = [
44
+ ("None (Auto)", "none"),
45
+ ("Custom Metal (255)", "custom"),
46
+ ("Iron (230)", "iron"),
47
+ ("Gold (231)", "gold"),
48
+ ("Aluminum (232)", "aluminum"),
49
+ ("Chrome (233)", "chrome"),
50
+ ("Copper (234)", "copper"),
51
+ ("Lead (235)", "lead"),
52
+ ("Platinum (236)", "platinum"),
53
+ ("Silver (237)", "silver"),
54
+ ]
55
+
56
+
57
+ def roughness_to_smoothness(roughness: torch.Tensor) -> torch.Tensor:
58
+ """
59
+ Convert roughness to LabPBR perceptual smoothness.
60
+
61
+ LabPBR formula: roughness = pow(1.0 - perceptualSmoothness, 2.0)
62
+ Inverse: perceptualSmoothness = 1.0 - sqrt(roughness)
63
+
64
+ Args:
65
+ roughness: Roughness tensor in range [0, 1]
66
+
67
+ Returns:
68
+ Perceptual smoothness in range [0, 1]
69
+ """
70
+ return 1.0 - torch.sqrt(torch.clamp(roughness, 0.0, 1.0))
71
+
72
+
73
+ def metalness_to_f0(
74
+ metalness: torch.Tensor,
75
+ threshold: float = 0.5,
76
+ hardcoded_metal: str = "none",
77
+ ) -> torch.Tensor:
78
+ """
79
+ Convert metalness to LabPBR F0/metal channel.
80
+
81
+ LabPBR encoding (stored LINEAR, no gamma):
82
+ - 0-229: Dielectric F0 (we use ~10 for typical dielectric F0 of 0.04)
83
+ - 230-254: Predefined metals (iron=230, gold=231, aluminum=232, etc.)
84
+ - 255: Custom metal (shader uses albedo as F0)
85
+
86
+ F0 values are stored linearly: F0 = green_channel / 255.0
87
+ No sRGB or gamma correction is applied.
88
+
89
+ Args:
90
+ metalness: Metalness tensor in range [0, 1]
91
+ threshold: Threshold above which material is considered metal
92
+ hardcoded_metal: Name of predefined metal type ("none", "custom", "iron", "gold", etc.)
93
+ When not "none", metallic areas use this specific metal value instead of 255.
94
+
95
+ Returns:
96
+ F0 channel values in range [0, 1] (stored LINEAR, scaled to 0-255 on save)
97
+ """
98
+ # F0 values stored linearly (no gamma correction)
99
+ # Dielectric F0 ≈ 0.04, which maps to ~10/255 ≈ 0.039 (linear)
100
+ dielectric_f0 = 10.0 / 255.0
101
+
102
+ # Determine metal F0 value (linear)
103
+ metal_value = LABPBR_METALS.get(hardcoded_metal, None)
104
+ if metal_value is None:
105
+ # Default: custom metal (255)
106
+ metal_f0 = 1.0
107
+ else:
108
+ metal_f0 = metal_value / 255.0
109
+
110
+ # Blend based on metalness (hard threshold for cleaner results)
111
+ is_metal = (metalness > threshold).float()
112
+ return torch.lerp(
113
+ torch.full_like(metalness, dielectric_f0),
114
+ torch.full_like(metalness, metal_f0),
115
+ is_metal
116
+ )
117
+
118
+
119
+ def convert_normal_to_directx(
120
+ normal: torch.Tensor,
121
+ flip_y: bool = True,
122
+ swap_xy: bool = False,
123
+ ) -> torch.Tensor:
124
+ """
125
+ Convert normal map to DirectX convention (Y-down) if needed.
126
+
127
+ LabPBR uses DirectX-style normals where:
128
+ - R (X): Right is positive
129
+ - G (Y): Down is positive (Y-)
130
+
131
+ Chord outputs normals in range [0, 1] where 0.5 is neutral.
132
+ Note: Chord's internal rendering swaps X/Y channels (see chord.py:208),
133
+ suggesting the model may output [Y, X, Z]. Use swap_xy=True if needed.
134
+
135
+ Args:
136
+ normal: Normal map tensor, shape (B, 3, H, W) or (3, H, W), range [0, 1]
137
+ flip_y: Whether to flip Y for DirectX convention (OpenGL Y-up to DirectX Y-down)
138
+ swap_xy: Whether to swap X and Y channels (if model outputs [Y, X, Z])
139
+
140
+ Returns:
141
+ Normal map with DirectX convention, range [0, 1]
142
+ """
143
+ normal_out = normal.clone()
144
+
145
+ if normal.dim() == 4:
146
+ # Swap X and Y if needed (model outputs [Y, X, Z])
147
+ if swap_xy:
148
+ normal_out[:, 0, :, :] = normal[:, 1, :, :]
149
+ normal_out[:, 1, :, :] = normal[:, 0, :, :]
150
+ # Flip Y channel: new_y = 1.0 - old_y (OpenGL to DirectX)
151
+ if flip_y:
152
+ normal_out[:, 1, :, :] = 1.0 - normal_out[:, 1, :, :]
153
+ else:
154
+ if swap_xy:
155
+ normal_out[0, :, :] = normal[1, :, :]
156
+ normal_out[1, :, :] = normal[0, :, :]
157
+ if flip_y:
158
+ normal_out[1, :, :] = 1.0 - normal_out[1, :, :]
159
+
160
+ return normal_out
161
+
162
+
163
+ def calculate_porosity(
164
+ ao: torch.Tensor,
165
+ smoothness: torch.Tensor,
166
+ f0: torch.Tensor,
167
+ normalize: bool = True,
168
+ ) -> torch.Tensor:
169
+ """
170
+ Calculate porosity from AO, smoothness, and F0 maps.
171
+
172
+ Porosity is high where: AO is low AND smoothness is low AND F0 is low.
173
+ This identifies deep, rough, non-reflective areas typical of porous materials.
174
+
175
+ Args:
176
+ ao: Ambient occlusion map, shape (1, H, W) or (B, 1, H, W), range [0, 1]
177
+ where 0 = full occlusion, 1 = no occlusion
178
+ smoothness: Smoothness map, shape (1, H, W) or (B, 1, H, W), range [0, 1]
179
+ f0: F0/reflectance map, shape (1, H, W) or (B, 1, H, W), range [0, 1]
180
+ normalize: Whether to normalize output to full 0-1 range (default True)
181
+
182
+ Returns:
183
+ Porosity map, shape matching input, range [0, 1]
184
+ """
185
+ # Invert AO: deep/occluded areas become high
186
+ ao_factor = 1.0 - ao
187
+
188
+ # Invert smoothness to get roughness factor
189
+ roughness_factor = 1.0 - smoothness
190
+
191
+ # Invert F0: non-reflective areas become high
192
+ reflection_factor = 1.0 - f0
193
+
194
+ # Combine via multiplication (intersection of all three traits)
195
+ porosity = ao_factor * roughness_factor * reflection_factor
196
+
197
+ # Normalize to span full 0-1 range for better contrast
198
+ if normalize:
199
+ p_min = porosity.min()
200
+ p_max = porosity.max()
201
+ if p_max - p_min > 0:
202
+ porosity = (porosity - p_min) / (p_max - p_min)
203
+
204
+ # Scale to LabPBR porosity range (0-64 out of 255)
205
+ # LabPBR: 0-64 = porosity, 65-255 = SSS
206
+ porosity = porosity * (64.0 / 255.0)
207
+
208
+ return porosity
209
+
210
+
211
+ def calculate_sss_thickness(
212
+ normal: torch.Tensor,
213
+ ao: torch.Tensor = None,
214
+ curvature_weight: float = 0.7,
215
+ ao_weight: float = 0.3,
216
+ blur_radius: int = 2,
217
+ normalize: bool = True,
218
+ ) -> torch.Tensor:
219
+ """
220
+ Calculate SSS thickness map from normal map curvature and AO.
221
+
222
+ High curvature areas (edges, tips) are considered "thin" and allow more
223
+ light scattering. Inverted AO adds the "gummy glow" in crevices.
224
+
225
+ Args:
226
+ normal: Normal map, shape (3, H, W) or (B, 3, H, W), range [0, 1]
227
+ ao: Optional AO map, shape (1, H, W) or (B, 1, H, W), range [0, 1]
228
+ If None, only curvature is used
229
+ curvature_weight: Weight for curvature contribution (default 0.7)
230
+ ao_weight: Weight for inverted AO contribution (default 0.3)
231
+ blur_radius: Gaussian blur radius for soft SSS look (default 2)
232
+ normalize: Whether to normalize to full 0-1 range (default True)
233
+
234
+ Returns:
235
+ SSS thickness map, shape (1, H, W) or (B, 1, H, W), range [0, 1]
236
+ where 1.0 = maximum SSS (thin/translucent), 0.0 = no SSS
237
+ """
238
+ has_batch = normal.dim() == 4
239
+
240
+ if not has_batch:
241
+ normal = normal.unsqueeze(0)
242
+ if ao is not None:
243
+ ao = ao.unsqueeze(0)
244
+
245
+ b, _, h, w = normal.shape
246
+ device = normal.device
247
+
248
+ # Unpack normals from [0, 1] to [-1, 1]
249
+ nx = normal[:, 0:1, :, :] * 2.0 - 1.0
250
+ ny = normal[:, 1:2, :, :] * 2.0 - 1.0
251
+ nz = normal[:, 2:3, :, :] * 2.0 - 1.0
252
+
253
+ # Calculate curvature via gradient magnitude of normal components
254
+ # Using Sobel-like gradients
255
+ sobel_x = torch.tensor([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]],
256
+ dtype=normal.dtype, device=device).view(1, 1, 3, 3) / 8.0
257
+ sobel_y = torch.tensor([[-1, -2, -1], [0, 0, 0], [1, 2, 1]],
258
+ dtype=normal.dtype, device=device).view(1, 1, 3, 3) / 8.0
259
+
260
+ # Pad for convolution
261
+ pad = Fn.pad
262
+
263
+ # Gradients of each normal component
264
+ nx_padded = pad(nx, (1, 1, 1, 1), mode='reflect')
265
+ ny_padded = pad(ny, (1, 1, 1, 1), mode='reflect')
266
+ nz_padded = pad(nz, (1, 1, 1, 1), mode='reflect')
267
+
268
+ dx_nx = Fn.conv2d(nx_padded, sobel_x)
269
+ dy_nx = Fn.conv2d(nx_padded, sobel_y)
270
+ dx_ny = Fn.conv2d(ny_padded, sobel_x)
271
+ dy_ny = Fn.conv2d(ny_padded, sobel_y)
272
+ dx_nz = Fn.conv2d(nz_padded, sobel_x)
273
+ dy_nz = Fn.conv2d(nz_padded, sobel_y)
274
+
275
+ # Curvature magnitude
276
+ curvature = torch.sqrt(
277
+ dx_nx**2 + dy_nx**2 + dx_ny**2 + dy_ny**2 + dx_nz**2 + dy_nz**2 + 1e-6
278
+ )
279
+
280
+ # Normalize curvature to 0-1
281
+ if normalize:
282
+ c_min = curvature.min()
283
+ c_max = curvature.max()
284
+ if c_max - c_min > 0:
285
+ curvature = (curvature - c_min) / (c_max - c_min)
286
+
287
+ # Combine curvature with inverted AO
288
+ if ao is not None and ao_weight > 0:
289
+ inverted_ao = 1.0 - ao
290
+ sss = curvature * curvature_weight + inverted_ao * ao_weight
291
+ else:
292
+ sss = curvature
293
+
294
+ # Apply Gaussian blur for soft SSS look
295
+ if blur_radius > 0:
296
+ kernel_size = blur_radius * 2 + 1
297
+ sigma = blur_radius / 3.0
298
+
299
+ # Create 1D Gaussian kernel
300
+ x = torch.arange(kernel_size, device=device, dtype=normal.dtype)
301
+ x = x - kernel_size // 2
302
+ gaussian_1d = torch.exp(-x * x / (2 * sigma * sigma))
303
+ gaussian_1d = gaussian_1d / gaussian_1d.sum()
304
+
305
+ # Separable blur
306
+ padding = kernel_size // 2
307
+ sss_padded = pad(sss, (padding, padding, padding, padding), mode='reflect')
308
+
309
+ kernel_h = gaussian_1d.view(1, 1, 1, -1)
310
+ sss_h = Fn.conv2d(sss_padded, kernel_h, padding=0)
311
+
312
+ kernel_v = gaussian_1d.view(1, 1, -1, 1)
313
+ sss = Fn.conv2d(sss_h, kernel_v, padding=0)
314
+
315
+ # Final normalization and clamp
316
+ if normalize:
317
+ s_min = sss.min()
318
+ s_max = sss.max()
319
+ if s_max - s_min > 0:
320
+ sss = (sss - s_min) / (s_max - s_min)
321
+
322
+ sss = torch.clamp(sss, 0.0, 1.0)
323
+
324
+ if not has_batch:
325
+ sss = sss.squeeze(0)
326
+
327
+ return sss
328
+
329
+
330
+ def sss_to_labpbr(sss: torch.Tensor) -> torch.Tensor:
331
+ """
332
+ Convert SSS intensity to LabPBR blue channel encoding.
333
+
334
+ LabPBR encoding for blue channel:
335
+ - 0-64: Porosity
336
+ - 65-255: SSS (linear)
337
+
338
+ Args:
339
+ sss: SSS intensity, range [0, 1] where 1 = max SSS
340
+
341
+ Returns:
342
+ LabPBR encoded SSS for blue channel, range [65/255, 1.0]
343
+ """
344
+ # Map [0, 1] to [65, 255] in normalized form
345
+ # 65/255 ≈ 0.255, 255/255 = 1.0
346
+ return 65.0 / 255.0 + sss * (190.0 / 255.0)
347
+
348
+
349
+ def extract_emissive(
350
+ basecolor: torch.Tensor,
351
+ threshold: float = 0.85,
352
+ knee: float = 0.1,
353
+ bloom_radius: int = 0,
354
+ ) -> torch.Tensor:
355
+ """
356
+ Extract emissive regions from basecolor using luminance thresholding with soft knee.
357
+
358
+ Uses a soft knee to create smooth transitions at the threshold boundary,
359
+ avoiding hard cutoffs that look unnatural.
360
+
361
+ Args:
362
+ basecolor: RGB basecolor map, shape (3, H, W) or (B, 3, H, W), range [0, 1]
363
+ threshold: Luminance threshold for emission detection (0-1, default 0.85)
364
+ knee: Soft knee width for smooth transition (0-1, default 0.1)
365
+ - 0 = hard threshold
366
+ - Higher values = softer/wider transition
367
+ bloom_radius: Optional Gaussian blur radius for bloom effect (0 = disabled)
368
+
369
+ Returns:
370
+ Emission intensity map, shape (1, H, W) or (B, 1, H, W), range [0, 1]
371
+ where 1.0 = maximum emission, 0.0 = no emission
372
+ """
373
+ has_batch = basecolor.dim() == 4
374
+
375
+ if not has_batch:
376
+ basecolor = basecolor.unsqueeze(0)
377
+
378
+ # Calculate luminance (Rec. 709 coefficients)
379
+ luminance = (
380
+ 0.2126 * basecolor[:, 0:1, :, :]
381
+ + 0.7152 * basecolor[:, 1:2, :, :]
382
+ + 0.0722 * basecolor[:, 2:3, :, :]
383
+ )
384
+
385
+ # Soft knee thresholding
386
+ # Creates smooth transition: 0 below (threshold - knee), 1 above (threshold + knee)
387
+ knee_low = threshold - knee
388
+ knee_high = threshold + knee
389
+
390
+ if knee > 0:
391
+ # Smooth hermite interpolation in the knee region
392
+ t = torch.clamp((luminance - knee_low) / (knee_high - knee_low + 1e-6), 0.0, 1.0)
393
+ # Smoothstep: 3t^2 - 2t^3
394
+ emission = t * t * (3.0 - 2.0 * t)
395
+ else:
396
+ # Hard threshold
397
+ emission = (luminance > threshold).float()
398
+
399
+ # Scale by how much luminance exceeds threshold (for intensity variation)
400
+ excess = torch.clamp((luminance - knee_low) / (1.0 - knee_low + 1e-6), 0.0, 1.0)
401
+ emission = emission * excess
402
+
403
+ # Optional bloom effect using Gaussian blur
404
+ if bloom_radius > 0:
405
+ # Ensure odd kernel size
406
+ kernel_size = bloom_radius * 2 + 1
407
+ sigma = bloom_radius / 3.0
408
+
409
+ # Create 1D Gaussian kernel
410
+ x = torch.arange(kernel_size, device=basecolor.device, dtype=basecolor.dtype)
411
+ x = x - kernel_size // 2
412
+ gaussian_1d = torch.exp(-x * x / (2 * sigma * sigma))
413
+ gaussian_1d = gaussian_1d / gaussian_1d.sum()
414
+
415
+ # Apply separable blur (horizontal then vertical)
416
+ padding = kernel_size // 2
417
+ emission_padded = Fn.pad(emission, (padding, padding, padding, padding), mode='reflect')
418
+
419
+ # Horizontal pass
420
+ kernel_h = gaussian_1d.view(1, 1, 1, -1)
421
+ emission_h = Fn.conv2d(emission_padded, kernel_h, padding=0)
422
+
423
+ # Vertical pass
424
+ kernel_v = gaussian_1d.view(1, 1, -1, 1)
425
+ emission_blurred = Fn.conv2d(emission_h, kernel_v, padding=0)
426
+
427
+ # Blend original with bloom (additive-ish)
428
+ emission = torch.maximum(emission, emission_blurred)
429
+
430
+ # Clamp final result
431
+ emission = torch.clamp(emission, 0.0, 1.0)
432
+
433
+ if not has_batch:
434
+ emission = emission.squeeze(0)
435
+
436
+ return emission
437
+
438
+
439
+ def emission_to_labpbr(emission: torch.Tensor) -> torch.Tensor:
440
+ """
441
+ Convert emission intensity to LabPBR alpha channel encoding.
442
+
443
+ LabPBR emission encoding (LINEAR):
444
+ - 0 = no emission
445
+ - 1-254 = emissive levels (1 = min, 254 = max)
446
+ - 255 = no emission (same as 0, or no alpha channel)
447
+
448
+ Args:
449
+ emission: Emission intensity, range [0, 1] where 1 = max emission
450
+
451
+ Returns:
452
+ LabPBR encoded emission for alpha channel, range [0, 254/255]
453
+ (will become 0-254 when saved as uint8)
454
+ """
455
+ # Map [0, 1] to [0, 254]: 0 = no emission, 254 = max emission
456
+ # Avoid 255 which also means no emission in LabPBR
457
+ return torch.clamp(emission, 0.0, 1.0) * (254.0 / 255.0)
458
+
459
+
460
+ def create_specular_texture(
461
+ roughness: torch.Tensor,
462
+ metalness: torch.Tensor,
463
+ porosity: torch.Tensor = None,
464
+ sss: torch.Tensor = None,
465
+ emission: torch.Tensor = None,
466
+ hardcoded_metal: str = "none",
467
+ sss_threshold: float = 0.01,
468
+ ) -> torch.Tensor:
469
+ """
470
+ Create LabPBR specular texture (_s).
471
+
472
+ All channels are stored LINEAR (no gamma correction).
473
+
474
+ Channel layout:
475
+ - R: Perceptual smoothness (1 - sqrt(roughness))
476
+ - G: F0 / metal ID (LINEAR: 0-229 dielectric, 230-237 metals, 255 custom)
477
+ - B: Porosity (0-64) or SSS (65-255) - per-pixel, SSS takes priority
478
+ - A: Emission (0 or 255 = no emission, 1-254 = emissive levels)
479
+
480
+ Args:
481
+ roughness: Roughness map, shape (B, 1, H, W) or (1, H, W)
482
+ metalness: Metalness map, shape (B, 1, H, W) or (1, H, W)
483
+ porosity: Optional porosity map, pre-scaled to 0-64/255 range (default: zeros)
484
+ sss: Optional SSS thickness map, range [0, 1] - will be encoded to 65-255
485
+ On a per-pixel basis, SSS takes precedence over porosity where SSS > threshold
486
+ emission: Optional emission intensity map, range [0, 1] where 1 = max emission
487
+ If provided, output is RGBA; if None, output is RGB
488
+ hardcoded_metal: Predefined metal type for metallic areas ("none", "iron", "gold", etc.)
489
+ sss_threshold: SSS intensity threshold for per-pixel priority (default 0.01)
490
+ Pixels with SSS > threshold use SSS encoding, others use porosity
491
+
492
+ Returns:
493
+ RGB or RGBA specular texture, shape (B, 3/4, H, W) or (3/4, H, W)
494
+ """
495
+ has_batch = roughness.dim() == 4
496
+
497
+ if not has_batch:
498
+ roughness = roughness.unsqueeze(0)
499
+ metalness = metalness.unsqueeze(0)
500
+ if porosity is not None:
501
+ porosity = porosity.unsqueeze(0)
502
+ if sss is not None:
503
+ sss = sss.unsqueeze(0)
504
+ if emission is not None:
505
+ emission = emission.unsqueeze(0)
506
+
507
+ b, _, h, w = roughness.shape
508
+ device = roughness.device
509
+
510
+ # R: Smoothness
511
+ smoothness = roughness_to_smoothness(roughness)
512
+
513
+ # G: F0/Metal (stored LINEAR, uses hardcoded metal value for metallic areas if specified)
514
+ f0 = metalness_to_f0(metalness, hardcoded_metal=hardcoded_metal)
515
+
516
+ # B: Porosity (0-64) and/or SSS (65-255) - per-pixel blending
517
+ # SSS takes priority where it exceeds threshold
518
+ if sss is not None and porosity is not None:
519
+ # Both provided: blend per-pixel, SSS takes priority where > threshold
520
+ sss_encoded = sss_to_labpbr(sss)
521
+ sss_mask = (sss > sss_threshold).float()
522
+ blue_channel = torch.where(sss_mask > 0.5, sss_encoded, porosity)
523
+ elif sss is not None:
524
+ # Only SSS provided
525
+ blue_channel = sss_to_labpbr(sss)
526
+ elif porosity is not None:
527
+ # Only porosity provided (already scaled to 0-64/255 range)
528
+ blue_channel = porosity
529
+ else:
530
+ # Default: no porosity or SSS
531
+ blue_channel = torch.zeros(b, 1, h, w, device=device)
532
+
533
+ channels = [smoothness, f0, blue_channel]
534
+
535
+ # A: Emission (optional)
536
+ if emission is not None:
537
+ emission_encoded = emission_to_labpbr(emission)
538
+ channels.append(emission_encoded)
539
+
540
+ specular = torch.cat(channels, dim=1)
541
+
542
+ if not has_batch:
543
+ specular = specular.squeeze(0)
544
+
545
+ return specular
546
+
547
+
548
+ def create_normal_texture(
549
+ normal: torch.Tensor,
550
+ ao: torch.Tensor = None,
551
+ height: torch.Tensor = None,
552
+ flip_y: bool = True,
553
+ swap_xy: bool = False,
554
+ ) -> torch.Tensor:
555
+ """
556
+ Create LabPBR normal texture (_n).
557
+
558
+ Channel layout:
559
+ - R: Normal X
560
+ - G: Normal Y (DirectX convention, Y-down)
561
+ - B: Ambient Occlusion (0=full AO, 255=none)
562
+ - A: Height/displacement
563
+
564
+ Args:
565
+ normal: Normal map, shape (B, 3, H, W) or (3, H, W), range [0, 1]
566
+ ao: Optional AO map (default: 1.0 = no occlusion)
567
+ height: Optional height map (default: 0.5 = neutral)
568
+ flip_y: Whether to flip Y for DirectX convention
569
+ swap_xy: Whether to swap X and Y channels (if model outputs [Y, X, Z])
570
+
571
+ Returns:
572
+ RGBA normal texture, shape (B, 4, H, W) or (4, H, W)
573
+ """
574
+ has_batch = normal.dim() == 4
575
+
576
+ if not has_batch:
577
+ normal = normal.unsqueeze(0)
578
+ if ao is not None:
579
+ ao = ao.unsqueeze(0)
580
+ if height is not None:
581
+ height = height.unsqueeze(0)
582
+
583
+ b, _, h, w = normal.shape
584
+ device = normal.device
585
+
586
+ # Convert to DirectX convention
587
+ normal = convert_normal_to_directx(normal, flip_y=flip_y, swap_xy=swap_xy)
588
+
589
+ # Extract X and Y channels
590
+ normal_x = normal[:, 0:1, :, :]
591
+ normal_y = normal[:, 1:2, :, :]
592
+
593
+ # B: Ambient Occlusion
594
+ # LabPBR: 0 = full occlusion, 255 = no occlusion
595
+ # When not provided, caller should derive from normal map via derive_ao_and_height()
596
+ if ao is None:
597
+ ao = torch.ones(b, 1, h, w, device=device) # Default: no occlusion
598
+
599
+ # A: Height map
600
+ # When not provided, caller should derive from normal map via derive_ao_and_height()
601
+ if height is None:
602
+ height = torch.full((b, 1, h, w), 0.5, device=device) # Neutral height
603
+
604
+ normal_tex = torch.cat([normal_x, normal_y, ao, height], dim=1)
605
+
606
+ if not has_batch:
607
+ normal_tex = normal_tex.squeeze(0)
608
+
609
+ return normal_tex
610
+
611
+
612
+ def tensor_to_pil(tensor: torch.Tensor, mode: str = "RGBA") -> Image.Image:
613
+ """
614
+ Convert a tensor to PIL Image using LINEAR mapping (no gamma correction).
615
+
616
+ All LabPBR data channels (F0, smoothness, porosity, SSS, emission, AO, height)
617
+ are stored linearly. This function performs a direct value * 255 conversion.
618
+
619
+ Args:
620
+ tensor: Image tensor, shape (C, H, W), range [0, 1]
621
+ mode: PIL image mode ("RGBA", "RGB", etc.)
622
+
623
+ Returns:
624
+ PIL Image with linear channel values
625
+ """
626
+ if tensor.dim() == 4:
627
+ tensor = tensor.squeeze(0)
628
+
629
+ # Linear conversion to uint8 (no gamma correction)
630
+ tensor = torch.clamp(tensor, 0.0, 1.0)
631
+ array = (tensor.permute(1, 2, 0).cpu().numpy() * 255).astype(np.uint8)
632
+
633
+ return Image.fromarray(array, mode=mode)
634
+
635
+
636
+ def convert_to_labpbr(
637
+ basecolor: torch.Tensor,
638
+ normal: torch.Tensor,
639
+ roughness: torch.Tensor,
640
+ metalness: torch.Tensor,
641
+ ao: torch.Tensor = None,
642
+ height: torch.Tensor = None,
643
+ porosity: torch.Tensor = None,
644
+ sss: torch.Tensor = None,
645
+ emission: torch.Tensor = None,
646
+ flip_normal_y: bool = True,
647
+ swap_normal_xy: bool = False,
648
+ derive_ao_height: bool = True,
649
+ compute_porosity: bool = False,
650
+ normalize_porosity: bool = True,
651
+ compute_sss: bool = False,
652
+ sss_curvature_weight: float = 0.7,
653
+ sss_ao_weight: float = 0.3,
654
+ sss_blur: int = 2,
655
+ compute_emission: bool = False,
656
+ emission_threshold: float = 0.85,
657
+ emission_knee: float = 0.1,
658
+ emission_bloom: int = 0,
659
+ hardcoded_metal: str = "none",
660
+ seamless: bool = False,
661
+ ao_strength: float = 2.0,
662
+ ao_blur: int = 5,
663
+ ) -> dict:
664
+ """
665
+ Convert Chord outputs to LabPBR 1.3 format.
666
+
667
+ Args:
668
+ basecolor: Albedo/basecolor map (B, 3, H, W) or (3, H, W)
669
+ normal: Normal map (B, 3, H, W) or (3, H, W)
670
+ roughness: Roughness map (B, 1, H, W) or (1, H, W)
671
+ metalness: Metalness map (B, 1, H, W) or (1, H, W)
672
+ ao: Optional AO map (if None and derive_ao_height=True, derived from normal)
673
+ height: Optional height map (if None and derive_ao_height=True, derived from normal)
674
+ porosity: Optional porosity map (if None and compute_porosity=True, calculated from AO/smoothness/F0)
675
+ sss: Optional SSS thickness map (if None and compute_sss=True, calculated from normal curvature)
676
+ Note: Porosity and SSS can coexist on same texture. Per-pixel, SSS takes priority where > threshold.
677
+ emission: Optional emission map (if None and compute_emission=True, extracted from basecolor)
678
+ flip_normal_y: Flip Y channel for DirectX convention (default True)
679
+ swap_normal_xy: Swap X/Y channels if model outputs [Y,X,Z] (default False)
680
+ derive_ao_height: If True, derive AO and height from normal map when not provided
681
+ compute_porosity: If True, calculate porosity from AO, smoothness, and F0 when not provided
682
+ normalize_porosity: If True, normalize porosity to full 0-1 range before LabPBR scaling
683
+ compute_sss: If True, calculate SSS thickness from normal curvature and AO when not provided
684
+ sss_curvature_weight: Weight for curvature in SSS calculation (default 0.7)
685
+ sss_ao_weight: Weight for inverted AO in SSS calculation (default 0.3)
686
+ sss_blur: Gaussian blur radius for soft SSS look (default 2)
687
+ compute_emission: If True, extract emission from basecolor luminance when not provided
688
+ emission_threshold: Luminance threshold for emission detection (0-1, default 0.85)
689
+ emission_knee: Soft knee width for smooth emission transition (0-1, default 0.1)
690
+ emission_bloom: Gaussian blur radius for emission bloom effect (0 = disabled)
691
+ hardcoded_metal: Predefined metal type for specular G channel ("none", "iron", "gold", etc.)
692
+ Uses metalness map as mask - metallic areas get this metal ID value.
693
+ seamless: Whether the texture should tile seamlessly (for height derivation)
694
+ ao_strength: AO intensity multiplier (higher = more contrast)
695
+ ao_blur: Gaussian blur radius for AO smoothing
696
+
697
+ Returns:
698
+ Dictionary with PIL Images:
699
+ - 'albedo': RGB albedo texture
700
+ - 'specular': RGB or RGBA specular texture (_s) - RGBA if emission enabled
701
+ - 'normal': RGBA normal texture (_n)
702
+ """
703
+ # Ensure tensors have consistent dimensions
704
+ has_batch = basecolor.dim() == 4
705
+
706
+ if has_batch:
707
+ basecolor = basecolor.squeeze(0)
708
+ normal = normal.squeeze(0)
709
+ roughness = roughness.squeeze(0)
710
+ metalness = metalness.squeeze(0)
711
+ if ao is not None:
712
+ ao = ao.squeeze(0)
713
+ if height is not None:
714
+ height = height.squeeze(0)
715
+
716
+ # Ensure roughness/metalness are (1, H, W)
717
+ if roughness.dim() == 2:
718
+ roughness = roughness.unsqueeze(0)
719
+ if metalness.dim() == 2:
720
+ metalness = metalness.unsqueeze(0)
721
+
722
+ # Derive AO and height from normal map if not provided
723
+ if derive_ao_height and (ao is None or height is None):
724
+ derived_ao, derived_height = derive_ao_and_height(
725
+ normal,
726
+ seamless=seamless,
727
+ ao_strength=ao_strength,
728
+ ao_blur=ao_blur,
729
+ )
730
+ if ao is None:
731
+ ao = derived_ao
732
+ if height is None:
733
+ height = derived_height
734
+
735
+ # Calculate porosity from AO, smoothness, and F0 if requested
736
+ # Note: Both porosity and SSS can coexist - SSS takes precedence per-pixel
737
+ if compute_porosity and porosity is None:
738
+ smoothness = roughness_to_smoothness(roughness)
739
+ f0 = metalness_to_f0(metalness)
740
+ # Use derived AO if available, otherwise use neutral (1.0 = no occlusion)
741
+ ao_for_porosity = ao if ao is not None else torch.ones_like(roughness)
742
+ porosity = calculate_porosity(
743
+ ao_for_porosity,
744
+ smoothness,
745
+ f0,
746
+ normalize=normalize_porosity,
747
+ )
748
+
749
+ # Calculate SSS thickness from normal curvature and AO if requested
750
+ if compute_sss and sss is None:
751
+ ao_for_sss = ao if ao is not None else None
752
+ sss = calculate_sss_thickness(
753
+ normal,
754
+ ao=ao_for_sss,
755
+ curvature_weight=sss_curvature_weight,
756
+ ao_weight=sss_ao_weight,
757
+ blur_radius=sss_blur,
758
+ )
759
+
760
+ # Extract emission from basecolor luminance if requested
761
+ if compute_emission and emission is None:
762
+ emission = extract_emissive(
763
+ basecolor,
764
+ threshold=emission_threshold,
765
+ knee=emission_knee,
766
+ bloom_radius=emission_bloom,
767
+ )
768
+
769
+ # Create LabPBR textures
770
+ specular_tex = create_specular_texture(
771
+ roughness, metalness, porosity, sss, emission, hardcoded_metal=hardcoded_metal
772
+ )
773
+ normal_tex = create_normal_texture(
774
+ normal, ao, height, flip_y=flip_normal_y, swap_xy=swap_normal_xy
775
+ )
776
+
777
+ # Determine specular output mode based on emission presence
778
+ specular_mode = "RGBA" if emission is not None else "RGB"
779
+
780
+ return {
781
+ 'albedo': tensor_to_pil(basecolor, mode="RGB"),
782
+ 'specular': tensor_to_pil(specular_tex, mode=specular_mode),
783
+ 'normal': tensor_to_pil(normal_tex, mode="RGBA"),
784
+ }
785
+
786
+
787
+ # =============================================================================
788
+ # Bedrock RTX Format Conversion
789
+ # =============================================================================
790
+
791
+ def create_bedrock_normal_texture(
792
+ normal: torch.Tensor,
793
+ flip_y: bool = True,
794
+ swap_xy: bool = False,
795
+ ) -> torch.Tensor:
796
+ """
797
+ Create Bedrock RTX normal texture (_normal).
798
+
799
+ Bedrock uses standard DirectX normal maps (RGB only, no AO/height packing).
800
+
801
+ Args:
802
+ normal: Normal map, shape (B, 3, H, W) or (3, H, W), range [0, 1]
803
+ flip_y: Whether to flip Y for DirectX convention
804
+ swap_xy: Whether to swap X and Y channels
805
+
806
+ Returns:
807
+ RGB normal texture, shape (B, 3, H, W) or (3, H, W)
808
+ """
809
+ return convert_normal_to_directx(normal, flip_y=flip_y, swap_xy=swap_xy)
810
+
811
+
812
+ def create_mer_texture(
813
+ metalness: torch.Tensor,
814
+ roughness: torch.Tensor,
815
+ emission: torch.Tensor = None,
816
+ sss: torch.Tensor = None,
817
+ metal_threshold: float = 0.5,
818
+ ) -> torch.Tensor:
819
+ """
820
+ Create Bedrock RTX MER/MERS texture.
821
+
822
+ All channels are stored LINEAR (no gamma correction).
823
+
824
+ Channel layout:
825
+ - R: Metalness (LINEAR, directly from Chord)
826
+ - G: Emissive level (LINEAR, 0 = no emission, 255 = max emission)
827
+ - B: Roughness (LINEAR, directly from Chord)
828
+ - A (optional): SSS thickness (only in MERS format)
829
+
830
+ Note: Metalness and SSS are mutually exclusive per-pixel.
831
+ Where metalness > threshold, SSS is set to 0.
832
+
833
+ Args:
834
+ metalness: Metalness map, shape (B, 1, H, W) or (1, H, W), range [0, 1]
835
+ roughness: Roughness map, shape (B, 1, H, W) or (1, H, W), range [0, 1]
836
+ emission: Optional emission intensity, range [0, 1] (0 = none, 1 = max)
837
+ sss: Optional SSS thickness, range [0, 1] (creates MERS output)
838
+ metal_threshold: Threshold for metalness priority over SSS (default 0.5)
839
+
840
+ Returns:
841
+ RGB (MER) or RGBA (MERS) texture, shape (B, 3/4, H, W) or (3/4, H, W)
842
+ """
843
+ has_batch = roughness.dim() == 4
844
+
845
+ if not has_batch:
846
+ roughness = roughness.unsqueeze(0)
847
+ metalness = metalness.unsqueeze(0)
848
+ if emission is not None:
849
+ emission = emission.unsqueeze(0)
850
+ if sss is not None:
851
+ sss = sss.unsqueeze(0)
852
+
853
+ b, _, h, w = roughness.shape
854
+ device = roughness.device
855
+
856
+ # R: Metalness (stored LINEAR)
857
+ metal_channel = metalness
858
+
859
+ # G: Emissive level (stored LINEAR, 0 = no emission, 255 = max)
860
+ # Note: Bedrock uses full 0-255 range (vs LabPBR where 0 and 255 both mean no emission)
861
+ if emission is not None:
862
+ emissive_channel = emission
863
+ else:
864
+ emissive_channel = torch.zeros(b, 1, h, w, device=device)
865
+
866
+ # B: Roughness (stored LINEAR)
867
+ rough_channel = roughness
868
+
869
+ channels = [metal_channel, emissive_channel, rough_channel]
870
+
871
+ # A: SSS (optional, creates MERS format)
872
+ # Metalness and SSS are mutually exclusive - metalness takes priority
873
+ if sss is not None:
874
+ # Zero out SSS where metalness exceeds threshold
875
+ is_metal = (metalness > metal_threshold).float()
876
+ sss_masked = sss * (1.0 - is_metal)
877
+ channels.append(sss_masked)
878
+
879
+ mer_tex = torch.cat(channels, dim=1)
880
+
881
+ if not has_batch:
882
+ mer_tex = mer_tex.squeeze(0)
883
+
884
+ return mer_tex
885
+
886
+
887
+ def convert_to_bedrock(
888
+ basecolor: torch.Tensor,
889
+ normal: torch.Tensor,
890
+ roughness: torch.Tensor,
891
+ metalness: torch.Tensor,
892
+ emission: torch.Tensor = None,
893
+ sss: torch.Tensor = None,
894
+ flip_normal_y: bool = True,
895
+ swap_normal_xy: bool = False,
896
+ compute_sss: bool = False,
897
+ sss_curvature_weight: float = 0.7,
898
+ sss_ao_weight: float = 0.3,
899
+ sss_blur: int = 2,
900
+ compute_emission: bool = False,
901
+ emission_threshold: float = 0.85,
902
+ emission_knee: float = 0.1,
903
+ emission_bloom: int = 0,
904
+ ) -> dict:
905
+ """
906
+ Convert Chord outputs to Bedrock RTX format.
907
+
908
+ Args:
909
+ basecolor: Albedo/basecolor map (B, 3, H, W) or (3, H, W)
910
+ normal: Normal map (B, 3, H, W) or (3, H, W)
911
+ roughness: Roughness map (B, 1, H, W) or (1, H, W)
912
+ metalness: Metalness map (B, 1, H, W) or (1, H, W)
913
+ emission: Optional emission map (if None and compute_emission=True, extracted from basecolor)
914
+ sss: Optional SSS thickness map (if None and compute_sss=True, calculated from normal curvature)
915
+ flip_normal_y: Flip Y channel for DirectX convention (default True)
916
+ swap_normal_xy: Swap X/Y channels if model outputs [Y,X,Z] (default False)
917
+ compute_sss: If True, calculate SSS thickness from normal curvature (creates MERS output)
918
+ sss_curvature_weight: Weight for curvature in SSS calculation (default 0.7)
919
+ sss_ao_weight: Weight for inverted AO in SSS calculation (default 0.3)
920
+ sss_blur: Gaussian blur radius for soft SSS look (default 2)
921
+ compute_emission: If True, extract emission from basecolor luminance
922
+ emission_threshold: Luminance threshold for emission detection (0-1, default 0.85)
923
+ emission_knee: Soft knee width for smooth emission transition (0-1, default 0.1)
924
+ emission_bloom: Gaussian blur radius for emission bloom effect (0 = disabled)
925
+
926
+ Returns:
927
+ Dictionary with PIL Images:
928
+ - 'albedo': RGB albedo texture (basecolor)
929
+ - 'normal': RGB DirectX normal texture (_normal)
930
+ - 'mer': RGB MER texture or RGBA MERS texture (_mer / _mers)
931
+ """
932
+ # Ensure tensors have consistent dimensions
933
+ has_batch = basecolor.dim() == 4
934
+
935
+ if has_batch:
936
+ basecolor = basecolor.squeeze(0)
937
+ normal = normal.squeeze(0)
938
+ roughness = roughness.squeeze(0)
939
+ metalness = metalness.squeeze(0)
940
+
941
+ # Ensure roughness/metalness are (1, H, W)
942
+ if roughness.dim() == 2:
943
+ roughness = roughness.unsqueeze(0)
944
+ if metalness.dim() == 2:
945
+ metalness = metalness.unsqueeze(0)
946
+
947
+ # Calculate SSS thickness from normal curvature if requested
948
+ if compute_sss and sss is None:
949
+ sss = calculate_sss_thickness(
950
+ normal,
951
+ ao=None, # Bedrock doesn't derive AO
952
+ curvature_weight=sss_curvature_weight,
953
+ ao_weight=sss_ao_weight,
954
+ blur_radius=sss_blur,
955
+ )
956
+
957
+ # Extract emission from basecolor luminance if requested
958
+ if compute_emission and emission is None:
959
+ emission = extract_emissive(
960
+ basecolor,
961
+ threshold=emission_threshold,
962
+ knee=emission_knee,
963
+ bloom_radius=emission_bloom,
964
+ )
965
+
966
+ # Create Bedrock textures
967
+ normal_tex = create_bedrock_normal_texture(
968
+ normal, flip_y=flip_normal_y, swap_xy=swap_normal_xy
969
+ )
970
+ mer_tex = create_mer_texture(
971
+ metalness, roughness, emission, sss
972
+ )
973
+
974
+ # Determine MER output mode based on SSS presence
975
+ mer_mode = "RGBA" if sss is not None else "RGB"
976
+
977
+ return {
978
+ 'albedo': tensor_to_pil(basecolor, mode="RGB"),
979
+ 'normal': tensor_to_pil(normal_tex, mode="RGB"),
980
+ 'mer': tensor_to_pil(mer_tex, mode=mer_mode),
981
+ }
chord/module/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ modules = {}
2
+
3
+ def register(name):
4
+ def decorator(cls):
5
+ modules[name] = cls
6
+ return cls
7
+ return decorator
8
+
9
+
10
+ def make(name, config):
11
+ model = modules[name](config)
12
+ return model
13
+
14
+
15
+ from . import (
16
+ light,
17
+ stable_diffusion,
18
+ chord,
19
+ )
chord/module/base.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+
4
+ class Base(nn.Module):
5
+ def __init__(self, config):
6
+ super().__init__()
7
+ self.config = config
8
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
9
+ self.setup()
10
+
11
+ def setup(self):
12
+ raise NotImplementedError
13
+
chord/module/chord.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import torch
3
+ from torch import nn
4
+ import torch.nn.functional as Fn
5
+ from torchvision.transforms import v2
6
+
7
+ from . import register, make
8
+ from .base import Base
9
+
10
+ from chord.util import fresnelSchlick, GeometrySchlickGGX, DistributionGGX
11
+ from chord.util import srgb_to_rgb, tone_gamma, get_positions, safe_01_div
12
+
13
+ class dummy_module(nn.Module):
14
+ def forward(self, x): return x
15
+
16
+ def post_decoder(out_dict):
17
+ out = {}
18
+ for key in out_dict.keys():
19
+ if key.startswith("approx"): continue
20
+ elif key == "normal":
21
+ out[key] = Fn.normalize(2. * out_dict[key] - 1., dim=1) / 2. + 0.5
22
+ elif key == "rou_met":
23
+ out['roughness'], out['metalness'] = out_dict['rou_met'][:,0], out_dict['rou_met'][:,1]
24
+ else: out[key] = out_dict[key]
25
+ return out
26
+
27
+ def process_irradiance(radiance, kernel_size=25, res=64):
28
+ """
29
+ Process the irradiance using PyTorch, equivalent to the original OpenCV-based function.
30
+
31
+ Args:
32
+ radiance (torch.Tensor): Input radiance tensor (H, W).
33
+ kernel_size (int): Size of the kernel for the median blur.
34
+ res (int): Target resolution for resizing the image.
35
+
36
+ Returns:
37
+ torch.Tensor: Processed radiance tensor (res, res).
38
+ """
39
+ # Ensure the input radiance is a 4D tensor (B, 1, H, W)
40
+ assert radiance.shape[1] == 1 and radiance.dim() == 4, f"Invalid radiance shape, got {radiance.shape}"
41
+ # resize to low resolution
42
+ resizer = v2.Resize(size=res, antialias=True)
43
+ radiance = resizer(radiance)
44
+
45
+ # Define a 11x11 averaging kernel
46
+ kernel = torch.ones((1, 1, 11, 11), dtype=torch.float32).to(radiance) / 121.0
47
+ # Apply convolution (averaging filter)
48
+ radiance = Fn.pad(radiance, (5,)*4, mode="reflect") # Pad for edge handling
49
+ radiance = Fn.conv2d(radiance, kernel, padding=0) # 'padding=2' to maintain input dimensions
50
+
51
+ # Clamp values and scale to [0, 255] for median filtering
52
+ radiance = torch.clamp(radiance * 255, 0, 255) # Remove batch/channel dims
53
+
54
+ # Apply median filtering
55
+ paded_radiance = Fn.pad(radiance, (kernel_size // 2,) * 4, mode="reflect") # Pad for edge handling
56
+ unfolded = Fn.unfold(paded_radiance, kernel_size) # Extract patches
57
+ radiance = torch.median(unfolded, dim=1).values.view(radiance.shape) # Median of patches
58
+
59
+ # Normalize to [0, 1]
60
+ rad_min, rad_max = radiance.amin([2,3], keepdim=True), radiance.amax([2,3], keepdim=True)
61
+ radiance = (radiance - rad_min) / (rad_max - rad_min)
62
+ return radiance
63
+
64
+ def opt_light_dir(_radiance, _num_samples=6):
65
+ '''
66
+ _radiance: (bs, 1, h, w)
67
+ '''
68
+ assert _radiance.shape[1] == 1 and _radiance.dim()==4
69
+ bs, _, h, w = _radiance.shape
70
+
71
+ def evenly_sample(_num_samples, min=0, max=2*torch.pi):
72
+ # returns torch.tensor([1, _num_samples])
73
+ return torch.tensor(range(_num_samples+1)) * (max - min) / _num_samples + min
74
+
75
+ def compute_radiance_diff(angles):
76
+ num = angles.shape[-1]
77
+ dirs = torch.cat([torch.cos(angles), torch.sin(angles)]).T
78
+ pos_dir = grid_pos.repeat(num, 1, 1, 1)
79
+ pos_mask = torch.einsum("abcd,ad->abc", pos_dir, dirs) > 0
80
+ neg_mask = torch.einsum("abcd,ad->abc", pos_dir, dirs) < 0
81
+ samples_radiance = _radiance.repeat(1,num,1,1)
82
+ radiance_diff = (samples_radiance*pos_mask[None] - samples_radiance*neg_mask[None]).sum([2,3])
83
+ return radiance_diff
84
+
85
+ angle_min, angle_max = 0, 2*torch.pi
86
+ grid_pos = Fn.normalize(get_positions(h,w,10)[...,:2], dim=-1, eps=1e-6).to(_radiance)
87
+ while(((angle_max - angle_min) > (torch.pi/90))):
88
+ angles = evenly_sample(_num_samples, angle_min, angle_max)[None].to(_radiance)
89
+ diffs = compute_radiance_diff(angles).mean(0)
90
+ angle_min = angles[:,diffs.argmax()].item() - (angle_max - angle_min)/_num_samples
91
+ angle_max = angles[:,diffs.argmax()].item() + (angle_max - angle_min)/_num_samples
92
+
93
+ light_angle = angles[:, diffs.argmax()]
94
+ return torch.tensor([torch.cos(light_angle), torch.sin(light_angle)]).to(_radiance)
95
+
96
+
97
+ def find_light_dir(raw_irradiance, light):
98
+ raw_irradiance = v2.functional.rgb_to_grayscale(raw_irradiance)
99
+ irradiance = process_irradiance(raw_irradiance)
100
+ dir = opt_light_dir(irradiance)
101
+ dir = torch.cat([dir, torch.tensor([0.5**0.5]).to(dir)])
102
+ _light = copy.deepcopy(light)
103
+ _light.direction = dir
104
+ return _light
105
+
106
+ @register("chord")
107
+ class Chord(Base):
108
+ def setup(self):
109
+ # Define forward chain
110
+ self.chain_type = self.config.get("chain_type", "chord")
111
+ self.chain = self.config.get("chain_library", {})[self.chain_type]
112
+ self.prompts = self.config.get("rgbx_prompts", {})
113
+ self.roughness_step = self.config.get("roughness_step", 10)
114
+ self.metallic_step = self.config.get("metallic_step", 0.2)
115
+
116
+ self.sd = make(self.config.stable_diffusion.name, self.config.stable_diffusion)
117
+ self.dtype = self.sd.dtype
118
+ self.device = self.sd.device
119
+
120
+ # LEGO-conditioning
121
+ self.sd.unet.ConvIns = nn.ModuleDict()
122
+ self.sd.unet.ConvOuts = nn.ModuleDict()
123
+ self.sd.unet.FirstDownBlocks = nn.ModuleDict()
124
+ self.sd.unet.LastUpBlocks = nn.ModuleDict()
125
+ for key in list(set("_".join(self.chain.values()).split("_"))) + ["noise"]:
126
+ if "0" in key or "1" in key: continue
127
+ self.sd.unet.ConvIns[key] = nn.Conv2d(4, 320, 3, 1 , 1, device=self.device, dtype=self.dtype)
128
+ self.sd.unet.ConvIns[key].load_state_dict(self.sd.unet.conv_in.state_dict())
129
+ for kout in list(set(self.chain.keys())):
130
+ self.sd.unet.ConvOuts[kout] = nn.Conv2d(320, 4, 3, 1 , 1, device=self.device, dtype=self.dtype)
131
+ self.sd.unet.ConvOuts[kout].load_state_dict(self.sd.unet.conv_out.state_dict())
132
+ self.sd.unet.LastUpBlocks[kout] = copy.deepcopy(self.sd.unet.up_blocks[-1]).to(self.device)
133
+ self.sd.unet.FirstDownBlocks[kout] = copy.deepcopy(self.sd.unet.down_blocks[0]).to(self.device)
134
+ self.sd.unet.ConvIns.train()
135
+ self.sd.unet.ConvOuts.train()
136
+ self.sd.unet.FirstDownBlocks.train()
137
+ self.sd.unet.LastUpBlocks.train()
138
+ self.sd.unet.conv_in = dummy_module()
139
+ self.sd.unet.conv_out = dummy_module()
140
+
141
+ # Load Lights
142
+ if self.config.get("prior_light", None) is None:
143
+ self.prior_light = make("point-light", {"position": [0, 0, 10]})
144
+ else:
145
+ self.prior_light = make(self.config.prior_light.name, self.config.prior_light)
146
+
147
+ # Init Embeddings
148
+ self.text_emb = {}
149
+ # Eq.3
150
+ def compute_approxIrr(self, render, basecolor):
151
+ approxIrr = safe_01_div.apply(srgb_to_rgb(render), srgb_to_rgb(basecolor))
152
+ return tone_gamma(approxIrr)
153
+ # Eq.6
154
+ @torch.no_grad()
155
+ def compute_approxRouMet(self, render, maps, seperate=False, light=None):
156
+ render = srgb_to_rgb(render)
157
+ bs, _, h, w = render.shape
158
+ light = find_light_dir(maps['approxIrr'], self.prior_light) if light is None else light
159
+ # light.direction = estimate_light_dir(render, maps)
160
+ pos = get_positions(h, w, 10).to(self.device)
161
+ cameras = torch.tensor([0, 0, 10.0]).to(self.device)
162
+
163
+ # sample grid
164
+ r_samples = torch.arange(25, 225+self.roughness_step, self.roughness_step) / 255
165
+ m_samples = torch.arange(0., 1.+self.metallic_step, self.metallic_step)
166
+
167
+ grid_maps = {} # change map size into: gs, bs, h, w, c
168
+ grid_maps['basecolor'] = maps['basecolor'][None].permute(0,1,3,4,2)
169
+ grid_maps['normal'] = maps['normal'][None].permute(0,1,3,4,2)
170
+ r_values = r_samples[:,None].repeat(1,len(m_samples)).reshape(-1,1,1,1,1).to(maps['basecolor'])
171
+ m_values = m_samples[None].repeat(len(r_samples),1).reshape(-1,1,1,1,1).to(maps['basecolor'])
172
+ # split into chunks to avoid OOM
173
+ chunk_size = 25
174
+ rgb_list, r_list, m_list = [], [], []
175
+ for _r, _m in zip(torch.split(r_values, chunk_size), torch.split(m_values, chunk_size)):
176
+ grid_maps['roughness'], grid_maps['metallic'] = _r, _m
177
+ _rgb = self.compute_render(grid_maps, cameras, pos, light)
178
+ loss = (render[None].permute(0,1,3,4,2) - _rgb).abs().sum(-1,keepdim=True)
179
+ min_idx = loss.argmin(dim=0,keepdim=True)
180
+ r_list.append(torch.gather(grid_maps['roughness'].flatten(), 0, min_idx.flatten()).reshape(min_idx.shape))
181
+ m_list.append(torch.gather(grid_maps['metallic'].flatten(), 0, min_idx.flatten()).reshape(min_idx.shape))
182
+ rgb_list.append(torch.gather(_rgb, 0, min_idx.repeat(1,1,1,1,3)))
183
+ rgb = torch.cat(rgb_list).permute(0,1,4,2,3)
184
+ roughness = torch.cat(r_list).permute(0,1,4,2,3)
185
+ metallic = torch.cat(m_list).permute(0,1,4,2,3)
186
+ loss = (render[None] - rgb).abs().sum(2,keepdim=True)
187
+ roughness = torch.gather(roughness, 0, loss.argmin(dim=0,keepdim=True))[0]
188
+ metallic = torch.gather(metallic, 0, loss.argmin(dim=0,keepdim=True))[0]
189
+ torch.cuda.empty_cache()
190
+ if seperate:
191
+ return roughness, metallic
192
+ else:
193
+ out = torch.cat([roughness, metallic, torch.zeros_like(roughness)], dim=1)
194
+ return out
195
+
196
+
197
+ @torch.no_grad()
198
+ def compute_render(self, maps, camera_position, pos, light):
199
+ '''
200
+ maps: gs, bs, h, w, c (gs: the number of grids)
201
+ '''
202
+ def cos(x, y):
203
+ return torch.clamp((x*y).sum(-1, keepdim=True), min=0, max=1)
204
+
205
+ # pre-process
206
+ albedo = srgb_to_rgb(maps['basecolor'])
207
+ normal = maps['normal'].clone()
208
+ normal[..., :2] = normal[..., [1,0]]
209
+ N = Fn.normalize((normal - 0.5) * 2.0, dim=-1, eps=1e-6)
210
+ roughness = maps['roughness']
211
+ metallic = maps['metallic']
212
+ V = Fn.normalize(camera_position - pos, dim=-1, eps=1e-6).repeat(1,1,1,1,1).to(self.device)
213
+ irradiance, L = light(pos)
214
+ irradiance, L = irradiance.repeat(1,1,1,1,1).to(self.device), L.repeat(1,1,1,1,1).to(self.device)
215
+ # rendering
216
+ H = Fn.normalize(L+V, dim=-1, eps=1e-6)
217
+ f0 = torch.ones_like(albedo).to(self.device) * 0.04
218
+ F0 = torch.lerp(f0, albedo, metallic)
219
+ F = fresnelSchlick(cos(H,V), F0)
220
+ ks = F
221
+
222
+ diffuse = (1-ks) * albedo / torch.pi
223
+ diffuse *= 1-metallic
224
+
225
+ NDF = DistributionGGX(cos(N,H), roughness)
226
+ G = GeometrySchlickGGX(cos(N,L), roughness) * GeometrySchlickGGX(cos(N,V), roughness)
227
+
228
+ numerator = NDF * G * F
229
+ denominator = 4.0 * cos(N,V) * cos(N,L) + 1e-3
230
+ specular = numerator / denominator
231
+ ambient = 0.3 * albedo
232
+
233
+ rgb = (diffuse + specular) * irradiance * cos(N,L) + ambient
234
+
235
+ return rgb
236
+
237
+ def forward(self, maps:dict):
238
+ # prepare
239
+ bs = maps['render'].shape[0]
240
+ self.sd.scheduler.set_timesteps(1)
241
+ t = self.sd.scheduler.timesteps[0]
242
+ # chain processing
243
+ pred, pred_latent, arxiv_latent = {}, {}, {}
244
+ for kout, info in self.chain.items():
245
+ info = info.split("_")
246
+ keys, ids = info[:-1], info[-1]
247
+ # Swap active LEGO blocks
248
+ self.sd.unet.down_blocks[0] = self.sd.unet.FirstDownBlocks[kout]
249
+ self.sd.unet.up_blocks[-1] = self.sd.unet.LastUpBlocks[kout]
250
+ # Eq.2, summing input latents
251
+ in_latent = 0
252
+ for k, i in zip(keys, ids):
253
+ if i=="0":
254
+ if not k in arxiv_latent.keys(): arxiv_latent[k] = self.sd.encode_imgs_deterministic(maps[k])
255
+ zx = arxiv_latent[k]
256
+ else:
257
+ zx = pred_latent[k]
258
+ in_latent += self.sd.unet.ConvIns[k](zx)
259
+ in_latent = in_latent / len(keys)
260
+ # single-step denoising
261
+ embs = self.produce_embeddings(kout, bs)
262
+ out_latent = self.sd.unet(in_latent, t, **embs)[0]
263
+ out_latent = self.sd.unet.ConvOuts[kout](out_latent)
264
+ pred_latent[kout] = self.sd.scheduler.step(out_latent, t, torch.zeros_like(zx)).pred_original_sample
265
+ pred[kout] = self.sd.decode_latents(pred_latent[kout]).float()
266
+ # compute intermediate representations
267
+ if self.chain_type in ["chord"] and kout == "basecolor":
268
+ pred['approxIrr'] = self.compute_approxIrr(maps['render'], pred['basecolor'])
269
+ pred_latent['approxIrr'] = self.sd.encode_imgs_deterministic(pred['approxIrr'])
270
+ if self.chain_type in ["chord"] and kout == "normal":
271
+ pred['approxRM'] = self.compute_approxRouMet(maps['render'], pred, seperate=False)
272
+ pred_latent['approxRM'] = self.sd.encode_imgs_deterministic(pred['approxRM'])
273
+
274
+ return pred
275
+
276
+ @torch.no_grad()
277
+ def produce_embeddings(self, key, batch_size):
278
+ if key not in self.text_emb.keys():
279
+ self.text_emb[key] = self.sd.encode_text(self.prompts[key], "max_length")
280
+ prompt_emb = self.text_emb[key].expand(batch_size, -1, -1)
281
+ return { "encoder_hidden_states": prompt_emb }
chord/module/light.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from typing import Optional
3
+ import torch.nn.functional as Fn
4
+ import math
5
+ import copy
6
+
7
+ from . import register
8
+ from .base import Base
9
+
10
+ class BaseLight(Base):
11
+ """
12
+ Base class for light models.
13
+ """
14
+
15
+ def setup(self):
16
+ pass
17
+
18
+ def forward(self, x: Optional[torch.Tensor] = None):
19
+ """
20
+ Get the light intensity.
21
+
22
+ Args:
23
+ x: positions of shape (..., 3).
24
+
25
+ Returns:
26
+ color: radiance intensity of shape (..., 3)
27
+ d: directions of shape (..., 3).
28
+ """
29
+ raise NotImplementedError
30
+
31
+
32
+ @register("point-light")
33
+ class PointLight(BaseLight):
34
+ """Point light definitions
35
+ """
36
+ def setup(self):
37
+ """Initialize point light.
38
+
39
+ Args:
40
+ position (float, float, float): World coordinate of the light.
41
+ color (float, float, float): Light color in (R, G, B).
42
+ power (float): Light power, it will be directly multiplied to each color channel.
43
+ """
44
+ position = self.config.get("position", [0., 0., 10.])
45
+ color = self.config.get("color", [23.47, 21.31, 20.79])
46
+ power = self.config.get("power", 10.)
47
+
48
+ self.register_buffer("position", torch.tensor(position))
49
+ self.register_buffer("color", torch.tensor(color) * power)
50
+
51
+ def forward(self, x: Optional[torch.Tensor] = None):
52
+ """Compute light radiance and direction.
53
+
54
+ Args:
55
+ x : World coordinate of the interacting surface. [B, H, W, 3]
56
+ Returns:
57
+ color: radiance intensity of shape [B, H, W, 3]
58
+ d: directions of shape [B, H, W, 3], V = (light_pos - world_pos)
59
+ """
60
+ distance = torch.norm(self.position - x, dim=-1, keepdim=True)
61
+ attenuation = 1.0 / (distance ** 2)
62
+ radiance = self.color * attenuation
63
+ direction = Fn.normalize(self.position - x, dim=-1)
64
+ return radiance, direction
65
+
66
+ @register("distant-light")
67
+ class DistantLight(BaseLight):
68
+ """Distant light definitions
69
+ """
70
+ def setup(self):
71
+ """Initialize distant light.
72
+
73
+ Args:
74
+ direction (float, float, float):The direction of light vector.
75
+ color (float, float, float): Light color in (R, G, B).
76
+ power (float): Light power, it will be directly multiplied to each color channel.
77
+ """
78
+ direction = self.config.get("direction", [0., 0., 1.])
79
+ color = self.config.get("color", [23.47, 21.31, 20.79])
80
+ power = self.config.get("power", 0.1)
81
+
82
+ self.register_buffer("color", torch.tensor(color) * power)
83
+ self.register_buffer("direction", Fn.normalize(torch.tensor(direction), dim=0))
84
+
85
+ def forward(self, x: Optional[torch.Tensor] = None):
86
+ """Compute light radiance and direction.
87
+
88
+ Args:
89
+ x : World coordinate of the interacting surface. [B, H, W, 3]
90
+ Returns:
91
+ color: radiance intensity of shape [B, H, W, 3]
92
+ d: directions of shape [B, H, W, 3]
93
+ """
94
+ radiance = self.color.repeat(*x.shape[:-1], 1)
95
+ direction = self.direction.repeat(*x.shape[:-1], 1)
96
+ return radiance, direction
chord/module/stable_diffusion.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torchvision.transforms import v2
3
+
4
+ from diffusers import UNet2DConditionModel, AutoencoderKL, DDIMScheduler
5
+ from transformers import CLIPTextModel, CLIPTextConfig, CLIPTokenizer
6
+
7
+ from . import register
8
+ from .base import Base
9
+
10
+
11
+ def apply_padding(model, mode):
12
+ for layer in [layer for _, layer in model.named_modules() if isinstance(layer, torch.nn.Conv2d)]:
13
+ if mode == 'circular':
14
+ layer.padding_mode = 'circular'
15
+ else:
16
+ layer.padding_mode = 'zeros'
17
+ return model
18
+
19
+ def freeze(model):
20
+ model = model.eval()
21
+ for param in model.parameters():
22
+ param.requires_grad = False
23
+ return model
24
+
25
+ @register("stable_diffusion")
26
+ class StableDiffusion(Base):
27
+ def setup(self):
28
+ hf_key = self.config.get("hf_key", None)
29
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
30
+ fp16 = self.config.get("fp16", True)
31
+ self.dtype = torch.bfloat16 if fp16 else torch.float32
32
+ vae_padding = self.config.get("vae_padding", "zeros")
33
+
34
+ self.sd_version = self.config.get("version", 2.1)
35
+ local_files_only = False
36
+ if hf_key is not None:
37
+ print(f"[INFO] using hugging face custom model key: {hf_key}")
38
+ model_key = hf_key
39
+ local_files_only = True
40
+ elif str(self.sd_version) == "2.1":
41
+ # model_key = "stabilityai/stable-diffusion-2-1"
42
+ # StabilityAI deleted the original 2.1 model from HF, use a community version
43
+ model_key = "RedbeardNZ/stable-diffusion-2-1-base"
44
+ else:
45
+ raise ValueError(
46
+ f"Stable-diffusion version {self.sd_version} not supported."
47
+ )
48
+
49
+ # Load components separately to avoid download unnecessary weights
50
+ # 1. UNet (diffusion backbone)
51
+ unet_config = UNet2DConditionModel.load_config(model_key, subfolder="unet")
52
+ self.unet = UNet2DConditionModel.from_config(unet_config, local_files_only=local_files_only)
53
+ self.unet.to(self.device, dtype=self.dtype).eval()
54
+ # 2. VAE (image autoencoder)
55
+ vae_config = AutoencoderKL.load_config(model_key, subfolder="vae")
56
+ self.vae = AutoencoderKL.from_config(vae_config, local_files_only=local_files_only)
57
+ self.vae.to(self.device, dtype=self.dtype).eval()
58
+ self.vae = apply_padding(freeze(self.vae), vae_padding)
59
+ # 3. Text encoder (CLIP)
60
+ text_encoder_config = CLIPTextConfig.from_pretrained(model_key, subfolder="text_encoder", local_files_only=local_files_only)
61
+ self.text_encoder = CLIPTextModel(text_encoder_config)
62
+ self.text_encoder.to(self.device, dtype=self.dtype).eval()
63
+ # 4. Tokenizer (CLIP tokenizer, this one has vocab so from_pretrained is needed)
64
+ self.tokenizer = CLIPTokenizer.from_pretrained(model_key, subfolder="tokenizer", local_files_only=local_files_only)
65
+ # 5. Scheduler
66
+ scheduler_config = DDIMScheduler.load_config(model_key, subfolder="scheduler")
67
+ scheduler_config["prediction_type"] = "v_prediction"
68
+ scheduler_config["timestep_spacing"] = "trailing"
69
+ scheduler_config["rescale_betas_zero_snr"] = True
70
+ self.scheduler = DDIMScheduler.from_config(scheduler_config)
71
+
72
+ def encode_text(self, prompt, padding_mode="do_not_pad"):
73
+ # prompt: [str]
74
+ inputs = self.tokenizer(
75
+ prompt,
76
+ padding=padding_mode,
77
+ max_length=self.tokenizer.model_max_length,
78
+ return_tensors="pt",
79
+ )
80
+ embeddings = self.text_encoder(inputs.input_ids.to(self.device))[0]
81
+ return embeddings
82
+
83
+ def decode_latents(self, latents):
84
+ latents = 1 / self.vae.config.scaling_factor * latents
85
+ imgs = self.vae.decode(latents).sample
86
+ imgs = (imgs / 2 + 0.5).clamp(0, 1)
87
+ return imgs
88
+
89
+ def encode_imgs(self, imgs):
90
+ if imgs.shape[1] == 1: # for grayscale maps
91
+ imgs = v2.functional.grayscale_to_rgb(imgs)
92
+ imgs = 2 * imgs - 1
93
+ posterior = self.vae.encode(imgs).latent_dist
94
+ latents = posterior.sample() * self.vae.config.scaling_factor
95
+ return latents
96
+
97
+ def encode_imgs_deterministic(self, imgs):
98
+ if imgs.shape[1] == 1: # for grayscale maps
99
+ imgs = v2.functional.grayscale_to_rgb(imgs)
100
+ imgs = 2 * imgs - 1
101
+ h = self.vae.encoder(imgs)
102
+ moments = self.vae.quant_conv(h)
103
+ mean, logvar = torch.chunk(moments, 2, dim=1)
104
+ latents = mean * self.vae.config.scaling_factor
105
+ return latents
chord/normal_utils.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Normal Map Utilities
3
+
4
+ Derives height and ambient occlusion from normal maps.
5
+
6
+ Height: Frankot-Chellappa algorithm (FFT-based gradient integration)
7
+ AO: Divergence of normal vectors (concave areas have higher occlusion)
8
+ """
9
+
10
+ import torch
11
+ import torch.nn.functional as F
12
+
13
+
14
+ def normal_to_gradient(normal: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
15
+ """
16
+ Extract surface gradients from a normal map.
17
+
18
+ Normal maps store (nx, ny, nz) where the surface gradient is:
19
+ - dz/dx = -nx/nz
20
+ - dz/dy = -ny/nz
21
+
22
+ For normalized normals in [0,1] range (0.5 = neutral):
23
+ - nx = (R - 0.5) * 2
24
+ - ny = (G - 0.5) * 2
25
+
26
+ Args:
27
+ normal: Normal map tensor (B, 3, H, W) or (3, H, W), range [0, 1]
28
+
29
+ Returns:
30
+ grad_x, grad_y: Surface gradients
31
+ """
32
+ if normal.dim() == 3:
33
+ normal = normal.unsqueeze(0)
34
+
35
+ # Convert from [0,1] to [-1,1]
36
+ nx = (normal[:, 0:1, :, :] - 0.5) * 2.0
37
+ ny = (normal[:, 1:2, :, :] - 0.5) * 2.0
38
+
39
+ return nx, ny
40
+
41
+
42
+ def frankot_chellappa(grad_x: torch.Tensor, grad_y: torch.Tensor) -> torch.Tensor:
43
+ """
44
+ Reconstruct height map from gradients using Frankot-Chellappa algorithm.
45
+
46
+ This uses FFT-based integration to find a height field Z such that:
47
+ dZ/dx ≈ grad_x and dZ/dy ≈ grad_y
48
+
49
+ Args:
50
+ grad_x: X gradient (B, 1, H, W)
51
+ grad_y: Y gradient (B, 1, H, W)
52
+
53
+ Returns:
54
+ Height map (B, 1, H, W), normalized to [0, 1]
55
+ """
56
+ b, _, h, w = grad_x.shape
57
+ device = grad_x.device
58
+ dtype = grad_x.dtype
59
+
60
+ # Create frequency grids
61
+ rows_scale = (torch.arange(h, device=device, dtype=dtype) - (h // 2 + 1)) / (h - h % 2)
62
+ cols_scale = (torch.arange(w, device=device, dtype=dtype) - (w // 2 + 1)) / (w - w % 2)
63
+
64
+ v_grid, u_grid = torch.meshgrid(rows_scale, cols_scale, indexing='ij')
65
+ u_grid = torch.fft.ifftshift(u_grid)
66
+ v_grid = torch.fft.ifftshift(v_grid)
67
+
68
+ # FFT of gradients
69
+ grad_x_F = torch.fft.fft2(grad_x.squeeze(1))
70
+ grad_y_F = torch.fft.fft2(grad_y.squeeze(1))
71
+
72
+ # Integrate in frequency domain
73
+ # Z_F = (-j*u*Gx - j*v*Gy) / (u^2 + v^2)
74
+ numerator = (-1j * u_grid * grad_x_F) + (-1j * v_grid * grad_y_F)
75
+ denominator = (u_grid ** 2) + (v_grid ** 2) + 1e-16
76
+ Z_F = numerator / denominator
77
+ Z_F[:, 0, 0] = 0.0 # Set DC component to zero
78
+
79
+ # Inverse FFT to get height
80
+ Z = torch.real(torch.fft.ifft2(Z_F)).unsqueeze(1)
81
+
82
+ # Normalize to [0, 1]
83
+ Z_min = Z.amin(dim=(2, 3), keepdim=True)
84
+ Z_max = Z.amax(dim=(2, 3), keepdim=True)
85
+ Z = (Z - Z_min) / (Z_max - Z_min + 1e-8)
86
+
87
+ return Z
88
+
89
+
90
+ def make_seamless_gradients(grad_x: torch.Tensor, grad_y: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
91
+ """
92
+ Create seamless gradient field by mirroring.
93
+
94
+ Concatenates 4 flipped copies to create a seamless tileable field,
95
+ doubling the dimensions.
96
+
97
+ Args:
98
+ grad_x, grad_y: Gradients (B, 1, H, W)
99
+
100
+ Returns:
101
+ Seamless gradients (B, 1, 2H, 2W)
102
+ """
103
+ # Flip operations
104
+ grad_x_hflip = torch.flip(grad_x, dims=[3]) # Horizontal flip
105
+ grad_x_vflip = torch.flip(grad_x, dims=[2]) # Vertical flip
106
+ grad_x_hvflip = torch.flip(grad_x, dims=[2, 3]) # Both
107
+
108
+ grad_y_hflip = torch.flip(grad_y, dims=[3])
109
+ grad_y_vflip = torch.flip(grad_y, dims=[2])
110
+ grad_y_hvflip = torch.flip(grad_y, dims=[2, 3])
111
+
112
+ # Create 2x2 grid with appropriate sign flips for seamless tiling
113
+ grad_x_top = torch.cat([grad_x, -grad_x_hflip], dim=3)
114
+ grad_x_bottom = torch.cat([grad_x_vflip, -grad_x_hvflip], dim=3)
115
+ new_grad_x = torch.cat([grad_x_top, grad_x_bottom], dim=2)
116
+
117
+ grad_y_top = torch.cat([grad_y, grad_y_hflip], dim=3)
118
+ grad_y_bottom = torch.cat([-grad_y_vflip, -grad_y_hvflip], dim=3)
119
+ new_grad_y = torch.cat([grad_y_top, grad_y_bottom], dim=2)
120
+
121
+ return new_grad_x, new_grad_y
122
+
123
+
124
+ def normal_to_height(
125
+ normal: torch.Tensor,
126
+ seamless: bool = False,
127
+ ) -> torch.Tensor:
128
+ """
129
+ Convert normal map to height map using Frankot-Chellappa algorithm.
130
+
131
+ Args:
132
+ normal: Normal map (B, 3, H, W) or (3, H, W), range [0, 1]
133
+ seamless: If True, input is assumed tileable and output will tile seamlessly
134
+
135
+ Returns:
136
+ Height map (B, 1, H, W) or (1, H, W), range [0, 1]
137
+ """
138
+ squeeze = normal.dim() == 3
139
+ if squeeze:
140
+ normal = normal.unsqueeze(0)
141
+
142
+ b, _, h, w = normal.shape
143
+
144
+ # Extract gradients from normal map
145
+ grad_x, grad_y = normal_to_gradient(normal)
146
+
147
+ # For non-seamless, use mirror trick to avoid edge artifacts
148
+ if not seamless:
149
+ grad_x, grad_y = make_seamless_gradients(grad_x, grad_y)
150
+
151
+ # Reconstruct height via FFT integration
152
+ height = frankot_chellappa(-grad_x, grad_y)
153
+
154
+ # Crop back to original size if we expanded
155
+ if not seamless:
156
+ height = height[:, :, :h, :w]
157
+
158
+ if squeeze:
159
+ height = height.squeeze(0)
160
+
161
+ return height
162
+
163
+
164
+ def compute_divergence(normal: torch.Tensor) -> torch.Tensor:
165
+ """
166
+ Compute divergence of 2D normal field.
167
+
168
+ Divergence = dnx/dx + dny/dy
169
+
170
+ High positive divergence = concave (crevices) → more AO
171
+ Negative divergence = convex (bumps) → less AO
172
+
173
+ Args:
174
+ normal: Normal map (B, 3, H, W) or (3, H, W), range [0, 1]
175
+
176
+ Returns:
177
+ Divergence field (B, 1, H, W) or (1, H, W)
178
+ """
179
+ squeeze = normal.dim() == 3
180
+ if squeeze:
181
+ normal = normal.unsqueeze(0)
182
+
183
+ # Convert to [-1, 1] range
184
+ nx = (normal[:, 0:1, :, :] - 0.5) * 2.0
185
+ ny = (normal[:, 1:2, :, :] - 0.5) * 2.0
186
+
187
+ # Sobel-like kernels for derivatives
188
+ # dnx/dx
189
+ kernel_x = torch.tensor([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]],
190
+ dtype=normal.dtype, device=normal.device).view(1, 1, 3, 3) / 8.0
191
+ # dny/dy
192
+ kernel_y = torch.tensor([[-1, -2, -1], [0, 0, 0], [1, 2, 1]],
193
+ dtype=normal.dtype, device=normal.device).view(1, 1, 3, 3) / 8.0
194
+
195
+ # Compute derivatives with replicate padding
196
+ dnx_dx = F.conv2d(nx, kernel_x, padding=1)
197
+ dny_dy = F.conv2d(ny, kernel_y, padding=1)
198
+
199
+ divergence = dnx_dx + dny_dy
200
+
201
+ if squeeze:
202
+ divergence = divergence.squeeze(0)
203
+
204
+ return divergence
205
+
206
+
207
+ def normal_to_ao(
208
+ normal: torch.Tensor,
209
+ strength: float = 1.0,
210
+ blur_radius: int = 3,
211
+ ) -> torch.Tensor:
212
+ """
213
+ Approximate ambient occlusion from normal map using divergence.
214
+
215
+ Concave areas (crevices) have positive divergence and should be darker.
216
+ Convex areas (bumps) have negative divergence and should be lighter.
217
+
218
+ Args:
219
+ normal: Normal map (B, 3, H, W) or (3, H, W), range [0, 1]
220
+ strength: AO intensity multiplier
221
+ blur_radius: Gaussian blur radius for smoothing
222
+
223
+ Returns:
224
+ AO map (B, 1, H, W) or (1, H, W), range [0, 1]
225
+ 1.0 = no occlusion, 0.0 = full occlusion
226
+ """
227
+ squeeze = normal.dim() == 3
228
+ if squeeze:
229
+ normal = normal.unsqueeze(0)
230
+
231
+ # Compute divergence
232
+ div = compute_divergence(normal)
233
+
234
+ # Apply gaussian blur to smooth the AO
235
+ if blur_radius > 0:
236
+ kernel_size = blur_radius * 2 + 1
237
+ sigma = blur_radius / 2.0
238
+
239
+ # Create 1D Gaussian kernel
240
+ x = torch.arange(kernel_size, dtype=normal.dtype, device=normal.device) - blur_radius
241
+ gauss_1d = torch.exp(-x ** 2 / (2 * sigma ** 2))
242
+ gauss_1d = gauss_1d / gauss_1d.sum()
243
+
244
+ # Separable 2D convolution
245
+ gauss_h = gauss_1d.view(1, 1, 1, -1)
246
+ gauss_v = gauss_1d.view(1, 1, -1, 1)
247
+
248
+ div = F.pad(div, (blur_radius, blur_radius, blur_radius, blur_radius), mode='replicate')
249
+ div = F.conv2d(div, gauss_h)
250
+ div = F.conv2d(div, gauss_v)
251
+
252
+ # Convert divergence to AO
253
+ # Positive divergence (concave) → darker → lower AO
254
+ # We want: high div → low AO, low div → high AO
255
+ # AO = 1 - clamp(div * strength, 0, 1)
256
+ ao = 1.0 - torch.clamp(div * strength, 0.0, 1.0)
257
+
258
+ # Ensure output is in [0, 1]
259
+ ao = torch.clamp(ao, 0.0, 1.0)
260
+
261
+ if squeeze:
262
+ ao = ao.squeeze(0)
263
+
264
+ return ao
265
+
266
+
267
+ def derive_ao_and_height(
268
+ normal: torch.Tensor,
269
+ seamless: bool = False,
270
+ ao_strength: float = 2.0,
271
+ ao_blur: int = 5,
272
+ ) -> tuple[torch.Tensor, torch.Tensor]:
273
+ """
274
+ Derive both AO and height from a normal map.
275
+
276
+ Args:
277
+ normal: Normal map (B, 3, H, W) or (3, H, W), range [0, 1]
278
+ seamless: Whether the texture should tile seamlessly
279
+ ao_strength: AO intensity multiplier
280
+ ao_blur: Gaussian blur radius for AO smoothing
281
+
282
+ Returns:
283
+ ao: Ambient occlusion (1.0 = no occlusion)
284
+ height: Height map (0.0 = low, 1.0 = high)
285
+ """
286
+ ao = normal_to_ao(normal, strength=ao_strength, blur_radius=ao_blur)
287
+ height = normal_to_height(normal, seamless=seamless)
288
+
289
+ return ao, height
chord/util.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+
3
+ def vector_dot(A: torch.Tensor, B: torch.Tensor, min=0.0) -> torch.Tensor:
4
+ return torch.clamp((A * B).sum(1, keepdim=True), min=min, max=1.0)
5
+
6
+ def srgb_to_rgb(f: torch.Tensor) -> torch.Tensor:
7
+ return torch.where(f <= 0.04045, f / 12.92, torch.pow((torch.clamp(f, 0.04045) + 0.055) / 1.055, 2.4)).to(f.dtype)
8
+
9
+ def rgb_to_srgb(f: torch.Tensor) -> torch.Tensor:
10
+ return torch.where(f <= 0.0031308, f * 12.92, torch.pow(torch.clamp(f, 0.0031308), 1.0/2.4)*1.055 - 0.055).to(f.dtype)
11
+
12
+ def tone_gamma(x: torch.Tensor) -> torch.Tensor:
13
+ x = 1 - torch.exp(-x)
14
+ return torch.pow(x, 1.0/2.2)
15
+
16
+ # safe division for value range 0-1
17
+ class safe_01_div(torch.autograd.Function):
18
+ @staticmethod
19
+ def forward(ctx, a, b):
20
+ ctx.save_for_backward(a, b)
21
+ return torch.div(a, torch.clamp(b, min=1e-4, max=1.0))
22
+
23
+ @staticmethod
24
+ def backward(ctx, grad_output):
25
+ a, b = ctx.saved_tensors
26
+ grad_input = grad_output.clone()
27
+
28
+ return torch.div(1, torch.clamp(b, min=1e-4, max=1.0)) * grad_input, -1 * torch.div(a, torch.clamp(b, min=1e-2, max=1.0)**2) * grad_input
29
+
30
+
31
+ def get_positions(h, w, real_size, use_pixel_centers=True) -> torch.Tensor:
32
+ pixel_center = 0.5 if use_pixel_centers else 0
33
+ i, j = torch.meshgrid(
34
+ torch.arange(h) + pixel_center,
35
+ torch.arange(w) + pixel_center,
36
+ indexing='ij'
37
+ )
38
+ if not isinstance(real_size, list):
39
+ real_size = [real_size] * 2
40
+ pos = torch.stack([(i / h - 0.5) * real_size[0], (j / w - 0.5) * real_size[1], torch.zeros_like(i)], dim=-1)
41
+ return pos
42
+
43
+ # N, H: (Bx3xHxW), roughness: (Bx1xHxW)
44
+ # The "D", facet distribution function in Cook-Torrence model
45
+ def DistributionGGX(cosNH, roughness):
46
+ a = roughness * roughness
47
+ a2 = a * a
48
+ cosNH2 = cosNH * cosNH
49
+ num = a2
50
+ denom = cosNH2 * (a2 - 1.0) + 1.0
51
+ denom = torch.pi * denom * denom
52
+ return num / denom
53
+
54
+ # NdotV, roughness: (Bx1xHxW)
55
+ def GeometrySchlickGGX(NdotV: torch.Tensor, roughness: torch.Tensor) -> torch.Tensor:
56
+ r = (roughness + 1.0)
57
+ k = (r*r) / 8.0
58
+
59
+ num = NdotV
60
+ denom = NdotV * (1.0 - k) + k
61
+
62
+ return num / denom
63
+
64
+ # cosTheta, F0 (Bx1xHxW)
65
+ # The "F"
66
+ def fresnelSchlick(cosTheta: torch.Tensor, F0: torch.Tensor) -> torch.Tensor:
67
+ return F0 + (1.0 - F0) * torch.pow(1.0 - cosTheta, 5.0)
config/chord.yaml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model:
2
+ name: chord
3
+ roughness_step: 5.
4
+ metallic_step: 1.
5
+ # format: "OutputMapName": ConvInInput1_ConvInInput2_{0/1}
6
+ # 0/1 stands for using gt/pred image;
7
+ chain_type: chord
8
+ chain_library:
9
+ chord:
10
+ basecolor: render_0
11
+ normal: render_approxIrr_01
12
+ rou_met: render_approxRM_01
13
+ rgbx_prompts:
14
+ basecolor: Basecolor
15
+ normal: Normal
16
+ roughness: Roughness
17
+ metallic: Metallic
18
+ irradiance: Irradiance
19
+ rou_met: Roughness and Metallic
20
+ prior_light:
21
+ name: distant-light
22
+ direction: [-1.0, -1.0, 1.0] # Top-left corner towards bottom right
23
+ color: [23.47, 21.31, 20.79]
24
+ power: 0.1
25
+ stable_diffusion:
26
+ name: stable_diffusion
27
+ fp16: true
28
+ vae_padding: circular
29
+ version: 2.1
examples/generated/fabric.jpg ADDED

Git LFS Details

  • SHA256: 841890d9b7de8194442d720a83c1cb367e37dab1dc7814433cd4c3593c03ec53
  • Pointer size: 131 Bytes
  • Size of remote file: 521 kB
examples/generated/ironwall.jpg ADDED

Git LFS Details

  • SHA256: 205a24b34fde03d06cbe1556c737e097daae0b43cd89d0ba4c0e086f7e9d9ca8
  • Pointer size: 131 Bytes
  • Size of remote file: 242 kB
examples/generated/pasta.jpg ADDED

Git LFS Details

  • SHA256: a1a1324ae420fad726a840ac5ece83564a888d6489268c69166fbf7a2d45822c
  • Pointer size: 131 Bytes
  • Size of remote file: 459 kB
examples/generated/stone.jpg ADDED

Git LFS Details

  • SHA256: 7f605a24eb4a6ccbf4711246a079924dc57aca5af4b7e76002fc4b9af6e3c11e
  • Pointer size: 131 Bytes
  • Size of remote file: 491 kB
examples/generated/wood.jpg ADDED

Git LFS Details

  • SHA256: fe03b9eddcf0cd5ae665fc56be53a7ee701a4612090dd7412f1777fbfc844129
  • Pointer size: 131 Bytes
  • Size of remote file: 410 kB
examples/in_the_wild/wild_1.jpg ADDED

Git LFS Details

  • SHA256: f94cf069cc4ba49e891d5faaee3f1e9be69427e0790e13dd1d84c642adf07fbd
  • Pointer size: 131 Bytes
  • Size of remote file: 796 kB
examples/in_the_wild/wild_2.jpg ADDED

Git LFS Details

  • SHA256: 02ad8a56c031b10e46f0e3813464a872afda25e844c071de981e3e16a7f383ce
  • Pointer size: 131 Bytes
  • Size of remote file: 415 kB
examples/in_the_wild/wild_3.jpg ADDED

Git LFS Details

  • SHA256: a91aee08e0258cd155c1074319042653a0f0e9044088afb118a7a23d0b8bec75
  • Pointer size: 131 Bytes
  • Size of remote file: 435 kB
examples/in_the_wild/wild_4.jpg ADDED

Git LFS Details

  • SHA256: 1a93668593210a6d7fae00d39027238fe39ca2b8c7b23793f4c6606ccf64d129
  • Pointer size: 131 Bytes
  • Size of remote file: 529 kB
examples/in_the_wild/wild_5.jpg ADDED

Git LFS Details

  • SHA256: ddbb353e9e539fc6e95d6faffde615583b4894a810552ab9dabb8ea79c8b8c51
  • Pointer size: 131 Bytes
  • Size of remote file: 476 kB
examples/specular/ceiling.jpg ADDED

Git LFS Details

  • SHA256: 1242812ca992e858218b01f93752fcedb5281bf9dfe5471da6f41e4f370fb6a9
  • Pointer size: 131 Bytes
  • Size of remote file: 253 kB
examples/specular/chain.jpg ADDED

Git LFS Details

  • SHA256: 4d9ae665fcb77f8107e570c7e9c1f3cb35ed542ee90c28c9606873a8d3a71c35
  • Pointer size: 131 Bytes
  • Size of remote file: 337 kB
examples/specular/metal.jpg ADDED

Git LFS Details

  • SHA256: 3c4884db8a4b2610af026212eb74ee06a9b14e92460bc2d6757a1c8786b3202c
  • Pointer size: 131 Bytes
  • Size of remote file: 288 kB
examples/specular/titanium.jpg ADDED

Git LFS Details

  • SHA256: a5cadab527a3fb1a2df1c53694f52b4cacfbbb50788fa3b1c09b94efae5d1130
  • Pointer size: 131 Bytes
  • Size of remote file: 589 kB
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cu128
2
+ torch
3
+ torchvision
4
+ huggingface_hub[hf_xet]
5
+ diffusers==0.35.2
6
+ transformers==4.57.1
7
+ tokenizers==0.22.1
8
+ safetensors
9
+ omegaconf
10
+ imageio
11
+ gradio
12
+ spaces
13
+ python-dotenv
test_minecraft_pbr.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quick test of Minecraft PBR conversion without running full model."""
2
+ import torch
3
+ from chord.minecraft_pbr import convert_to_labpbr, convert_to_bedrock
4
+ from chord.normal_utils import derive_ao_and_height
5
+
6
+ # Create dummy tensors (simulating model output)
7
+ h, w = 256, 256
8
+ basecolor = torch.rand(3, h, w)
9
+ normal = torch.rand(3, h, w) * 0.5 + 0.25 # Center around 0.5
10
+ roughness = torch.rand(1, h, w)
11
+ metalness = torch.rand(1, h, w)
12
+
13
+ print("Testing AO and height derivation from normal map...")
14
+ ao, height = derive_ao_and_height(normal)
15
+ print(f" AO shape: {ao.shape}, range: [{ao.min():.3f}, {ao.max():.3f}]")
16
+ print(f" Height shape: {height.shape}, range: [{height.min():.3f}, {height.max():.3f}]")
17
+
18
+ print("\nTesting Minecraft PBR (LabPBR format) conversion...")
19
+ result = convert_to_labpbr(
20
+ basecolor=basecolor,
21
+ normal=normal,
22
+ roughness=roughness,
23
+ metalness=metalness,
24
+ derive_ao_height=True,
25
+ )
26
+
27
+ print(f" Albedo: {result['albedo'].size} {result['albedo'].mode}")
28
+ print(f" Specular: {result['specular'].size} {result['specular'].mode}")
29
+ print(f" Normal: {result['normal'].size} {result['normal'].mode}")
30
+
31
+ # Save LabPBR test outputs
32
+ result['albedo'].save('test_albedo.png')
33
+ result['specular'].save('test_specular_s.png')
34
+ result['normal'].save('test_normal_n.png')
35
+ print("Saved: test_albedo.png, test_specular_s.png, test_normal_n.png")
36
+
37
+ print("\nTesting Minecraft PBR (Bedrock RTX format) conversion...")
38
+ bedrock_result = convert_to_bedrock(
39
+ basecolor=basecolor,
40
+ normal=normal,
41
+ roughness=roughness,
42
+ metalness=metalness,
43
+ compute_sss=True,
44
+ compute_emission=True,
45
+ )
46
+
47
+ print(f" Albedo: {bedrock_result['albedo'].size} {bedrock_result['albedo'].mode}")
48
+ print(f" MER: {bedrock_result['mer'].size} {bedrock_result['mer'].mode}")
49
+ print(f" Normal: {bedrock_result['normal'].size} {bedrock_result['normal'].mode}")
50
+
51
+ # Save Bedrock test outputs
52
+ bedrock_result['albedo'].save('test_bedrock_albedo.png')
53
+ bedrock_result['mer'].save('test_bedrock_mer.png')
54
+ bedrock_result['normal'].save('test_bedrock_normal.png')
55
+ print("Saved: test_bedrock_albedo.png, test_bedrock_mer.png, test_bedrock_normal.png")
56
+
57
+ print("\nAll tests passed!")