Spaces:
Running on Zero
Running on Zero
Initial commit
Browse filesCopied chord-demo and modified for Minecraft PBR material standards.
- .gitattributes +1 -0
- .gitignore +28 -0
- LICENSE +99 -0
- README.md +38 -8
- app.py +315 -0
- chord/__init__.py +14 -0
- chord/io.py +100 -0
- chord/minecraft_pbr.py +981 -0
- chord/module/__init__.py +19 -0
- chord/module/base.py +13 -0
- chord/module/chord.py +281 -0
- chord/module/light.py +96 -0
- chord/module/stable_diffusion.py +105 -0
- chord/normal_utils.py +289 -0
- chord/util.py +67 -0
- config/chord.yaml +29 -0
- examples/generated/fabric.jpg +3 -0
- examples/generated/ironwall.jpg +3 -0
- examples/generated/pasta.jpg +3 -0
- examples/generated/stone.jpg +3 -0
- examples/generated/wood.jpg +3 -0
- examples/in_the_wild/wild_1.jpg +3 -0
- examples/in_the_wild/wild_2.jpg +3 -0
- examples/in_the_wild/wild_3.jpg +3 -0
- examples/in_the_wild/wild_4.jpg +3 -0
- examples/in_the_wild/wild_5.jpg +3 -0
- examples/specular/ceiling.jpg +3 -0
- examples/specular/chain.jpg +3 -0
- examples/specular/metal.jpg +3 -0
- examples/specular/titanium.jpg +3 -0
- requirements.txt +13 -0
- test_minecraft_pbr.py +57 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
|
| 11 |
-
short_description: Chord for Minecraft shader PBR textures (Java and Bedrock)
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
examples/generated/ironwall.jpg
ADDED
|
Git LFS Details
|
examples/generated/pasta.jpg
ADDED
|
Git LFS Details
|
examples/generated/stone.jpg
ADDED
|
Git LFS Details
|
examples/generated/wood.jpg
ADDED
|
Git LFS Details
|
examples/in_the_wild/wild_1.jpg
ADDED
|
Git LFS Details
|
examples/in_the_wild/wild_2.jpg
ADDED
|
Git LFS Details
|
examples/in_the_wild/wild_3.jpg
ADDED
|
Git LFS Details
|
examples/in_the_wild/wild_4.jpg
ADDED
|
Git LFS Details
|
examples/in_the_wild/wild_5.jpg
ADDED
|
Git LFS Details
|
examples/specular/ceiling.jpg
ADDED
|
Git LFS Details
|
examples/specular/chain.jpg
ADDED
|
Git LFS Details
|
examples/specular/metal.jpg
ADDED
|
Git LFS Details
|
examples/specular/titanium.jpg
ADDED
|
Git LFS Details
|
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!")
|