UniLat3D commited on
Commit
0e8aff8
·
verified ·
1 Parent(s): 46afef8

Use validated latest single-view inference and step-30000 completion weights

Browse files

Pin upstream e4dfbbd and model release 1bc02eb. Retain rendered o-voxel previews and explicit later GLB export. Backview and refine disabled. Keep current UI layout. Validated on L4: 112 preview frames in 399.8s without GLB, separate 4096px PBR GLB export in 48.4s.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. app.py +70 -70
  2. configs/pipeline.json +98 -0
  3. deployment/runtime/SOURCE_PROVENANCE.json +26 -29
  4. deployment/runtime/geometry/pixal3d/models/__init__.py +78 -87
  5. deployment/runtime/geometry/pixal3d/models/structured_latent_flow.py +0 -0
  6. deployment/runtime/geometry/pixal3d/modules/projected_image_feature_extractor.py +458 -0
  7. deployment/runtime/geometry/pixal3d/pipelines/__init__.py +55 -57
  8. deployment/runtime/geometry/pixal3d/pipelines/rembg/RMBG2.py +59 -64
  9. deployment/runtime/geometry/pixal3d/representations/mesh/base.py +239 -239
  10. deployment/runtime/qwen/diffsynth/core/__init__.py +6 -8
  11. deployment/runtime/qwen/diffsynth/core/data/__init__.py +1 -1
  12. deployment/runtime/qwen/diffsynth/diffusion/__init__.py +1 -7
  13. deployment/runtime/qwen/diffsynth/diffusion/base_pipeline.py +526 -542
  14. deployment/runtime/qwen/diffsynth/diffusion/template.py +200 -209
  15. deployment/runtime/qwen/diffsynth/pipelines/qwen_image_21_pbr.py +90 -98
  16. deployment/runtime/qwen/examples/qwen_image_21/pbr/infer.py +371 -390
  17. deployment/runtime/qwen/examples/qwen_image_21/pbr/inference_utils.py +135 -0
  18. deployment/runtime/qwen/requirements.txt +6 -7
  19. deployment/runtime/requirements-python.txt +29 -31
  20. hf_space/ovoxel_results.py +117 -117
  21. hf_space/release_backend.py +176 -0
  22. hf_space/startup_progress.py +23 -23
  23. infer.py +350 -227
  24. pyproject.toml +29 -31
  25. scripts/check_runtime.py +172 -179
  26. scripts/convert_geometry_weights.py +41 -0
  27. scripts/install_runtime.py +356 -276
  28. scripts/prepare_models.py +552 -360
  29. scripts/run_example.py +156 -116
  30. supratex/frontend/camera.py +76 -79
  31. supratex/frontend/firered.py +135 -126
  32. supratex/frontend/geometry.py +174 -169
  33. supratex/frontend/geometry_loading.py +128 -69
  34. supratex/frontend/geometry_runner.py +434 -450
  35. supratex/frontend/material_model/runtime/pbr/conditioning.py +178 -0
  36. supratex/frontend/material_model/runtime/pbr/model_loading.py +136 -0
  37. supratex/frontend/material_model/runtime/pbr/predict_joint_pbr.py +592 -595
  38. supratex/frontend/material_model/runtime/pbr/qwen_pbr_factorized.py +853 -0
  39. supratex/frontend/pipeline.py +291 -265
  40. supratex/frontend/preprocess.py +128 -128
  41. supratex/frontend/qwen_image.py +180 -186
  42. supratex/inference/_kernel_config_h800.py +0 -0
  43. supratex/inference/geometry_cleanup.py +114 -0
  44. supratex/inference/native_observation.py +413 -364
  45. supratex/inference/pipeline.py +700 -552
  46. supratex/inference/reprojection/__init__.py +329 -286
  47. supratex/inference/reprojection/_common.py +117 -115
  48. supratex/inference/reprojection/_continuous.py +514 -389
  49. supratex/inference/reprojection/_project.py +283 -227
  50. supratex/inference/reprojection/_renderer.py +207 -170
app.py CHANGED
@@ -1,70 +1,70 @@
1
- """Supra-Tex entrypoint with the supplied demo presentation layer."""
2
- import spaces
3
- import gradio as gr
4
- from hf_space import native_build
5
- from hf_space import backend
6
- from hf_space import result_cache
7
- from hf_space import generation_progress
8
- from hf_space import ovoxel_results
9
- import time
10
-
11
- result_cache.prepare_examples()
12
- native_build.start()
13
- # Preload once per server process, independently of visitor sessions.
14
- backend.start_preparation()
15
-
16
-
17
- def deployment_status():
18
- return {'native_build': native_build.status(), 'models': backend.model_status()}
19
-
20
-
21
- @spaces.GPU(duration=900, size='large')
22
- def generate_on_gpu(input_image, seed, progress, generation_token=None):
23
- mesh, _asset, report = backend.generate(input_image, int(seed), progress=progress,
24
- generation_token=generation_token)
25
- return mesh, report
26
-
27
-
28
- def process_image_generator(input_image, seed=42, generation_token=None, progress=gr.Progress()):
29
- if input_image is None:
30
- raise gr.Error('Please upload an image.')
31
- print('[generation] Explicit Generate request; running released model without cache reuse', flush=True)
32
- token = generation_token if generation_progress.valid(generation_token) else generation_progress.start()
33
- try:
34
- backend.ensure_prepared(token)
35
- generation_progress.update(token, status='queued', label='Waiting for GPU')
36
- mesh, report = generate_on_gpu(input_image, int(seed), progress, token)
37
- generation_progress.update(token, status='saving', label='Saving the 3D asset')
38
- try:
39
- identifier = ovoxel_results.persist(mesh, report)
40
- except Exception as error:
41
- print(f'[result-cache] Could not persist result: {type(error).__name__}: {error}', flush=True)
42
- raise
43
- # The pipeline already rendered the saved asset. Show those views now;
44
- # exporting a GLB belongs to the separate Extract GLB action.
45
- rendered = ovoxel_results.preview_html(identifier)
46
- generation_progress.update(token, status='complete', label='Complete', finished_unix=time.time())
47
- except Exception:
48
- generation_progress.update(token, status='failed', label='Generation failed', finished_unix=time.time())
49
- raise
50
- return rendered, identifier
51
-
52
-
53
- @spaces.GPU(duration=600, size='large')
54
- def extract_glb(identifier, extraction_token=None):
55
- return ovoxel_results.extract(identifier, extraction_token)
56
-
57
-
58
- @spaces.GPU(duration=600, size='large')
59
- def recover_preview(identifier):
60
- from hf_space.preview_recovery import recover
61
- return recover(identifier)
62
-
63
-
64
- from hf_space.ui import build_ui, CSS, THEME
65
-
66
- demo = build_ui(process_image_generator, deployment_status, cached_result=result_cache.lookup, extract_glb=extract_glb, recover_preview=recover_preview,
67
- default_image=None, default_model=None, estimated_seconds=390,
68
- example_names=result_cache.EXAMPLE_NAMES, examples_status=result_cache.examples_status)
69
- demo.queue().launch(allowed_paths=['/tmp/supratex-jobs', str(result_cache.ROOT/'assets/previews'), str(result_cache.CACHE),str(ovoxel_results.ROOT), str(result_cache.ROOT/'hf_space/gallery-viewer.bundle.js'), str(result_cache.ROOT/'hf_space/gallery-lights')],
70
- css=CSS, theme=THEME)
 
1
+ """Supra-Tex entrypoint with the supplied demo presentation layer."""
2
+ import spaces
3
+ import gradio as gr
4
+ from hf_space import native_build
5
+ from hf_space import release_backend as backend
6
+ from hf_space import result_cache
7
+ from hf_space import generation_progress
8
+ from hf_space import ovoxel_results
9
+ import time
10
+
11
+ result_cache.prepare_examples()
12
+ native_build.start()
13
+ # Preload once per server process, independently of visitor sessions.
14
+ backend.start_preparation()
15
+
16
+
17
+ def deployment_status():
18
+ return {'native_build': native_build.status(), 'models': backend.model_status()}
19
+
20
+
21
+ @spaces.GPU(duration=900, size='large')
22
+ def generate_on_gpu(input_image, seed, progress, generation_token=None):
23
+ mesh, _asset, report = backend.generate(input_image, int(seed), progress=progress,
24
+ generation_token=generation_token)
25
+ return mesh, report
26
+
27
+
28
+ def process_image_generator(input_image, seed=42, generation_token=None, progress=gr.Progress()):
29
+ if input_image is None:
30
+ raise gr.Error('Please upload an image.')
31
+ print('[generation] Explicit Generate request; running released model without cache reuse', flush=True)
32
+ token = generation_token if generation_progress.valid(generation_token) else generation_progress.start()
33
+ try:
34
+ backend.ensure_prepared(token)
35
+ generation_progress.update(token, status='queued', label='Waiting for GPU')
36
+ mesh, report = generate_on_gpu(input_image, int(seed), progress, token)
37
+ generation_progress.update(token, status='saving', label='Saving the 3D asset')
38
+ try:
39
+ identifier = ovoxel_results.persist(mesh, report)
40
+ except Exception as error:
41
+ print(f'[result-cache] Could not persist result: {type(error).__name__}: {error}', flush=True)
42
+ raise
43
+ # The pipeline already rendered the saved asset. Show those views now;
44
+ # exporting a GLB belongs to the separate Extract GLB action.
45
+ rendered = ovoxel_results.preview_html(identifier)
46
+ generation_progress.update(token, status='complete', label='Complete', finished_unix=time.time())
47
+ except Exception:
48
+ generation_progress.update(token, status='failed', label='Generation failed', finished_unix=time.time())
49
+ raise
50
+ return rendered, identifier
51
+
52
+
53
+ @spaces.GPU(duration=600, size='large')
54
+ def extract_glb(identifier, extraction_token=None):
55
+ return ovoxel_results.extract(identifier, extraction_token)
56
+
57
+
58
+ @spaces.GPU(duration=600, size='large')
59
+ def recover_preview(identifier):
60
+ from hf_space.preview_recovery import recover
61
+ return recover(identifier)
62
+
63
+
64
+ from hf_space.ui import build_ui, CSS, THEME
65
+
66
+ demo = build_ui(process_image_generator, deployment_status, cached_result=result_cache.lookup, extract_glb=extract_glb, recover_preview=recover_preview,
67
+ default_image=None, default_model=None, estimated_seconds=390,
68
+ example_names=result_cache.EXAMPLE_NAMES, examples_status=result_cache.examples_status)
69
+ demo.queue().launch(allowed_paths=['/tmp/supratex-jobs', str(result_cache.ROOT/'assets/previews'), str(result_cache.CACHE),str(ovoxel_results.ROOT), str(result_cache.ROOT/'hf_space/gallery-viewer.bundle.js'), str(result_cache.ROOT/'hf_space/gallery-lights')],
70
+ css=CSS, theme=THEME)
configs/pipeline.json ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "format": "supratex_pipeline",
3
+ "schema_version": 1,
4
+ "models": {
5
+ "completion": "completion",
6
+ "material": "material"
7
+ },
8
+ "external_models": {
9
+ "qwen-base": {
10
+ "repo_id": "Qwen/Qwen-Image-2.1",
11
+ "revision": "d26bb61231c349cf6b7896fa83353113880e1ba3",
12
+ "allow_patterns": [
13
+ "processor/*",
14
+ "vae/*",
15
+ "text_encoder/*",
16
+ "transformer/*",
17
+ "LICENSE*",
18
+ "README.md"
19
+ ]
20
+ },
21
+ "geometry": {
22
+ "repo_id": "TencentARC/Pixal3D",
23
+ "revision": "b0cb2e1b794cab9aa0ac38a95d794a4d9337437f",
24
+ "allow_patterns": [
25
+ "ckpts/ss_flow_img_dit_1_3B_64_bf16.json",
26
+ "ckpts/ss_flow_img_dit_1_3B_64_bf16.safetensors",
27
+ "ckpts/ss_dec_conv3d_16l8_fp16.json",
28
+ "ckpts/ss_dec_conv3d_16l8_fp16.safetensors",
29
+ "ckpts/slat_flow_img2shape_dit_1_3B_512_bf16.json",
30
+ "ckpts/slat_flow_img2shape_dit_1_3B_512_bf16.safetensors",
31
+ "ckpts/slat_flow_img2shape_dit_1_3B_1024_bf16.json",
32
+ "ckpts/slat_flow_img2shape_dit_1_3B_1024_bf16.safetensors",
33
+ "ckpts/shape_dec_next_dc_f16c32_fp16.json",
34
+ "ckpts/shape_dec_next_dc_f16c32_fp16.safetensors"
35
+ ]
36
+ },
37
+ "moge2": {
38
+ "repo_id": "Ruicheng/moge-2-vitl-normal",
39
+ "revision": "cb0e8bbd6b1e243589717c78e750b1ba4c093acf",
40
+ "allow_patterns": [
41
+ "model.pt",
42
+ "README.md",
43
+ "LICENSE*"
44
+ ]
45
+ },
46
+ "dino": {
47
+ "repo_id": "facebook/dinov3-vitl16-pretrain-lvd1689m",
48
+ "revision": "ea8dc2863c51be0a264bab82070e3e8836b02d51",
49
+ "gated": true,
50
+ "allow_patterns": [
51
+ "config.json",
52
+ "model.safetensors",
53
+ "preprocessor_config.json",
54
+ "LICENSE*",
55
+ "README.md"
56
+ ]
57
+ },
58
+ "rmbg2": {
59
+ "repo_id": "briaai/RMBG-2.0",
60
+ "revision": "5df4c9c76d8170882c34f6986e848ee07fd0ba43",
61
+ "gated": true,
62
+ "allow_patterns": [
63
+ "config.json",
64
+ "model.safetensors",
65
+ "BiRefNet_config.py",
66
+ "birefnet.py",
67
+ "preprocessor_config.json",
68
+ "LICENSE*",
69
+ "README.md"
70
+ ]
71
+ },
72
+ "vae": {
73
+ "repo_id": "microsoft/TRELLIS.2-4B",
74
+ "revision": "af44b45f2e35a493886929c6d786e563ec68364d",
75
+ "allow_patterns": [
76
+ "ckpts/shape_enc_next_dc_f16c32_fp16.json",
77
+ "ckpts/shape_enc_next_dc_f16c32_fp16.safetensors",
78
+ "ckpts/tex_enc_next_dc_f16c32_fp16.json",
79
+ "ckpts/tex_enc_next_dc_f16c32_fp16.safetensors",
80
+ "ckpts/shape_dec_next_dc_f16c32_fp16.json",
81
+ "ckpts/shape_dec_next_dc_f16c32_fp16.safetensors",
82
+ "ckpts/tex_dec_next_dc_f16c32_fp16.json",
83
+ "ckpts/tex_dec_next_dc_f16c32_fp16.safetensors"
84
+ ]
85
+ },
86
+ "naf": {
87
+ "url": "https://github.com/valeoai/NAF/releases/download/model/naf_release.pth",
88
+ "filename": "naf_release.pth"
89
+ }
90
+ },
91
+ "defaults": {
92
+ "seed": 42,
93
+ "texture_size": 4096,
94
+ "decimation_target": 500000,
95
+ "texture_format": "png",
96
+ "attention": "auto"
97
+ }
98
+ }
deployment/runtime/SOURCE_PROVENANCE.json CHANGED
@@ -1,29 +1,26 @@
1
- {
2
- "verification": "source file presence and sizes only; no hashes calculated",
3
- "geometry": {
4
- "source": "local Pixal3D/pixal3d source tree",
5
- "git_head": "0b9ff55cbcf5f87aaae9948742cf6388a1a3ecb1",
6
- "note": "Working tree snapshot; HEAD alone does not capture local edits"
7
- },
8
- "qwen": {
9
- "source": "ckpts/qwen-runtime",
10
- "snapshot_schema": "qwen_pbr_runtime_snapshot_v1",
11
- "snapshot_created_utc": "2026-10-03T23:58:03.610370+00:00",
12
- "note": "Exact staged inference source; upstream git identity is not recorded in staged manifest"
13
- },
14
- "moge": {
15
- "source": "installed moge 2.0.0 source",
16
- "upstream": "https://github.com/microsoft/MoGe.git",
17
- "recorded_commit": "07444410f1e33f402353b99d6ccd26bd31e469e8"
18
- },
19
- "utils3d": {
20
- "source": "installed utils3d 0.0.2 Python source",
21
- "upstream": "https://github.com/EasternJournalist/utils3d",
22
- "note": "Installed wheel provenance, no git commit recorded"
23
- },
24
- "naf": {
25
- "source": "local valeoai_NAF_main Torch Hub source cache",
26
- "upstream": "https://github.com/valeoai/NAF",
27
- "note": "No git metadata in staged torch hub cache; bundled source is the pin"
28
- }
29
- }
 
1
+ {
2
+ "verification": "source file presence and sizes only; no hashes calculated",
3
+ "geometry": {
4
+ "source": "Bundled Pixal3D runtime based on TencentARC/Pixal3D and Microsoft TRELLIS.2",
5
+ "note": "Vendored files are the distribution reference; an exact upstream Git revision is unavailable."
6
+ },
7
+ "qwen": {
8
+ "source": "Bundled native Qwen-Image-2.1 material runtime",
9
+ "note": "Vendored files are the distribution reference; an exact upstream Git revision is unavailable."
10
+ },
11
+ "moge": {
12
+ "source": "installed moge 2.0.0 source",
13
+ "upstream": "https://github.com/microsoft/MoGe.git",
14
+ "recorded_commit": "07444410f1e33f402353b99d6ccd26bd31e469e8"
15
+ },
16
+ "utils3d": {
17
+ "source": "installed utils3d 0.0.2 Python source",
18
+ "upstream": "https://github.com/EasternJournalist/utils3d",
19
+ "note": "Installed wheel provenance, no git commit recorded"
20
+ },
21
+ "naf": {
22
+ "source": "Bundled NAF runtime",
23
+ "upstream": "https://github.com/valeoai/NAF",
24
+ "note": "Bundled source is the distribution reference; an exact upstream Git revision is unavailable."
25
+ }
26
+ }
 
 
 
deployment/runtime/geometry/pixal3d/models/__init__.py CHANGED
@@ -1,87 +1,78 @@
1
- import importlib
2
-
3
- __attributes = {
4
- # Sparse Structure
5
- 'SparseStructureEncoder': 'sparse_structure_vae',
6
- 'SparseStructureDecoder': 'sparse_structure_vae',
7
- 'SparseStructureFlowModel': 'sparse_structure_flow',
8
-
9
- # SLat Generation
10
- 'SLatFlowModel': 'structured_latent_flow',
11
- 'ElasticSLatFlowModel': 'structured_latent_flow',
12
- 'V13PSAFlowModel': 'v13psa',
13
- 'V13PSAILVFlowModel': 'v13psailv',
14
- 'V14FXFlowModel': 'v14fx',
15
-
16
- # SC-VAEs
17
- 'SparseUnetVaeEncoder': 'sc_vaes.sparse_unet_vae',
18
- 'SparseUnetVaeDecoder': 'sc_vaes.sparse_unet_vae',
19
- 'FlexiDualGridVaeEncoder': 'sc_vaes.fdg_vae',
20
- 'FlexiDualGridVaeDecoder': 'sc_vaes.fdg_vae'
21
- }
22
-
23
- __submodules = []
24
-
25
- __all__ = list(__attributes.keys()) + __submodules
26
-
27
- def __getattr__(name):
28
- if name not in globals():
29
- if name in __attributes:
30
- module_name = __attributes[name]
31
- module = importlib.import_module(f".{module_name}", __name__)
32
- globals()[name] = getattr(module, name)
33
- elif name in __submodules:
34
- module = importlib.import_module(f".{name}", __name__)
35
- globals()[name] = module
36
- else:
37
- raise AttributeError(f"module {__name__} has no attribute {name}")
38
- return globals()[name]
39
-
40
-
41
- def from_pretrained(path: str, **kwargs):
42
- """
43
- Load a model from a pretrained checkpoint.
44
-
45
- Args:
46
- path: The path to the checkpoint. Can be either local path or a Hugging Face model name.
47
- NOTE: config file and model file should take the name f'{path}.json' and f'{path}.safetensors' respectively.
48
- **kwargs: Additional arguments for the model constructor.
49
- """
50
- import os
51
- import json
52
- from safetensors.torch import load_file
53
- is_local = os.path.exists(f"{path}.json") and os.path.exists(f"{path}.safetensors")
54
-
55
- if is_local:
56
- config_file = f"{path}.json"
57
- model_file = f"{path}.safetensors"
58
- else:
59
- from huggingface_hub import hf_hub_download
60
- path_parts = path.split('/')
61
- repo_id = f'{path_parts[0]}/{path_parts[1]}'
62
- model_name = '/'.join(path_parts[2:])
63
- config_file = hf_hub_download(repo_id, f"{model_name}.json")
64
- model_file = hf_hub_download(repo_id, f"{model_name}.safetensors")
65
-
66
- with open(config_file, 'r') as f:
67
- config = json.load(f)
68
- if config['name'] == 'V14FXFlowModel':
69
- raise ValueError(
70
- 'V14FX requires strict native-checkpoint loading through '
71
- 'pixal3d.pipelines.build_v14fx_inference_model; '
72
- 'the generic non-strict pretrained loader is unsupported'
73
- )
74
- model = __getattr__(config['name'])(**config['args'], **kwargs)
75
- model.load_state_dict(load_file(model_file), strict=False)
76
-
77
- return model
78
-
79
-
80
- # For Pylance
81
- if __name__ == '__main__':
82
- from .sparse_structure_vae import SparseStructureEncoder, SparseStructureDecoder
83
- from .sparse_structure_flow import SparseStructureFlowModel
84
- from .structured_latent_flow import SLatFlowModel, ElasticSLatFlowModel
85
-
86
- from .sc_vaes.sparse_unet_vae import SparseUnetVaeEncoder, SparseUnetVaeDecoder
87
- from .sc_vaes.fdg_vae import FlexiDualGridVaeEncoder, FlexiDualGridVaeDecoder
 
1
+ import importlib
2
+
3
+ __attributes = {
4
+ # Sparse Structure
5
+ 'SparseStructureEncoder': 'sparse_structure_vae',
6
+ 'SparseStructureDecoder': 'sparse_structure_vae',
7
+ 'SparseStructureFlowModel': 'sparse_structure_flow',
8
+
9
+ # SLat Generation
10
+ 'SLatFlowModel': 'structured_latent_flow',
11
+ 'ElasticSLatFlowModel': 'structured_latent_flow',
12
+
13
+ # SC-VAEs
14
+ 'SparseUnetVaeEncoder': 'sc_vaes.sparse_unet_vae',
15
+ 'SparseUnetVaeDecoder': 'sc_vaes.sparse_unet_vae',
16
+ 'FlexiDualGridVaeEncoder': 'sc_vaes.fdg_vae',
17
+ 'FlexiDualGridVaeDecoder': 'sc_vaes.fdg_vae'
18
+ }
19
+
20
+ __submodules = []
21
+
22
+ __all__ = list(__attributes.keys()) + __submodules
23
+
24
+ def __getattr__(name):
25
+ if name not in globals():
26
+ if name in __attributes:
27
+ module_name = __attributes[name]
28
+ module = importlib.import_module(f".{module_name}", __name__)
29
+ globals()[name] = getattr(module, name)
30
+ elif name in __submodules:
31
+ module = importlib.import_module(f".{name}", __name__)
32
+ globals()[name] = module
33
+ else:
34
+ raise AttributeError(f"module {__name__} has no attribute {name}")
35
+ return globals()[name]
36
+
37
+
38
+ def from_pretrained(path: str, **kwargs):
39
+ """
40
+ Load a model from a pretrained checkpoint.
41
+
42
+ Args:
43
+ path: The path to the checkpoint. Can be either local path or a Hugging Face model name.
44
+ NOTE: config file and model file should take the name f'{path}.json' and f'{path}.safetensors' respectively.
45
+ **kwargs: Additional arguments for the model constructor.
46
+ """
47
+ import os
48
+ import json
49
+ from safetensors.torch import load_file
50
+ is_local = os.path.exists(f"{path}.json") and os.path.exists(f"{path}.safetensors")
51
+
52
+ if is_local:
53
+ config_file = f"{path}.json"
54
+ model_file = f"{path}.safetensors"
55
+ else:
56
+ from huggingface_hub import hf_hub_download
57
+ path_parts = path.split('/')
58
+ repo_id = f'{path_parts[0]}/{path_parts[1]}'
59
+ model_name = '/'.join(path_parts[2:])
60
+ config_file = hf_hub_download(repo_id, f"{model_name}.json")
61
+ model_file = hf_hub_download(repo_id, f"{model_name}.safetensors")
62
+
63
+ with open(config_file, 'r') as f:
64
+ config = json.load(f)
65
+ model = __getattr__(config['name'])(**config['args'], **kwargs)
66
+ model.load_state_dict(load_file(model_file), strict=False)
67
+
68
+ return model
69
+
70
+
71
+ # For Pylance
72
+ if __name__ == '__main__':
73
+ from .sparse_structure_vae import SparseStructureEncoder, SparseStructureDecoder
74
+ from .sparse_structure_flow import SparseStructureFlowModel
75
+ from .structured_latent_flow import SLatFlowModel
76
+
77
+ from .sc_vaes.sparse_unet_vae import SparseUnetVaeEncoder, SparseUnetVaeDecoder
78
+ from .sc_vaes.fdg_vae import FlexiDualGridVaeEncoder, FlexiDualGridVaeDecoder
 
 
 
 
 
 
 
 
 
deployment/runtime/geometry/pixal3d/models/structured_latent_flow.py CHANGED
The diff for this file is too large to render. See raw diff
 
deployment/runtime/geometry/pixal3d/modules/projected_image_feature_extractor.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen DINOv3 features with camera-aligned projection for Pixal3D inference."""
2
+ from typing import List, Optional, Tuple, Union
3
+
4
+ import numpy as np
5
+ from PIL import Image
6
+ import torch
7
+ from torch import nn
8
+ import torch.nn.functional as F
9
+ from torchvision import transforms
10
+
11
+
12
+ def project_points_to_image_batch(
13
+ points_3d: torch.Tensor,
14
+ transform_matrix: torch.Tensor,
15
+ camera_angle_x: torch.Tensor,
16
+ resolution: int = 518
17
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
18
+ """
19
+ Project 3D points to 2D image coordinates (batch processing).
20
+
21
+ Args:
22
+ points_3d: torch.Tensor, shape [N, 3] or [B, N, 3], 3D point coordinates (in [-1, 1] range)
23
+ transform_matrix: torch.Tensor, shape [B, 4, 4], camera transformation matrix
24
+ camera_angle_x: torch.Tensor, shape [B], horizontal field of view angle (radians)
25
+ resolution: int, image resolution, default 518
26
+
27
+ Returns:
28
+ points_2d: torch.Tensor, shape [B, N, 2], image coordinates [x, y]
29
+ depth: torch.Tensor, shape [B, N], depth values
30
+ valid_mask: torch.Tensor, shape [B, N], mask for points within view
31
+ """
32
+ device = points_3d.device
33
+ B = transform_matrix.shape[0]
34
+
35
+ # Ensure inputs are torch.Tensor on correct device
36
+ if not isinstance(transform_matrix, torch.Tensor):
37
+ transform_matrix = torch.tensor(transform_matrix, dtype=torch.float32, device=device)
38
+ if not isinstance(points_3d, torch.Tensor):
39
+ points_3d = torch.tensor(points_3d, dtype=torch.float32, device=device)
40
+ if not isinstance(camera_angle_x, torch.Tensor):
41
+ camera_angle_x = torch.tensor(camera_angle_x, dtype=torch.float32, device=device)
42
+
43
+ # Expand points_3d to batch dimension: [N, 3] -> [B, N, 3]
44
+ if points_3d.dim() == 2:
45
+ points_3d_batch = points_3d.unsqueeze(0).expand(B, -1, -1)
46
+ else:
47
+ points_3d_batch = points_3d
48
+ N = points_3d_batch.shape[1]
49
+
50
+ # Add homogeneous coordinates: [B, N, 3] -> [B, N, 4]
51
+ ones = torch.ones(B, N, 1, device=device, dtype=points_3d_batch.dtype)
52
+ points_homogeneous = torch.cat([points_3d_batch, ones], dim=-1) # [B, N, 4]
53
+
54
+ # Compute world to camera transformation matrix
55
+ world_to_camera = torch.linalg.inv(transform_matrix.float()).to(transform_matrix.dtype) # linalg.inv requires fp32+
56
+
57
+ # Batch transform to camera coordinate system: [B, N, 4] @ [B, 4, 4]^T -> [B, N, 3]
58
+ points_camera = torch.bmm(points_homogeneous, world_to_camera.transpose(-2, -1))[..., :3] # [B, N, 3]
59
+
60
+ # Extract camera coordinates
61
+ x_cam = points_camera[..., 0] # [B, N]
62
+ y_cam = points_camera[..., 1] # [B, N]
63
+ z_cam = points_camera[..., 2] # [B, N]
64
+
65
+ # Depth value (Z value in camera coordinate system, note Blender camera faces -Z direction)
66
+ depth = -z_cam # [B, N]
67
+
68
+ # Compute camera intrinsics (batch processing)
69
+ sensor_width = 32.0 # mm
70
+ focal_length = 16.0 / torch.tan(camera_angle_x / 2.0) # [B]
71
+ focal_length_pixels = focal_length * resolution / sensor_width # [B]
72
+
73
+ # Expand focal_length_pixels dimension for broadcasting: [B] -> [B, 1]
74
+ focal_length_pixels = focal_length_pixels.unsqueeze(1) # [B, 1]
75
+
76
+ # Perspective projection to NDC coordinates
77
+ x_ndc = focal_length_pixels * x_cam / (-z_cam + 1e-8) # [B, N]
78
+ y_ndc = focal_length_pixels * y_cam / (-z_cam + 1e-8) # [B, N]
79
+
80
+ # Convert to image coordinates (pixel coordinates)
81
+ x_pixel = x_ndc + resolution / 2.0 # [B, N]
82
+ y_pixel = -y_ndc + resolution / 2.0 # [B, N], flip Y axis
83
+
84
+ # Create validity mask (points within image range and in front of camera)
85
+ valid_mask = (
86
+ (x_pixel >= 0) & (x_pixel < resolution) &
87
+ (y_pixel >= 0) & (y_pixel < resolution) &
88
+ (depth > 0) # In front of camera
89
+ ) # [B, N]
90
+
91
+ points_2d = torch.stack([x_pixel, y_pixel], dim=-1) # [B, N, 2]
92
+
93
+ return points_2d, depth, valid_mask
94
+
95
+ def sample_features(fmap: torch.Tensor, queries_ndc: torch.Tensor) -> torch.Tensor:
96
+ """
97
+ Sample features from feature map at specified NDC coordinates.
98
+
99
+ Args:
100
+ fmap: torch.Tensor, shape [B, C, H, W], feature map
101
+ queries_ndc: torch.Tensor, shape [B, K, 2], normalized device coordinates
102
+
103
+ Returns:
104
+ torch.Tensor, shape [B, C, K], sampled features
105
+ """
106
+ B, C, H, W = fmap.shape
107
+ Bq, K, _ = queries_ndc.shape
108
+ assert Bq == B, "Batch size mismatch"
109
+
110
+ # grid_sample requires (B, out_h, out_w, 2), here we want K points -> out_h=K, out_w=1
111
+ grid = queries_ndc.view(B, K, 1, 2) # (B, K, 1, 2)
112
+
113
+ # Bilinear interpolation, align_corners=False (consistent with [-1,1] pixel center convention)
114
+ feat = F.grid_sample(
115
+ fmap, grid, mode='bilinear',
116
+ align_corners=False, padding_mode='border' # border avoids out-of-bound becoming 0
117
+ ) # (B, C, K, 1)
118
+
119
+ return feat.squeeze(-1) # (B, C, K)
120
+
121
+ class ProjGrid(nn.Module):
122
+ """
123
+ 3D Grid Projection Module.
124
+
125
+ Projects a 3D grid of points to 2D image coordinates and samples features
126
+ from the image feature map at those locations.
127
+
128
+ This is the core module for view-aligned feature extraction.
129
+ """
130
+ def __init__(self, grid_resolution: int = 16, image_resolution: int = 518):
131
+ super().__init__()
132
+ self.grid_resolution = grid_resolution
133
+ self.image_resolution = image_resolution
134
+
135
+ # Create 3D grid points
136
+ one_dim = torch.linspace(-1, 1, grid_resolution)
137
+ x, y, z = torch.meshgrid(one_dim, one_dim, one_dim, indexing='ij')
138
+ grid_points = torch.stack((x, y, z), dim=-1)
139
+
140
+ # Rotation matrix to align with Blender coordinate system
141
+ rotation_matrix = torch.tensor([
142
+ [1.0, 0.0, 0.0],
143
+ [0.0, 0.0, -1.0],
144
+ [0.0, 1.0, 0.0]
145
+ ])
146
+ grid_points = torch.matmul(grid_points, rotation_matrix.T)
147
+ grid_points = grid_points.reshape(-1, 3)
148
+ self.register_buffer('grid_points', grid_points) # [R³, 3]
149
+
150
+ # Default front view transformation matrix
151
+ front_view_transform_matrix = torch.tensor([
152
+ [1.0, 0.0, 0.0, 0.0],
153
+ [0.0, 0.0, -1.0, -2.0],
154
+ [0.0, 1.0, 0.0, 0.0],
155
+ [0.0, 0.0, 0.0, 1.0]
156
+ ])
157
+ self.register_buffer("front_view_transform_matrix", front_view_transform_matrix)
158
+
159
+ def forward(
160
+ self,
161
+ features_map: torch.Tensor,
162
+ camera_angle_x: torch.Tensor,
163
+ distance: torch.Tensor,
164
+ mesh_scale: torch.Tensor,
165
+ transform_matrix: Optional[torch.Tensor] = None,
166
+ BHWC: bool = True
167
+ ) -> torch.Tensor:
168
+ """
169
+ Project 3D grid points to image and sample features.
170
+
171
+ Args:
172
+ features_map: Feature map, shape [B, H, W, C] if BHWC else [B, C, H, W]
173
+ camera_angle_x: Camera FOV angle, shape [B]
174
+ distance: Camera distance, shape [B]
175
+ mesh_scale: Mesh scale factor, shape [B]
176
+ transform_matrix: Optional camera transform matrix, shape [B, 4, 4]
177
+ BHWC: Whether features_map is in BHWC format
178
+
179
+ Returns:
180
+ Projected features, shape [B, grid_resolution³, C]
181
+ """
182
+ if BHWC:
183
+ B, H, W, C = features_map.shape
184
+ else:
185
+ B, C, H, W = features_map.shape
186
+
187
+ grid_points = self.grid_points
188
+ grid_points = grid_points.expand(B, -1, -1)
189
+ grid_points = grid_points / mesh_scale.unsqueeze(-1).unsqueeze(-1) / 2 # Scale alignment
190
+ assert transform_matrix is None, "transform_matrix is not None"
191
+ if transform_matrix is None:
192
+ transform_matrix = self.front_view_transform_matrix
193
+ transform_matrix = transform_matrix.expand(B, -1, -1).clone()
194
+ transform_matrix[:, 1, 3] = -distance # Set camera distance
195
+
196
+ # Project to image coordinates (simulate Blender projection)
197
+ image_points, depth, valid_mask = project_points_to_image_batch(
198
+ grid_points, transform_matrix, camera_angle_x, self.image_resolution
199
+ )
200
+
201
+ # Normalize to [-1, 1] for grid_sample
202
+ image_points_norm = (image_points + 0.5) / self.image_resolution * 2 - 1
203
+
204
+ if BHWC:
205
+ features_map = features_map.permute(0, 3, 1, 2) # [B, C, H, W]
206
+
207
+ # Sample features from DINOv3 patch feature map
208
+ x = sample_features(features_map, image_points_norm) # [B, C, K]
209
+ x = x.permute(0, 2, 1) # [B, K, C]
210
+
211
+ return x
212
+
213
+
214
+ class DinoV3ProjFeatureExtractor(nn.Module):
215
+ """
216
+ DINOv3 Feature Extractor with View-Aligned Projection.
217
+
218
+ This extractor produces both:
219
+ 1. Global features (CLS token + register tokens) in embed_dim
220
+ 2. View-aligned projected features (3D grid projected to 2D and sampled)
221
+ - Without NAF: [B, R³, embed_dim]
222
+ - With NAF: [B, R³, embed_dim * 2] (concat of lr and hr features)
223
+
224
+ NOTE: proj_linear has been moved to per-block ProjectAttention / SparseProjectAttention.
225
+ This module now outputs raw DINOv3 features for proj (optionally concatenated with NAF-upsampled features).
226
+
227
+ Args:
228
+ model_name: Name of the pretrained DINOv3 model
229
+ image_size: Input image size (default: 512)
230
+ grid_resolution: Resolution of the 3D projection grid (default: 16)
231
+ use_naf_upsample: Whether to use NAF to upsample features (default: False)
232
+ naf_target_size: Target spatial size for NAF upsampling (default: [128, 128])
233
+ """
234
+ def __init__(
235
+ self,
236
+ model_name: str,
237
+ image_size: int = 512,
238
+ grid_resolution: int = 16,
239
+ use_naf_upsample: bool = False,
240
+ naf_target_size: Optional[List[int]] = None,
241
+ cls_only: bool = False,
242
+ cls_n_tokens: int = 1,
243
+ full_tokens: bool = False,
244
+ ):
245
+ super().__init__()
246
+ self.model_name = model_name
247
+ self.image_size = image_size
248
+ self.grid_resolution = grid_resolution
249
+ self.use_naf_upsample = use_naf_upsample
250
+ self.cls_only = cls_only
251
+ self.cls_n_tokens = cls_n_tokens
252
+ self.full_tokens = full_tokens
253
+ if naf_target_size is None:
254
+ self.naf_target_size = (128, 128)
255
+ elif isinstance(naf_target_size, int):
256
+ self.naf_target_size = (naf_target_size, naf_target_size)
257
+ else:
258
+ self.naf_target_size = tuple(naf_target_size)
259
+
260
+ # Load DINOv3 model (frozen, no trainable params in this module)
261
+ from transformers import DINOv3ViTModel
262
+
263
+ self.model = DINOv3ViTModel.from_pretrained(model_name)
264
+ self.model.eval()
265
+ self.model.requires_grad_(False)
266
+
267
+ # Image transform (only normalize, no resize - assume already resized)
268
+ self.transform = transforms.Compose([
269
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
270
+ ])
271
+
272
+ # Get patch info
273
+ self.patch_size = self.model.config.patch_size
274
+ self.patch_number = image_size // self.patch_size
275
+ self.embed_dim = self.model.config.hidden_size
276
+
277
+ # Projection grid for view-aligned features
278
+ self.proj_grid = ProjGrid(
279
+ grid_resolution=grid_resolution,
280
+ image_resolution=image_size
281
+ )
282
+
283
+ # NAF upsampler (frozen, no trainable params)
284
+ self.naf_model = None # Lazy-loaded on first use to avoid import if not needed
285
+
286
+ # proj_channels: the output dimension of proj features
287
+ # Without NAF: embed_dim (e.g. 1024)
288
+ # With NAF: embed_dim * 2 (e.g. 2048, concat of lr and hr)
289
+ self.proj_channels = self.embed_dim * 2 if use_naf_upsample else self.embed_dim
290
+
291
+ # NOTE: proj_linear removed — now lives in each denoiser block's ProjectAttention
292
+
293
+ def _load_naf(self):
294
+ """Lazy-load pretrained NAF model."""
295
+ if self.naf_model is None:
296
+ import torch.hub
297
+ device = next(self.model.parameters()).device
298
+ self.naf_model = torch.hub.load(
299
+ "valeoai/NAF", "naf", pretrained=True, device=device, trust_repo=True
300
+ )
301
+ self.naf_model.eval()
302
+ self.naf_model.requires_grad_(False)
303
+
304
+ def to(self, device):
305
+ super().to(device)
306
+ self.model.to(device)
307
+ self.proj_grid.to(device)
308
+ if self.naf_model is not None:
309
+ self.naf_model.to(device)
310
+ return self
311
+
312
+ def cuda(self):
313
+ super().cuda()
314
+ self.model.cuda()
315
+ self.proj_grid.cuda()
316
+ if self.naf_model is not None:
317
+ self.naf_model.cuda()
318
+ return self
319
+
320
+ def cpu(self):
321
+ super().cpu()
322
+ self.model.cpu()
323
+ self.proj_grid.cpu()
324
+ if self.naf_model is not None:
325
+ self.naf_model.cpu()
326
+ return self
327
+
328
+ def extract_features(self, image: torch.Tensor) -> torch.Tensor:
329
+ """Extract features using DINOv3."""
330
+ image = image.to(self.model.embeddings.patch_embeddings.weight.dtype)
331
+ hidden_states = self.model.embeddings(image, bool_masked_pos=None)
332
+ position_embeddings = self.model.rope_embeddings(image)
333
+
334
+ for layer_module in self.model.layer:
335
+ hidden_states = layer_module(
336
+ hidden_states,
337
+ position_embeddings=position_embeddings,
338
+ )
339
+
340
+ return F.layer_norm(hidden_states, hidden_states.shape[-1:])
341
+
342
+ def forward(
343
+ self,
344
+ image: Union[torch.Tensor, List[Image.Image]],
345
+ camera_angle_x: Optional[torch.Tensor] = None,
346
+ distance: Optional[torch.Tensor] = None,
347
+ mesh_scale: Optional[torch.Tensor] = None,
348
+ transform_matrix: Optional[torch.Tensor] = None,
349
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
350
+ """
351
+ Extract view-aligned features from the image.
352
+
353
+ Args:
354
+ image: Input image tensor [B, C, H, W] or list of PIL images
355
+ camera_angle_x: Camera FOV angle in radians [B]
356
+ distance: Camera distance [B]
357
+ mesh_scale: Mesh scale factor [B]
358
+ transform_matrix: Optional camera transform matrix [B, 4, 4]
359
+
360
+ Returns:
361
+ Tuple of (global_features, proj_features):
362
+ - global_features: [B, num_global_tokens, embed_dim]
363
+ - proj_features: [B, grid_resolution³, proj_channels]
364
+ where proj_channels = embed_dim (no NAF) or embed_dim*2 (with NAF)
365
+ """
366
+ # Handle input types
367
+ if isinstance(image, torch.Tensor):
368
+ assert image.ndim == 4, "Image tensor should be batched (B, C, H, W)"
369
+ elif isinstance(image, list):
370
+ assert all(isinstance(i, Image.Image) for i in image), "Image list should be list of PIL images"
371
+ image = [i.resize((self.image_size, self.image_size), Image.LANCZOS) for i in image]
372
+ image = [np.array(i.convert('RGB')).astype(np.float32) / 255 for i in image]
373
+ image = [torch.from_numpy(i).permute(2, 0, 1).float() for i in image]
374
+ image = torch.stack(image).cuda()
375
+ else:
376
+ raise ValueError(f"Unsupported type of image: {type(image)}")
377
+
378
+ B = image.shape[0]
379
+
380
+ # Trained-bridge CLS cross-attn mode: skip ProjGrid + NAF (no camera); return global token(s).
381
+ # cls_n_tokens=1 -> CLS only (v4 default); =5 (1 CLS + 4 register) -> faithful warm start of the
382
+ # shipped cross-attn (trained on [CLS + register] global tokens; registers are global, NOT patch).
383
+ # full_tokens=True (v6 patchcross) -> the UNSLICED [CLS|4 reg|patch...] sequence
384
+ # (1029 tokens @ image 512), i.e. the TRELLIS.2 cross-attn context. Lives in this branch
385
+ # because the non-cls path below requires camera params for ProjGrid.
386
+ if getattr(self, 'cls_only', False):
387
+ img = self.transform(image)
388
+ with torch.no_grad():
389
+ z = self.extract_features(img)
390
+ if getattr(self, 'full_tokens', False):
391
+ return z # [B, 1+4+patch_number**2, D=1024]
392
+ return z[:, 0:self.cls_n_tokens] # [B, cls_n_tokens, D=1024]
393
+
394
+ # Keep a copy of the unnormalized image for NAF guide
395
+ if self.use_naf_upsample:
396
+ image_for_naf = image.clone() # [B, 3, H, W], in [0, 1] range
397
+
398
+ # Apply transform (ImageNet normalization)
399
+ image = self.transform(image)
400
+
401
+ # Extract DINOv3 features (frozen, no gradients)
402
+ with torch.no_grad():
403
+ z = self.extract_features(image)
404
+
405
+ # Split into CLS token, register tokens, and patch tokens
406
+ z_clstoken = z[:, 0:1] # [B, 1, D]
407
+ num_reg = getattr(self.model.config, 'num_register_tokens', 4)
408
+ z_regtokens = z[:, 1:1+num_reg] # [B, num_reg, D]
409
+ z_patchtokens = z[:, 1+num_reg:] # [B, num_patches, D]
410
+
411
+ # Reshape patch tokens to spatial grid: [B, h, w, D]
412
+ z_patchtokens_spatial = z_patchtokens.reshape(
413
+ B, self.patch_number, self.patch_number, -1
414
+ ) # [B, h, w, D]
415
+
416
+ if camera_angle_x is None or distance is None or mesh_scale is None:
417
+ raise ValueError("camera_angle_x, distance, and mesh_scale must be provided")
418
+
419
+ # --- Low-resolution branch: sample from DINOv3 patch feature map ---
420
+ z_proj_lr = self.proj_grid(
421
+ z_patchtokens_spatial,
422
+ camera_angle_x,
423
+ distance,
424
+ mesh_scale,
425
+ transform_matrix
426
+ ) # [B, grid_res³, D]
427
+
428
+ # --- High-resolution branch (NAF): upsample then sample ---
429
+ if self.use_naf_upsample:
430
+ self._load_naf()
431
+ # NAF expects: guide [B, 3, H, W], lr_features [B, C, h, w], target_size (H', W')
432
+ lr_features_bchw = z_patchtokens_spatial.permute(0, 3, 1, 2) # [B, D, h, w]
433
+ hr_features = self.naf_model(
434
+ image_for_naf, lr_features_bchw, self.naf_target_size
435
+ ) # [B, D, H', W']
436
+
437
+ # Sample from high-res feature map using same projection coordinates
438
+ z_proj_hr = self.proj_grid(
439
+ hr_features,
440
+ camera_angle_x,
441
+ distance,
442
+ mesh_scale,
443
+ transform_matrix,
444
+ BHWC=False # hr_features is [B, C, H', W']
445
+ ) # [B, grid_res³, D]
446
+
447
+ # Concatenate lr and hr: [B, grid_res³, D*2]
448
+ z_proj = torch.cat([z_proj_lr, z_proj_hr], dim=-1)
449
+ else:
450
+ z_proj = z_proj_lr # [B, grid_res³, D]
451
+
452
+ # Combine global tokens
453
+ z_global = torch.cat([z_clstoken, z_regtokens], dim=1) # [B, 1+num_reg, D]
454
+
455
+ # proj_linear has been moved to per-block ProjectAttention
456
+ # z_proj stays in proj_channels, each block will project independently
457
+
458
+ return z_global, z_proj
deployment/runtime/geometry/pixal3d/pipelines/__init__.py CHANGED
@@ -1,57 +1,55 @@
1
- import importlib
2
-
3
- __attributes = {
4
- "Trellis2ImageTo3DPipeline": "trellis2_image_to_3d",
5
- "Trellis2TexturingPipeline": "trellis2_texturing",
6
- "Pixal3DImageTo3DPipeline": "pixal3d_image_to_3d",
7
- "build_v14fx_inference_model": "v14fx",
8
- "sample_v14fx_texture": "v14fx",
9
- }
10
-
11
-
12
- __submodules = ['samplers', 'rembg']
13
-
14
- __all__ = list(__attributes.keys()) + __submodules
15
-
16
- def __getattr__(name):
17
- if name not in globals():
18
- if name in __attributes:
19
- module_name = __attributes[name]
20
- module = importlib.import_module(f".{module_name}", __name__)
21
- globals()[name] = getattr(module, name)
22
- elif name in __submodules:
23
- module = importlib.import_module(f".{name}", __name__)
24
- globals()[name] = module
25
- else:
26
- raise AttributeError(f"module {__name__} has no attribute {name}")
27
- return globals()[name]
28
-
29
-
30
- def from_pretrained(path: str):
31
- """
32
- Load a pipeline from a model folder or a Hugging Face model hub.
33
-
34
- Args:
35
- path: The path to the model. Can be either local path or a Hugging Face model name.
36
- """
37
- import os
38
- import json
39
- is_local = os.path.exists(f"{path}/pipeline.json")
40
-
41
- if is_local:
42
- config_file = f"{path}/pipeline.json"
43
- else:
44
- from huggingface_hub import hf_hub_download
45
- config_file = hf_hub_download(path, "pipeline.json")
46
-
47
- with open(config_file, 'r') as f:
48
- config = json.load(f)
49
- return globals()[config['name']].from_pretrained(path)
50
-
51
-
52
- # For PyLance
53
- if __name__ == '__main__':
54
- from . import samplers, rembg
55
- from .trellis2_image_to_3d import Trellis2ImageTo3DPipeline
56
- from .trellis2_texturing import Trellis2TexturingPipeline
57
- from .pixal3d_image_to_3d import Pixal3DImageTo3DPipeline
 
1
+ import importlib
2
+
3
+ __attributes = {
4
+ "Trellis2ImageTo3DPipeline": "trellis2_image_to_3d",
5
+ "Trellis2TexturingPipeline": "trellis2_texturing",
6
+ "Pixal3DImageTo3DPipeline": "pixal3d_image_to_3d",
7
+ }
8
+
9
+
10
+ __submodules = ['samplers', 'rembg']
11
+
12
+ __all__ = list(__attributes.keys()) + __submodules
13
+
14
+ def __getattr__(name):
15
+ if name not in globals():
16
+ if name in __attributes:
17
+ module_name = __attributes[name]
18
+ module = importlib.import_module(f".{module_name}", __name__)
19
+ globals()[name] = getattr(module, name)
20
+ elif name in __submodules:
21
+ module = importlib.import_module(f".{name}", __name__)
22
+ globals()[name] = module
23
+ else:
24
+ raise AttributeError(f"module {__name__} has no attribute {name}")
25
+ return globals()[name]
26
+
27
+
28
+ def from_pretrained(path: str):
29
+ """
30
+ Load a pipeline from a model folder or a Hugging Face model hub.
31
+
32
+ Args:
33
+ path: The path to the model. Can be either local path or a Hugging Face model name.
34
+ """
35
+ import os
36
+ import json
37
+ is_local = os.path.exists(f"{path}/pipeline.json")
38
+
39
+ if is_local:
40
+ config_file = f"{path}/pipeline.json"
41
+ else:
42
+ from huggingface_hub import hf_hub_download
43
+ config_file = hf_hub_download(path, "pipeline.json")
44
+
45
+ with open(config_file, 'r') as f:
46
+ config = json.load(f)
47
+ return globals()[config['name']].from_pretrained(path)
48
+
49
+
50
+ # For PyLance
51
+ if __name__ == '__main__':
52
+ from . import samplers, rembg
53
+ from .trellis2_image_to_3d import Trellis2ImageTo3DPipeline
54
+ from .trellis2_texturing import Trellis2TexturingPipeline
55
+ from .pixal3d_image_to_3d import Pixal3DImageTo3DPipeline
 
 
deployment/runtime/geometry/pixal3d/pipelines/rembg/RMBG2.py CHANGED
@@ -1,64 +1,59 @@
1
- from typing import Optional
2
- import os
3
- from pathlib import Path
4
-
5
- from transformers import AutoModelForImageSegmentation
6
- import torch
7
- from torchvision import transforms
8
- from PIL import Image
9
-
10
-
11
- _CY_ROOT = os.environ.get("CY_ROOT", str(Path(__file__).parents[5]))
12
- RMBG2_MODEL_PATH = os.environ.get(
13
- "PIXAL3D_RMBG_MODEL_PATH",
14
- os.path.join(_CY_ROOT, "model_ckpts", "RMBG-2.0"),
15
- )
16
- _REMOTE_MODEL_NAMES = {"ZhengPeng7/BiRefNet", "briaai/RMBG-2.0"}
17
-
18
-
19
- class RMBG2:
20
- def __init__(
21
- self,
22
- model_name: str = RMBG2_MODEL_PATH,
23
- alpha_threshold: Optional[float] = None,
24
- ):
25
- if model_name in _REMOTE_MODEL_NAMES:
26
- model_name = RMBG2_MODEL_PATH
27
- if alpha_threshold is not None and not 0.0 <= alpha_threshold <= 1.0:
28
- raise ValueError(f"alpha_threshold must be in [0, 1], got {alpha_threshold}")
29
- self.model_name = model_name
30
- self.alpha_threshold = alpha_threshold
31
- self.model = AutoModelForImageSegmentation.from_pretrained(
32
- model_name, trust_remote_code=True
33
- )
34
- self.model.eval()
35
- self.transform_image = transforms.Compose(
36
- [
37
- transforms.Resize((1024, 1024)),
38
- transforms.ToTensor(),
39
- transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
40
- ]
41
- )
42
-
43
- def to(self, device: str):
44
- self.model.to(device)
45
-
46
- def cuda(self):
47
- self.model.cuda()
48
-
49
- def cpu(self):
50
- self.model.cpu()
51
-
52
- def __call__(self, image: Image.Image) -> Image.Image:
53
- image_size = image.size
54
- input_images = self.transform_image(image).unsqueeze(0).to("cuda")
55
- with torch.no_grad():
56
- preds = self.model(input_images)[-1].sigmoid().cpu()
57
- pred = preds[0].squeeze()
58
- pred_pil = transforms.ToPILImage()(pred)
59
- mask = pred_pil.resize(image_size)
60
- if self.alpha_threshold is not None:
61
- cutoff = int(round(self.alpha_threshold * 255))
62
- mask = mask.point([0 if value < cutoff else 255 for value in range(256)])
63
- image.putalpha(mask)
64
- return image
 
1
+ from typing import Optional
2
+ import os
3
+
4
+ from transformers import AutoModelForImageSegmentation
5
+ import torch
6
+ from torchvision import transforms
7
+ from PIL import Image
8
+
9
+
10
+ # A local RMBG-2.0 or BiRefNet directory can replace the Hub model.
11
+ RMBG2_MODEL_PATH = os.environ.get("PIXAL3D_RMBG_MODEL_PATH", "briaai/RMBG-2.0")
12
+
13
+
14
+ class RMBG2:
15
+ def __init__(
16
+ self,
17
+ model_name: str = RMBG2_MODEL_PATH,
18
+ alpha_threshold: Optional[float] = None,
19
+ ):
20
+ if model_name == "briaai/RMBG-2.0":
21
+ model_name = RMBG2_MODEL_PATH
22
+ if alpha_threshold is not None and not 0.0 <= alpha_threshold <= 1.0:
23
+ raise ValueError(f"alpha_threshold must be in [0, 1], got {alpha_threshold}")
24
+ self.model_name = model_name
25
+ self.alpha_threshold = alpha_threshold
26
+ self.model = AutoModelForImageSegmentation.from_pretrained(
27
+ model_name, trust_remote_code=True
28
+ )
29
+ self.model.eval()
30
+ self.transform_image = transforms.Compose(
31
+ [
32
+ transforms.Resize((1024, 1024)),
33
+ transforms.ToTensor(),
34
+ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
35
+ ]
36
+ )
37
+
38
+ def to(self, device: str):
39
+ self.model.to(device)
40
+
41
+ def cuda(self):
42
+ self.model.cuda()
43
+
44
+ def cpu(self):
45
+ self.model.cpu()
46
+
47
+ def __call__(self, image: Image.Image) -> Image.Image:
48
+ image_size = image.size
49
+ input_images = self.transform_image(image).unsqueeze(0).to("cuda")
50
+ with torch.no_grad():
51
+ preds = self.model(input_images)[-1].sigmoid().cpu()
52
+ pred = preds[0].squeeze()
53
+ pred_pil = transforms.ToPILImage()(pred)
54
+ mask = pred_pil.resize(image_size)
55
+ if self.alpha_threshold is not None:
56
+ cutoff = int(round(self.alpha_threshold * 255))
57
+ mask = mask.point([0 if value < cutoff else 255 for value in range(256)])
58
+ image.putalpha(mask)
59
+ return image
 
 
 
 
 
deployment/runtime/geometry/pixal3d/representations/mesh/base.py CHANGED
@@ -1,239 +1,239 @@
1
- from typing import *
2
- import torch
3
- from ..voxel import Voxel
4
- import cumesh
5
- # NOTE: flex_gemm is imported lazily in query_attrs (matching the renderers' pattern), NOT here.
6
- # This module sits in the DataLoader workers' import closure (dataset -> MeshWithVoxel), and
7
- # flex_gemm's triton @autotune decorators query the GPU driver AT IMPORT — in a spawned CPU-only
8
- # worker that intermittently dies with "RuntimeError: 0 active drivers", killing the rank and
9
- # wedging all other ranks in NCCL collectives (observed 2026-07-02 on the 64-rank 512-all run).
10
-
11
-
12
- class Mesh:
13
- def __init__(self,
14
- vertices,
15
- faces,
16
- vertex_attrs=None
17
- ):
18
- self.vertices = vertices.float()
19
- self.faces = faces.int()
20
- self.vertex_attrs = vertex_attrs
21
-
22
- @property
23
- def device(self):
24
- return self.vertices.device
25
-
26
- def to(self, device, non_blocking=False):
27
- return Mesh(
28
- self.vertices.to(device, non_blocking=non_blocking),
29
- self.faces.to(device, non_blocking=non_blocking),
30
- self.vertex_attrs.to(device, non_blocking=non_blocking) if self.vertex_attrs is not None else None,
31
- )
32
-
33
- def cuda(self, non_blocking=False):
34
- return self.to('cuda', non_blocking=non_blocking)
35
-
36
- def cpu(self):
37
- return self.to('cpu')
38
-
39
- def fill_holes(self, max_hole_perimeter=3e-2):
40
- vertices = self.vertices.clone().cuda().contiguous()
41
- faces = self.faces.clone().cuda().contiguous()
42
-
43
- mesh = cumesh.CuMesh()
44
- mesh.init(vertices, faces)
45
- mesh.get_edges()
46
- mesh.get_boundary_info()
47
- if mesh.num_boundaries == 0:
48
- return
49
- mesh.get_vertex_edge_adjacency()
50
- mesh.get_vertex_boundary_adjacency()
51
- mesh.get_manifold_boundary_adjacency()
52
- mesh.read_manifold_boundary_adjacency()
53
- mesh.get_boundary_connected_components()
54
- mesh.get_boundary_loops()
55
- if mesh.num_boundary_loops == 0:
56
- return
57
- mesh.fill_holes(max_hole_perimeter=max_hole_perimeter)
58
- new_vertices, new_faces = mesh.read()
59
-
60
- self.vertices = new_vertices.to(self.device)
61
- self.faces = new_faces.to(self.device)
62
-
63
- def remove_faces(self, face_mask: torch.Tensor):
64
- vertices = self.vertices.clone().cuda().contiguous()
65
- faces = self.faces.clone().cuda().contiguous()
66
-
67
- mesh = cumesh.CuMesh()
68
- mesh.init(vertices, faces)
69
- mesh.remove_faces(face_mask)
70
- new_vertices, new_faces = mesh.read()
71
-
72
- self.vertices = new_vertices.to(self.device)
73
- self.faces = new_faces.to(self.device)
74
-
75
- def simplify(self, target=1000000, verbose: bool=False, options: dict={}):
76
- vertices = self.vertices.clone().cuda().contiguous()
77
- faces = self.faces.clone().cuda().contiguous()
78
-
79
- mesh = cumesh.CuMesh()
80
- mesh.init(vertices, faces)
81
- mesh.simplify(target, verbose=verbose, options=options)
82
- new_vertices, new_faces = mesh.read()
83
-
84
- self.vertices = new_vertices.to(self.device)
85
- self.faces = new_faces.to(self.device)
86
-
87
-
88
- class TextureFilterMode:
89
- CLOSEST = 0
90
- LINEAR = 1
91
-
92
-
93
- class TextureWrapMode:
94
- CLAMP_TO_EDGE = 0
95
- REPEAT = 1
96
- MIRRORED_REPEAT = 2
97
-
98
-
99
- class AlphaMode:
100
- OPAQUE = 0
101
- MASK = 1
102
- BLEND = 2
103
-
104
-
105
- class Texture:
106
- def __init__(
107
- self,
108
- image: torch.Tensor,
109
- filter_mode: TextureFilterMode = TextureFilterMode.LINEAR,
110
- wrap_mode: TextureWrapMode = TextureWrapMode.REPEAT
111
- ):
112
- self.image = image
113
- self.filter_mode = filter_mode
114
- self.wrap_mode = wrap_mode
115
-
116
- def to(self, device, non_blocking=False):
117
- return Texture(
118
- self.image.to(device, non_blocking=non_blocking),
119
- self.filter_mode,
120
- self.wrap_mode,
121
- )
122
-
123
-
124
- class PbrMaterial:
125
- def __init__(
126
- self,
127
- base_color_texture: Optional[Texture] = None,
128
- base_color_factor: Union[torch.Tensor, List[float]] = [1.0, 1.0, 1.0],
129
- metallic_texture: Optional[Texture] = None,
130
- metallic_factor: float = 1.0,
131
- roughness_texture: Optional[Texture] = None,
132
- roughness_factor: float = 1.0,
133
- alpha_texture: Optional[Texture] = None,
134
- alpha_factor: float = 1.0,
135
- alpha_mode: AlphaMode = AlphaMode.OPAQUE,
136
- alpha_cutoff: float = 0.5,
137
- ):
138
- self.base_color_texture = base_color_texture
139
- self.base_color_factor = torch.tensor(base_color_factor, dtype=torch.float32)[:3]
140
- self.metallic_texture = metallic_texture
141
- self.metallic_factor = metallic_factor
142
- self.roughness_texture = roughness_texture
143
- self.roughness_factor = roughness_factor
144
- self.alpha_texture = alpha_texture
145
- self.alpha_factor = alpha_factor
146
- self.alpha_mode = alpha_mode
147
- self.alpha_cutoff = alpha_cutoff
148
-
149
- def to(self, device, non_blocking=False):
150
- return PbrMaterial(
151
- base_color_texture=self.base_color_texture.to(device, non_blocking=non_blocking) if self.base_color_texture is not None else None,
152
- base_color_factor=self.base_color_factor.to(device, non_blocking=non_blocking),
153
- metallic_texture=self.metallic_texture.to(device, non_blocking=non_blocking) if self.metallic_texture is not None else None,
154
- metallic_factor=self.metallic_factor,
155
- roughness_texture=self.roughness_texture.to(device, non_blocking=non_blocking) if self.roughness_texture is not None else None,
156
- roughness_factor=self.roughness_factor,
157
- alpha_texture=self.alpha_texture.to(device, non_blocking=non_blocking) if self.alpha_texture is not None else None,
158
- alpha_factor=self.alpha_factor,
159
- alpha_mode=self.alpha_mode,
160
- alpha_cutoff=self.alpha_cutoff,
161
- )
162
-
163
-
164
- class MeshWithPbrMaterial(Mesh):
165
- def __init__(self,
166
- vertices,
167
- faces,
168
- material_ids,
169
- uv_coords,
170
- materials: List[PbrMaterial],
171
- ):
172
- self.vertices = vertices.float()
173
- self.faces = faces.int()
174
- self.material_ids = material_ids # [M]
175
- self.uv_coords = uv_coords # [M, 3, 2]
176
- self.materials = materials
177
- self.layout = {
178
- 'base_color': slice(0, 3),
179
- 'metallic': slice(3, 4),
180
- 'roughness': slice(4, 5),
181
- 'alpha': slice(5, 6),
182
- }
183
-
184
- def to(self, device, non_blocking=False):
185
- return MeshWithPbrMaterial(
186
- self.vertices.to(device, non_blocking=non_blocking),
187
- self.faces.to(device, non_blocking=non_blocking),
188
- self.material_ids.to(device, non_blocking=non_blocking),
189
- self.uv_coords.to(device, non_blocking=non_blocking),
190
- [material.to(device, non_blocking=non_blocking) for material in self.materials],
191
- )
192
-
193
-
194
- class MeshWithVoxel(Mesh, Voxel):
195
- def __init__(self,
196
- vertices: torch.Tensor,
197
- faces: torch.Tensor,
198
- origin: list,
199
- voxel_size: float,
200
- coords: torch.Tensor,
201
- attrs: torch.Tensor,
202
- voxel_shape: torch.Size,
203
- layout: Dict = {},
204
- ):
205
- self.vertices = vertices.float()
206
- self.faces = faces.int()
207
- self.origin = torch.tensor(origin, dtype=torch.float32, device=self.device)
208
- self.voxel_size = voxel_size
209
- self.coords = coords
210
- self.attrs = attrs
211
- self.voxel_shape = voxel_shape
212
- self.layout = layout
213
-
214
- def to(self, device, non_blocking=False):
215
- return MeshWithVoxel(
216
- self.vertices.to(device, non_blocking=non_blocking),
217
- self.faces.to(device, non_blocking=non_blocking),
218
- self.origin.tolist(),
219
- self.voxel_size,
220
- self.coords.to(device, non_blocking=non_blocking),
221
- self.attrs.to(device, non_blocking=non_blocking),
222
- self.voxel_shape,
223
- self.layout,
224
- )
225
-
226
- def query_attrs(self, xyz):
227
- from flex_gemm.ops.grid_sample import grid_sample_3d
228
- grid = ((xyz - self.origin) / self.voxel_size).reshape(1, -1, 3)
229
- vertex_attrs = grid_sample_3d(
230
- self.attrs,
231
- torch.cat([torch.zeros_like(self.coords[..., :1]), self.coords], dim=-1),
232
- self.voxel_shape,
233
- grid,
234
- mode='trilinear'
235
- )[0]
236
- return vertex_attrs
237
-
238
- def query_vertex_attrs(self):
239
- return self.query_attrs(self.vertices)
 
1
+ from typing import *
2
+ import torch
3
+ from ..voxel import Voxel
4
+ import cumesh
5
+ # NOTE: flex_gemm is imported lazily in query_attrs (matching the renderers' pattern), NOT here.
6
+ # This module sits in the DataLoader workers' import closure (dataset -> MeshWithVoxel), and
7
+ # flex_gemm's triton @autotune decorators query the GPU driver AT IMPORT — in a spawned CPU-only
8
+ # worker this can raise "RuntimeError: 0 active drivers", killing the rank and
9
+ # leaving other ranks blocked in NCCL collectives.
10
+
11
+
12
+ class Mesh:
13
+ def __init__(self,
14
+ vertices,
15
+ faces,
16
+ vertex_attrs=None
17
+ ):
18
+ self.vertices = vertices.float()
19
+ self.faces = faces.int()
20
+ self.vertex_attrs = vertex_attrs
21
+
22
+ @property
23
+ def device(self):
24
+ return self.vertices.device
25
+
26
+ def to(self, device, non_blocking=False):
27
+ return Mesh(
28
+ self.vertices.to(device, non_blocking=non_blocking),
29
+ self.faces.to(device, non_blocking=non_blocking),
30
+ self.vertex_attrs.to(device, non_blocking=non_blocking) if self.vertex_attrs is not None else None,
31
+ )
32
+
33
+ def cuda(self, non_blocking=False):
34
+ return self.to('cuda', non_blocking=non_blocking)
35
+
36
+ def cpu(self):
37
+ return self.to('cpu')
38
+
39
+ def fill_holes(self, max_hole_perimeter=3e-2):
40
+ vertices = self.vertices.clone().cuda().contiguous()
41
+ faces = self.faces.clone().cuda().contiguous()
42
+
43
+ mesh = cumesh.CuMesh()
44
+ mesh.init(vertices, faces)
45
+ mesh.get_edges()
46
+ mesh.get_boundary_info()
47
+ if mesh.num_boundaries == 0:
48
+ return
49
+ mesh.get_vertex_edge_adjacency()
50
+ mesh.get_vertex_boundary_adjacency()
51
+ mesh.get_manifold_boundary_adjacency()
52
+ mesh.read_manifold_boundary_adjacency()
53
+ mesh.get_boundary_connected_components()
54
+ mesh.get_boundary_loops()
55
+ if mesh.num_boundary_loops == 0:
56
+ return
57
+ mesh.fill_holes(max_hole_perimeter=max_hole_perimeter)
58
+ new_vertices, new_faces = mesh.read()
59
+
60
+ self.vertices = new_vertices.to(self.device)
61
+ self.faces = new_faces.to(self.device)
62
+
63
+ def remove_faces(self, face_mask: torch.Tensor):
64
+ vertices = self.vertices.clone().cuda().contiguous()
65
+ faces = self.faces.clone().cuda().contiguous()
66
+
67
+ mesh = cumesh.CuMesh()
68
+ mesh.init(vertices, faces)
69
+ mesh.remove_faces(face_mask)
70
+ new_vertices, new_faces = mesh.read()
71
+
72
+ self.vertices = new_vertices.to(self.device)
73
+ self.faces = new_faces.to(self.device)
74
+
75
+ def simplify(self, target=1000000, verbose: bool=False, options: dict={}):
76
+ vertices = self.vertices.clone().cuda().contiguous()
77
+ faces = self.faces.clone().cuda().contiguous()
78
+
79
+ mesh = cumesh.CuMesh()
80
+ mesh.init(vertices, faces)
81
+ mesh.simplify(target, verbose=verbose, options=options)
82
+ new_vertices, new_faces = mesh.read()
83
+
84
+ self.vertices = new_vertices.to(self.device)
85
+ self.faces = new_faces.to(self.device)
86
+
87
+
88
+ class TextureFilterMode:
89
+ CLOSEST = 0
90
+ LINEAR = 1
91
+
92
+
93
+ class TextureWrapMode:
94
+ CLAMP_TO_EDGE = 0
95
+ REPEAT = 1
96
+ MIRRORED_REPEAT = 2
97
+
98
+
99
+ class AlphaMode:
100
+ OPAQUE = 0
101
+ MASK = 1
102
+ BLEND = 2
103
+
104
+
105
+ class Texture:
106
+ def __init__(
107
+ self,
108
+ image: torch.Tensor,
109
+ filter_mode: TextureFilterMode = TextureFilterMode.LINEAR,
110
+ wrap_mode: TextureWrapMode = TextureWrapMode.REPEAT
111
+ ):
112
+ self.image = image
113
+ self.filter_mode = filter_mode
114
+ self.wrap_mode = wrap_mode
115
+
116
+ def to(self, device, non_blocking=False):
117
+ return Texture(
118
+ self.image.to(device, non_blocking=non_blocking),
119
+ self.filter_mode,
120
+ self.wrap_mode,
121
+ )
122
+
123
+
124
+ class PbrMaterial:
125
+ def __init__(
126
+ self,
127
+ base_color_texture: Optional[Texture] = None,
128
+ base_color_factor: Union[torch.Tensor, List[float]] = [1.0, 1.0, 1.0],
129
+ metallic_texture: Optional[Texture] = None,
130
+ metallic_factor: float = 1.0,
131
+ roughness_texture: Optional[Texture] = None,
132
+ roughness_factor: float = 1.0,
133
+ alpha_texture: Optional[Texture] = None,
134
+ alpha_factor: float = 1.0,
135
+ alpha_mode: AlphaMode = AlphaMode.OPAQUE,
136
+ alpha_cutoff: float = 0.5,
137
+ ):
138
+ self.base_color_texture = base_color_texture
139
+ self.base_color_factor = torch.tensor(base_color_factor, dtype=torch.float32)[:3]
140
+ self.metallic_texture = metallic_texture
141
+ self.metallic_factor = metallic_factor
142
+ self.roughness_texture = roughness_texture
143
+ self.roughness_factor = roughness_factor
144
+ self.alpha_texture = alpha_texture
145
+ self.alpha_factor = alpha_factor
146
+ self.alpha_mode = alpha_mode
147
+ self.alpha_cutoff = alpha_cutoff
148
+
149
+ def to(self, device, non_blocking=False):
150
+ return PbrMaterial(
151
+ base_color_texture=self.base_color_texture.to(device, non_blocking=non_blocking) if self.base_color_texture is not None else None,
152
+ base_color_factor=self.base_color_factor.to(device, non_blocking=non_blocking),
153
+ metallic_texture=self.metallic_texture.to(device, non_blocking=non_blocking) if self.metallic_texture is not None else None,
154
+ metallic_factor=self.metallic_factor,
155
+ roughness_texture=self.roughness_texture.to(device, non_blocking=non_blocking) if self.roughness_texture is not None else None,
156
+ roughness_factor=self.roughness_factor,
157
+ alpha_texture=self.alpha_texture.to(device, non_blocking=non_blocking) if self.alpha_texture is not None else None,
158
+ alpha_factor=self.alpha_factor,
159
+ alpha_mode=self.alpha_mode,
160
+ alpha_cutoff=self.alpha_cutoff,
161
+ )
162
+
163
+
164
+ class MeshWithPbrMaterial(Mesh):
165
+ def __init__(self,
166
+ vertices,
167
+ faces,
168
+ material_ids,
169
+ uv_coords,
170
+ materials: List[PbrMaterial],
171
+ ):
172
+ self.vertices = vertices.float()
173
+ self.faces = faces.int()
174
+ self.material_ids = material_ids # [M]
175
+ self.uv_coords = uv_coords # [M, 3, 2]
176
+ self.materials = materials
177
+ self.layout = {
178
+ 'base_color': slice(0, 3),
179
+ 'metallic': slice(3, 4),
180
+ 'roughness': slice(4, 5),
181
+ 'alpha': slice(5, 6),
182
+ }
183
+
184
+ def to(self, device, non_blocking=False):
185
+ return MeshWithPbrMaterial(
186
+ self.vertices.to(device, non_blocking=non_blocking),
187
+ self.faces.to(device, non_blocking=non_blocking),
188
+ self.material_ids.to(device, non_blocking=non_blocking),
189
+ self.uv_coords.to(device, non_blocking=non_blocking),
190
+ [material.to(device, non_blocking=non_blocking) for material in self.materials],
191
+ )
192
+
193
+
194
+ class MeshWithVoxel(Mesh, Voxel):
195
+ def __init__(self,
196
+ vertices: torch.Tensor,
197
+ faces: torch.Tensor,
198
+ origin: list,
199
+ voxel_size: float,
200
+ coords: torch.Tensor,
201
+ attrs: torch.Tensor,
202
+ voxel_shape: torch.Size,
203
+ layout: Dict = {},
204
+ ):
205
+ self.vertices = vertices.float()
206
+ self.faces = faces.int()
207
+ self.origin = torch.tensor(origin, dtype=torch.float32, device=self.device)
208
+ self.voxel_size = voxel_size
209
+ self.coords = coords
210
+ self.attrs = attrs
211
+ self.voxel_shape = voxel_shape
212
+ self.layout = layout
213
+
214
+ def to(self, device, non_blocking=False):
215
+ return MeshWithVoxel(
216
+ self.vertices.to(device, non_blocking=non_blocking),
217
+ self.faces.to(device, non_blocking=non_blocking),
218
+ self.origin.tolist(),
219
+ self.voxel_size,
220
+ self.coords.to(device, non_blocking=non_blocking),
221
+ self.attrs.to(device, non_blocking=non_blocking),
222
+ self.voxel_shape,
223
+ self.layout,
224
+ )
225
+
226
+ def query_attrs(self, xyz):
227
+ from flex_gemm.ops.grid_sample import grid_sample_3d
228
+ grid = ((xyz - self.origin) / self.voxel_size).reshape(1, -1, 3)
229
+ vertex_attrs = grid_sample_3d(
230
+ self.attrs,
231
+ torch.cat([torch.zeros_like(self.coords[..., :1]), self.coords], dim=-1),
232
+ self.voxel_shape,
233
+ grid,
234
+ mode='trilinear'
235
+ )[0]
236
+ return vertex_attrs
237
+
238
+ def query_vertex_attrs(self):
239
+ return self.query_attrs(self.vertices)
deployment/runtime/qwen/diffsynth/core/__init__.py CHANGED
@@ -1,8 +1,6 @@
1
- from .attention import *
2
- from .data import *
3
- from .gradient import *
4
- from .quant import *
5
- from .loader import *
6
- from .vram import *
7
- from .device import *
8
- from .offload_training import *
 
1
+ from .attention import *
2
+ from .gradient import *
3
+ from .quant import *
4
+ from .loader import *
5
+ from .vram import *
6
+ from .device import *
 
 
deployment/runtime/qwen/diffsynth/core/data/__init__.py CHANGED
@@ -1 +1 @@
1
- from .unified_dataset import UnifiedDataset
 
1
+ """Image and media preprocessing operators used by inference pipelines."""
deployment/runtime/qwen/diffsynth/diffusion/__init__.py CHANGED
@@ -1,7 +1 @@
1
- from .flow_match import FlowMatchScheduler, HiDreamO1FlashScheduler, AncestralFlowMatchScheduler
2
- from .training_module import DiffusionTrainingModule
3
- from .logger import ModelLogger
4
- from .runner import launch_training_task, launch_data_process_task
5
- from .parsers import *
6
- from .loss import *
7
- from .dmd2 import *
 
1
+ from .flow_match import FlowMatchScheduler, HiDreamO1FlashScheduler, AncestralFlowMatchScheduler
 
 
 
 
 
 
deployment/runtime/qwen/diffsynth/diffusion/base_pipeline.py CHANGED
@@ -1,542 +1,526 @@
1
- from PIL import Image
2
- import torch
3
- import numpy as np
4
- from einops import repeat, reduce
5
- from typing import Union
6
- from ..core import AutoTorchModule, AutoWrappedLinear, LoRAHotLoadMixin, load_state_dict, ModelConfig, parse_device_type, enable_vram_management
7
- from ..core.device.npu_compatible_device import get_device_type
8
- from ..utils.lora import GeneralLoRALoader
9
- from ..models.model_loader import ModelPool
10
- from ..utils.controlnet import ControlNetInput
11
- from ..core.device import get_device_name, IS_NPU_AVAILABLE
12
- from .template import load_template_model, load_template_data_processor
13
-
14
-
15
- class PipelineUnit:
16
- def __init__(
17
- self,
18
- seperate_cfg: bool = False,
19
- take_over: bool = False,
20
- input_params: tuple[str] = None,
21
- output_params: tuple[str] = None,
22
- input_params_posi: dict[str, str] = None,
23
- input_params_nega: dict[str, str] = None,
24
- onload_model_names: tuple[str] = None
25
- ):
26
- self.seperate_cfg = seperate_cfg
27
- self.take_over = take_over
28
- self.input_params = input_params
29
- self.output_params = output_params
30
- self.input_params_posi = input_params_posi
31
- self.input_params_nega = input_params_nega
32
- self.onload_model_names = onload_model_names
33
-
34
- def fetch_input_params(self):
35
- params = []
36
- if self.input_params is not None:
37
- for param in self.input_params:
38
- params.append(param)
39
- if self.input_params_posi is not None:
40
- for _, param in self.input_params_posi.items():
41
- params.append(param)
42
- if self.input_params_nega is not None:
43
- for _, param in self.input_params_nega.items():
44
- params.append(param)
45
- params = sorted(list(set(params)))
46
- return params
47
-
48
- def fetch_output_params(self):
49
- params = []
50
- if self.output_params is not None:
51
- for param in self.output_params:
52
- params.append(param)
53
- return params
54
-
55
- def process(self, pipe, **kwargs) -> dict:
56
- return {}
57
-
58
- def post_process(self, pipe, **kwargs) -> dict:
59
- return {}
60
-
61
-
62
- class BasePipeline(torch.nn.Module):
63
-
64
- def __init__(
65
- self,
66
- device=get_device_type(), torch_dtype=torch.float16,
67
- height_division_factor=64, width_division_factor=64,
68
- time_division_factor=None, time_division_remainder=None,
69
- ):
70
- super().__init__()
71
- # The device and torch_dtype is used for the storage of intermediate variables, not models.
72
- self.device = device
73
- self.torch_dtype = torch_dtype
74
- self.device_type = parse_device_type(device)
75
- # The following parameters are used for shape check.
76
- self.height_division_factor = height_division_factor
77
- self.width_division_factor = width_division_factor
78
- self.time_division_factor = time_division_factor
79
- self.time_division_remainder = time_division_remainder
80
- # VRAM management
81
- self.vram_management_enabled = False
82
- # Pipeline Unit Runner
83
- self.unit_runner = PipelineUnitRunner()
84
- # LoRA Loader
85
- self.lora_loader = GeneralLoRALoader
86
-
87
-
88
- def to(self, *args, **kwargs):
89
- device, dtype, non_blocking, convert_to_format = torch._C._nn._parse_to(*args, **kwargs)
90
- if device is not None:
91
- self.device = device
92
- if dtype is not None:
93
- self.torch_dtype = dtype
94
- super().to(*args, **kwargs)
95
- return self
96
-
97
-
98
- def check_resize_height_width(self, height, width, num_frames=None, verbose=1):
99
- # Shape check
100
- if height % self.height_division_factor != 0:
101
- height = (height + self.height_division_factor - 1) // self.height_division_factor * self.height_division_factor
102
- if verbose > 0:
103
- print(f"height % {self.height_division_factor} != 0. We round it up to {height}.")
104
- if width % self.width_division_factor != 0:
105
- width = (width + self.width_division_factor - 1) // self.width_division_factor * self.width_division_factor
106
- if verbose > 0:
107
- print(f"width % {self.width_division_factor} != 0. We round it up to {width}.")
108
- if num_frames is None:
109
- return height, width
110
- else:
111
- if num_frames % self.time_division_factor != self.time_division_remainder:
112
- num_frames = (num_frames - self.time_division_remainder + self.time_division_factor - 1) // self.time_division_factor * self.time_division_factor + self.time_division_remainder
113
- if verbose > 0:
114
- print(f"num_frames % {self.time_division_factor} != {self.time_division_remainder}. We round it up to {num_frames}.")
115
- return height, width, num_frames
116
-
117
-
118
- def preprocess_image(self, image, torch_dtype=None, device=None, pattern="B C H W", min_value=-1, max_value=1):
119
- # Transform a PIL.Image to torch.Tensor
120
- image = torch.Tensor(np.array(image, dtype=np.float32))
121
- image = image.to(dtype=torch_dtype or self.torch_dtype, device=device or self.device)
122
- image = image * ((max_value - min_value) / 255) + min_value
123
- image = repeat(image, f"H W C -> {pattern}", **({"B": 1} if "B" in pattern else {}))
124
- return image
125
-
126
-
127
- def preprocess_video(self, video, torch_dtype=None, device=None, pattern="B C T H W", min_value=-1, max_value=1):
128
- # Transform a list of PIL.Image to torch.Tensor
129
- video = [self.preprocess_image(image, torch_dtype=torch_dtype, device=device, min_value=min_value, max_value=max_value) for image in video]
130
- video = torch.stack(video, dim=pattern.index("T") // 2)
131
- return video
132
-
133
-
134
- def vae_output_to_image(self, vae_output, pattern="B C H W", min_value=-1, max_value=1):
135
- # Transform a torch.Tensor to PIL.Image
136
- if pattern != "H W C":
137
- vae_output = reduce(vae_output, f"{pattern} -> H W C", reduction="mean")
138
- image = ((vae_output - min_value) * (255 / (max_value - min_value))).clip(0, 255)
139
- image = image.to(device="cpu", dtype=torch.uint8)
140
- image = Image.fromarray(image.numpy())
141
- return image
142
-
143
-
144
- def vae_output_to_video(self, vae_output, pattern="B C T H W", min_value=-1, max_value=1):
145
- # Transform a torch.Tensor to list of PIL.Image
146
- if pattern != "T H W C":
147
- vae_output = reduce(vae_output, f"{pattern} -> T H W C", reduction="mean")
148
- video = [self.vae_output_to_image(image, pattern="H W C", min_value=min_value, max_value=max_value) for image in vae_output]
149
- return video
150
-
151
- def output_audio_format_check(self, audio_output):
152
- # output standard foramt: [C, T], output dtype: float()
153
- # remove batch dim
154
- if audio_output.ndim == 3:
155
- audio_output = audio_output.squeeze(0)
156
- return audio_output.float().cpu()
157
-
158
- def load_models_to_device(self, model_names):
159
- if self.vram_management_enabled:
160
- # offload models
161
- for name, model in self.named_children():
162
- if name not in model_names:
163
- if hasattr(model, "vram_management_enabled") and model.vram_management_enabled:
164
- if hasattr(model, "offload"):
165
- model.offload()
166
- else:
167
- for module in model.modules():
168
- if hasattr(module, "offload"):
169
- module.offload()
170
- getattr(torch, self.device_type).empty_cache()
171
- # onload models
172
- for name, model in self.named_children():
173
- if name in model_names:
174
- if hasattr(model, "vram_management_enabled") and model.vram_management_enabled:
175
- if hasattr(model, "onload"):
176
- model.onload()
177
- else:
178
- for module in model.modules():
179
- if hasattr(module, "onload"):
180
- module.onload()
181
-
182
-
183
- def generate_noise(self, shape, seed=None, rand_device="cpu", rand_torch_dtype=torch.float32, device=None, torch_dtype=None):
184
- # Initialize Gaussian noise
185
- generator = None if seed is None else torch.Generator(rand_device).manual_seed(seed)
186
- noise = torch.randn(shape, generator=generator, device=rand_device, dtype=rand_torch_dtype)
187
- noise = noise.to(dtype=torch_dtype or self.torch_dtype, device=device or self.device)
188
- return noise
189
-
190
-
191
- def get_vram(self):
192
- device = self.device if not IS_NPU_AVAILABLE else get_device_name()
193
- return getattr(torch, self.device_type).mem_get_info(device)[1] / (1024 ** 3)
194
-
195
- def get_module(self, model, name):
196
- if "." in name:
197
- name, suffix = name[:name.index(".")], name[name.index(".") + 1:]
198
- if name.isdigit():
199
- return self.get_module(model[int(name)], suffix)
200
- else:
201
- return self.get_module(getattr(model, name), suffix)
202
- else:
203
- return getattr(model, name)
204
-
205
- def freeze_except(self, model_names):
206
- self.eval()
207
- self.requires_grad_(False)
208
- for name in model_names:
209
- module = self.get_module(self, name)
210
- if module is None:
211
- print(f"No {name} models in the pipeline. We cannot enable training on the model. If this occurs during the data processing stage, it is normal.")
212
- continue
213
- module.train()
214
- module.requires_grad_(True)
215
-
216
-
217
- def blend_with_mask(self, base, addition, mask):
218
- return base * (1 - mask) + addition * mask
219
-
220
-
221
- def step(self, scheduler, latents, progress_id, noise_pred, input_latents=None, inpaint_mask=None, **kwargs):
222
- timestep = scheduler.timesteps[progress_id]
223
- if inpaint_mask is not None:
224
- noise_pred_expected = scheduler.return_to_timestep(scheduler.timesteps[progress_id], latents, input_latents)
225
- noise_pred = self.blend_with_mask(noise_pred_expected, noise_pred, inpaint_mask)
226
- latents_next = scheduler.step(noise_pred, timestep, latents)
227
- return latents_next
228
-
229
-
230
- def split_pipeline_units(self, model_names: list[str]):
231
- return PipelineUnitGraph().split_pipeline_units(self.units, model_names)
232
-
233
-
234
- def flush_vram_management_device(self, device):
235
- for module in self.modules():
236
- if isinstance(module, AutoTorchModule):
237
- module.offload_device = device
238
- module.onload_device = device
239
- module.preparing_device = device
240
- module.computation_device = device
241
-
242
-
243
- def check_quant_hot_load(self, module: torch.nn.Module):
244
- # A quantized weight cannot absorb a fused LoRA, so hot-loading is the only option for it.
245
- if getattr(module, "quantize_config", None) is None:
246
- return module
247
- return self.enable_lora_hot_loading(module)
248
-
249
-
250
- def load_lora(
251
- self,
252
- module: torch.nn.Module,
253
- lora_config: Union[ModelConfig, str] = None,
254
- alpha=1,
255
- hotload=None,
256
- state_dict=None,
257
- verbose=1,
258
- ):
259
- module = self.check_quant_hot_load(module)
260
- if state_dict is None:
261
- if isinstance(lora_config, str):
262
- lora = load_state_dict(lora_config, torch_dtype=self.torch_dtype, device=self.device)
263
- else:
264
- lora_config.download_if_necessary()
265
- lora = load_state_dict(lora_config.path, torch_dtype=self.torch_dtype, device=self.device)
266
- else:
267
- lora = state_dict
268
- lora_loader = self.lora_loader(torch_dtype=self.torch_dtype, device=self.device)
269
- lora = lora_loader.convert_state_dict(lora)
270
- if hotload is None:
271
- hotload = hasattr(module, "vram_management_enabled") and getattr(module, "vram_management_enabled")
272
- if hotload:
273
- if not (hasattr(module, "vram_management_enabled") and getattr(module, "vram_management_enabled")):
274
- raise ValueError("VRAM Management is not enabled. LoRA hotloading is not supported.")
275
- updated_num = 0
276
- for _, module in module.named_modules():
277
- if isinstance(module, LoRAHotLoadMixin):
278
- name = module.name
279
- lora_a_name = f'{name}.lora_A.weight'
280
- lora_b_name = f'{name}.lora_B.weight'
281
- if lora_a_name in lora and lora_b_name in lora:
282
- updated_num += 1
283
- module.lora_A_weights.append(lora[lora_a_name] * alpha)
284
- module.lora_B_weights.append(lora[lora_b_name])
285
- if verbose >= 1:
286
- print(f"{updated_num} tensors are patched by LoRA. You can use `pipe.clear_lora()` to clear all LoRA layers.")
287
- else:
288
- lora_loader.fuse_lora_to_base_model(module, lora, alpha=alpha)
289
-
290
-
291
- def clear_lora(self, verbose=1):
292
- cleared_num = 0
293
- for name, module in self.named_modules():
294
- if isinstance(module, LoRAHotLoadMixin):
295
- if hasattr(module, "lora_A_weights"):
296
- if len(module.lora_A_weights) > 0:
297
- cleared_num += 1
298
- module.lora_A_weights.clear()
299
- if hasattr(module, "lora_B_weights"):
300
- module.lora_B_weights.clear()
301
- if verbose >= 1:
302
- print(f"{cleared_num} LoRA layers are cleared.")
303
-
304
-
305
- def download_and_load_models(self, model_configs: list[ModelConfig] = [], vram_limit: float = None):
306
- model_pool = ModelPool()
307
- for model_config in model_configs:
308
- model_config.download_if_necessary()
309
- vram_config = model_config.vram_config()
310
- vram_config["computation_dtype"] = vram_config["computation_dtype"] or self.torch_dtype
311
- vram_config["computation_device"] = vram_config["computation_device"] or self.device
312
- model_pool.auto_load_model(
313
- model_config.path,
314
- vram_config=vram_config,
315
- vram_limit=vram_limit,
316
- clear_parameters=model_config.clear_parameters,
317
- state_dict=model_config.state_dict,
318
- quantize=model_config.quantize,
319
- )
320
- return model_pool
321
-
322
-
323
- def check_vram_management_state(self):
324
- vram_management_enabled = False
325
- for module in self.children():
326
- if hasattr(module, "vram_management_enabled") and getattr(module, "vram_management_enabled"):
327
- vram_management_enabled = True
328
- return vram_management_enabled
329
-
330
-
331
- def cfg_guided_model_fn(self, model_fn, cfg_scale, inputs_shared, inputs_posi, inputs_nega, **inputs_others):
332
- # Positive side forward
333
- if inputs_shared.get("positive_only_lora", None) is not None:
334
- self.load_lora(self.dit, state_dict=inputs_shared["positive_only_lora"], verbose=0)
335
- noise_pred_posi = model_fn(**inputs_posi, **inputs_shared, **inputs_others)
336
- if inputs_shared.get("positive_only_lora", None) is not None:
337
- self.clear_lora(verbose=0)
338
-
339
- if cfg_scale != 1.0:
340
- # Negative side forward
341
- if inputs_shared.get("negative_only_lora", None) is not None:
342
- self.load_lora(self.dit, state_dict=inputs_shared["negative_only_lora"], verbose=0)
343
- noise_pred_nega = model_fn(**inputs_nega, **inputs_shared, **inputs_others)
344
- if inputs_shared.get("negative_only_lora", None) is not None:
345
- self.clear_lora(verbose=0)
346
-
347
- if isinstance(noise_pred_posi, tuple):
348
- # Separately handling different output types of latents, eg. video and audio latents.
349
- noise_pred = tuple(
350
- n_nega + cfg_scale * (n_posi - n_nega)
351
- for n_posi, n_nega in zip(noise_pred_posi, noise_pred_nega)
352
- )
353
- else:
354
- noise_pred = noise_pred_nega + cfg_scale * (noise_pred_posi - noise_pred_nega)
355
- else:
356
- noise_pred = noise_pred_posi
357
- return noise_pred
358
-
359
-
360
- def load_training_template_model(self, model_config: ModelConfig = None):
361
- if model_config is not None:
362
- model_config.download_if_necessary()
363
- self.template_model = load_template_model(model_config.path, torch_dtype=self.torch_dtype, device=self.device)
364
- self.template_data_processor = load_template_data_processor(model_config.path)()
365
-
366
-
367
- def enable_lora_hot_loading(self, model: torch.nn.Module):
368
- if hasattr(model, "vram_management_enabled") and getattr(model, "vram_management_enabled"):
369
- return model
370
- module_map = {torch.nn.Linear: AutoWrappedLinear}
371
- vram_config = {
372
- "offload_dtype": self.torch_dtype,
373
- "offload_device": self.device,
374
- "onload_dtype": self.torch_dtype,
375
- "onload_device": self.device,
376
- "preparing_dtype": self.torch_dtype,
377
- "preparing_device": self.device,
378
- "computation_dtype": self.torch_dtype,
379
- "computation_device": self.device,
380
- }
381
- model = enable_vram_management(model, module_map, vram_config=vram_config, quantize=getattr(model, "quantize_config", None))
382
- return model
383
-
384
- def compile_pipeline(self, mode: str = "default", dynamic: bool = True, fullgraph: bool = False, compile_models: list = None, **kwargs):
385
- """
386
- compile the pipeline with torch.compile. The models that will be compiled are determined by the `compilable_models` attribute of the pipeline.
387
- If a model has `_repeated_blocks` attribute, we will compile these blocks with regional compilation. Otherwise, we will compile the whole model.
388
- See https://docs.pytorch.org/docs/stable/generated/torch.compile.html#torch.compile for details about compilation arguments.
389
- Args:
390
- mode: The compilation mode, which will be passed to `torch.compile`, options are "default", "reduce-overhead", "max-autotune" and "max-autotune-no-cudagraphs. Default to "default".
391
- dynamic: Whether to enable dynamic graph compilation to support dynamic input shapes, which will be passed to `torch.compile`. Default to True (recommended).
392
- fullgraph: Whether to use full graph compilation, which will be passed to `torch.compile`. Default to False (recommended).
393
- compile_models: The list of model names to be compiled. If None, we will compile the models in `pipeline.compilable_models`. Default to None.
394
- **kwargs: Other arguments for `torch.compile`.
395
- """
396
- compile_models = compile_models or getattr(self, "compilable_models", [])
397
- if len(compile_models) == 0:
398
- print("No compilable models in the pipeline. Skip compilation.")
399
- return
400
- for name in compile_models:
401
- model = getattr(self, name, None)
402
- if model is None:
403
- print(f"Model '{name}' not found in the pipeline.")
404
- continue
405
- repeated_blocks = getattr(model, "_repeated_blocks", None)
406
- # regional compilation for repeated blocks.
407
- if repeated_blocks is not None:
408
- for submod in model.modules():
409
- if submod.__class__.__name__ in repeated_blocks:
410
- submod.compile(mode=mode, dynamic=dynamic, fullgraph=fullgraph, **kwargs)
411
- # compile the whole model.
412
- else:
413
- model.compile(mode=mode, dynamic=dynamic, fullgraph=fullgraph, **kwargs)
414
- print(f"{name} is compiled with mode={mode}, dynamic={dynamic}, fullgraph={fullgraph}.")
415
-
416
-
417
- class PipelineUnitGraph:
418
- def __init__(self):
419
- pass
420
-
421
- def build_edges(self, units: list[PipelineUnit]):
422
- # Establish dependencies between units
423
- # to search for subsequent related computation units.
424
- last_compute_unit_id = {}
425
- edges = []
426
- for unit_id, unit in enumerate(units):
427
- for input_param in unit.fetch_input_params():
428
- if input_param in last_compute_unit_id:
429
- edges.append((last_compute_unit_id[input_param], unit_id))
430
- for output_param in unit.fetch_output_params():
431
- last_compute_unit_id[output_param] = unit_id
432
- return edges
433
-
434
- def build_chains(self, units: list[PipelineUnit]):
435
- # Establish updating chains for each variable
436
- # to track their computation process.
437
- params = sum([unit.fetch_input_params() + unit.fetch_output_params() for unit in units], [])
438
- params = sorted(list(set(params)))
439
- chains = {param: [] for param in params}
440
- for unit_id, unit in enumerate(units):
441
- for param in unit.fetch_output_params():
442
- chains[param].append(unit_id)
443
- return chains
444
-
445
- def search_direct_unit_ids(self, units: list[PipelineUnit], model_names: list[str]):
446
- # Search for units that directly participate in the model's computation.
447
- related_unit_ids = []
448
- for unit_id, unit in enumerate(units):
449
- for model_name in model_names:
450
- if unit.onload_model_names is not None and model_name in unit.onload_model_names:
451
- related_unit_ids.append(unit_id)
452
- break
453
- return related_unit_ids
454
-
455
- def search_related_unit_ids(self, edges, start_unit_ids, direction="target"):
456
- # Search for subsequent related computation units.
457
- related_unit_ids = [unit_id for unit_id in start_unit_ids]
458
- while True:
459
- neighbors = []
460
- for source, target in edges:
461
- if direction == "target" and source in related_unit_ids and target not in related_unit_ids:
462
- neighbors.append(target)
463
- elif direction == "source" and source not in related_unit_ids and target in related_unit_ids:
464
- neighbors.append(source)
465
- neighbors = sorted(list(set(neighbors)))
466
- if len(neighbors) == 0:
467
- break
468
- else:
469
- related_unit_ids.extend(neighbors)
470
- related_unit_ids = sorted(list(set(related_unit_ids)))
471
- return related_unit_ids
472
-
473
- def search_updating_unit_ids(self, units: list[PipelineUnit], chains, related_unit_ids):
474
- # If the input parameters of this subgraph are updated outside the subgraph,
475
- # search for the units where these updates occur.
476
- first_compute_unit_id = {}
477
- for unit_id in related_unit_ids:
478
- for param in units[unit_id].fetch_input_params():
479
- if param not in first_compute_unit_id:
480
- first_compute_unit_id[param] = unit_id
481
- updating_unit_ids = []
482
- for param in first_compute_unit_id:
483
- unit_id = first_compute_unit_id[param]
484
- chain = chains[param]
485
- if unit_id in chain and chain.index(unit_id) != len(chain) - 1:
486
- for unit_id_ in chain[chain.index(unit_id) + 1:]:
487
- if unit_id_ not in related_unit_ids:
488
- updating_unit_ids.append(unit_id_)
489
- related_unit_ids.extend(updating_unit_ids)
490
- related_unit_ids = sorted(list(set(related_unit_ids)))
491
- return related_unit_ids
492
-
493
- def split_pipeline_units(self, units: list[PipelineUnit], model_names: list[str]):
494
- # Split the computation graph,
495
- # separating all model-related computations.
496
- related_unit_ids = self.search_direct_unit_ids(units, model_names)
497
- edges = self.build_edges(units)
498
- chains = self.build_chains(units)
499
- while True:
500
- num_related_unit_ids = len(related_unit_ids)
501
- related_unit_ids = self.search_related_unit_ids(edges, related_unit_ids, "target")
502
- related_unit_ids = self.search_updating_unit_ids(units, chains, related_unit_ids)
503
- if len(related_unit_ids) == num_related_unit_ids:
504
- break
505
- else:
506
- num_related_unit_ids = len(related_unit_ids)
507
- related_units = [units[i] for i in related_unit_ids]
508
- unrelated_units = [units[i] for i in range(len(units)) if i not in related_unit_ids]
509
- return related_units, unrelated_units
510
-
511
-
512
- class PipelineUnitRunner:
513
- def __init__(self):
514
- pass
515
-
516
- def __call__(self, unit: PipelineUnit, pipe: BasePipeline, inputs_shared: dict, inputs_posi: dict, inputs_nega: dict) -> tuple[dict, dict]:
517
- if unit.take_over:
518
- # Let the pipeline unit take over this function.
519
- inputs_shared, inputs_posi, inputs_nega = unit.process(pipe, inputs_shared=inputs_shared, inputs_posi=inputs_posi, inputs_nega=inputs_nega)
520
- elif unit.seperate_cfg:
521
- # Positive side
522
- processor_inputs = {name: inputs_posi.get(name_) for name, name_ in unit.input_params_posi.items()}
523
- if unit.input_params is not None:
524
- for name in unit.input_params:
525
- processor_inputs[name] = inputs_shared.get(name)
526
- processor_outputs = unit.process(pipe, **processor_inputs)
527
- inputs_posi.update(processor_outputs)
528
- # Negative side
529
- if inputs_shared["cfg_scale"] != 1:
530
- processor_inputs = {name: inputs_nega.get(name_) for name, name_ in unit.input_params_nega.items()}
531
- if unit.input_params is not None:
532
- for name in unit.input_params:
533
- processor_inputs[name] = inputs_shared.get(name)
534
- processor_outputs = unit.process(pipe, **processor_inputs)
535
- inputs_nega.update(processor_outputs)
536
- else:
537
- inputs_nega.update(processor_outputs)
538
- else:
539
- processor_inputs = {name: inputs_shared.get(name) for name in unit.input_params}
540
- processor_outputs = unit.process(pipe, **processor_inputs)
541
- inputs_shared.update(processor_outputs)
542
- return inputs_shared, inputs_posi, inputs_nega
 
1
+ from PIL import Image
2
+ import torch
3
+ import numpy as np
4
+ from einops import repeat, reduce
5
+ from typing import Union
6
+ from ..core import AutoTorchModule, AutoWrappedLinear, LoRAHotLoadMixin, load_state_dict, ModelConfig, parse_device_type, enable_vram_management
7
+ from ..core.device.npu_compatible_device import get_device_type
8
+ from ..utils.lora import GeneralLoRALoader
9
+ from ..models.model_loader import ModelPool
10
+ from ..utils.controlnet import ControlNetInput
11
+ from ..core.device import get_device_name, IS_NPU_AVAILABLE
12
+
13
+
14
+ class PipelineUnit:
15
+ def __init__(
16
+ self,
17
+ seperate_cfg: bool = False,
18
+ take_over: bool = False,
19
+ input_params: tuple[str] = None,
20
+ output_params: tuple[str] = None,
21
+ input_params_posi: dict[str, str] = None,
22
+ input_params_nega: dict[str, str] = None,
23
+ onload_model_names: tuple[str] = None
24
+ ):
25
+ self.seperate_cfg = seperate_cfg
26
+ self.take_over = take_over
27
+ self.input_params = input_params
28
+ self.output_params = output_params
29
+ self.input_params_posi = input_params_posi
30
+ self.input_params_nega = input_params_nega
31
+ self.onload_model_names = onload_model_names
32
+
33
+ def fetch_input_params(self):
34
+ params = []
35
+ if self.input_params is not None:
36
+ for param in self.input_params:
37
+ params.append(param)
38
+ if self.input_params_posi is not None:
39
+ for _, param in self.input_params_posi.items():
40
+ params.append(param)
41
+ if self.input_params_nega is not None:
42
+ for _, param in self.input_params_nega.items():
43
+ params.append(param)
44
+ params = sorted(list(set(params)))
45
+ return params
46
+
47
+ def fetch_output_params(self):
48
+ params = []
49
+ if self.output_params is not None:
50
+ for param in self.output_params:
51
+ params.append(param)
52
+ return params
53
+
54
+ def process(self, pipe, **kwargs) -> dict:
55
+ return {}
56
+
57
+ def post_process(self, pipe, **kwargs) -> dict:
58
+ return {}
59
+
60
+
61
+ class BasePipeline(torch.nn.Module):
62
+
63
+ def __init__(
64
+ self,
65
+ device=get_device_type(), torch_dtype=torch.float16,
66
+ height_division_factor=64, width_division_factor=64,
67
+ time_division_factor=None, time_division_remainder=None,
68
+ ):
69
+ super().__init__()
70
+ # The device and torch_dtype is used for the storage of intermediate variables, not models.
71
+ self.device = device
72
+ self.torch_dtype = torch_dtype
73
+ self.device_type = parse_device_type(device)
74
+ # The following parameters are used for shape check.
75
+ self.height_division_factor = height_division_factor
76
+ self.width_division_factor = width_division_factor
77
+ self.time_division_factor = time_division_factor
78
+ self.time_division_remainder = time_division_remainder
79
+ # VRAM management
80
+ self.vram_management_enabled = False
81
+ # Pipeline Unit Runner
82
+ self.unit_runner = PipelineUnitRunner()
83
+ # LoRA Loader
84
+ self.lora_loader = GeneralLoRALoader
85
+
86
+
87
+ def to(self, *args, **kwargs):
88
+ device, dtype, non_blocking, convert_to_format = torch._C._nn._parse_to(*args, **kwargs)
89
+ if device is not None:
90
+ self.device = device
91
+ if dtype is not None:
92
+ self.torch_dtype = dtype
93
+ super().to(*args, **kwargs)
94
+ return self
95
+
96
+
97
+ def check_resize_height_width(self, height, width, num_frames=None, verbose=1):
98
+ # Shape check
99
+ if height % self.height_division_factor != 0:
100
+ height = (height + self.height_division_factor - 1) // self.height_division_factor * self.height_division_factor
101
+ if verbose > 0:
102
+ print(f"height % {self.height_division_factor} != 0. We round it up to {height}.")
103
+ if width % self.width_division_factor != 0:
104
+ width = (width + self.width_division_factor - 1) // self.width_division_factor * self.width_division_factor
105
+ if verbose > 0:
106
+ print(f"width % {self.width_division_factor} != 0. We round it up to {width}.")
107
+ if num_frames is None:
108
+ return height, width
109
+ else:
110
+ if num_frames % self.time_division_factor != self.time_division_remainder:
111
+ num_frames = (num_frames - self.time_division_remainder + self.time_division_factor - 1) // self.time_division_factor * self.time_division_factor + self.time_division_remainder
112
+ if verbose > 0:
113
+ print(f"num_frames % {self.time_division_factor} != {self.time_division_remainder}. We round it up to {num_frames}.")
114
+ return height, width, num_frames
115
+
116
+
117
+ def preprocess_image(self, image, torch_dtype=None, device=None, pattern="B C H W", min_value=-1, max_value=1):
118
+ # Transform a PIL.Image to torch.Tensor
119
+ image = torch.Tensor(np.array(image, dtype=np.float32))
120
+ image = image.to(dtype=torch_dtype or self.torch_dtype, device=device or self.device)
121
+ image = image * ((max_value - min_value) / 255) + min_value
122
+ image = repeat(image, f"H W C -> {pattern}", **({"B": 1} if "B" in pattern else {}))
123
+ return image
124
+
125
+
126
+ def preprocess_video(self, video, torch_dtype=None, device=None, pattern="B C T H W", min_value=-1, max_value=1):
127
+ # Transform a list of PIL.Image to torch.Tensor
128
+ video = [self.preprocess_image(image, torch_dtype=torch_dtype, device=device, min_value=min_value, max_value=max_value) for image in video]
129
+ video = torch.stack(video, dim=pattern.index("T") // 2)
130
+ return video
131
+
132
+
133
+ def vae_output_to_image(self, vae_output, pattern="B C H W", min_value=-1, max_value=1):
134
+ # Transform a torch.Tensor to PIL.Image
135
+ if pattern != "H W C":
136
+ vae_output = reduce(vae_output, f"{pattern} -> H W C", reduction="mean")
137
+ image = ((vae_output - min_value) * (255 / (max_value - min_value))).clip(0, 255)
138
+ image = image.to(device="cpu", dtype=torch.uint8)
139
+ image = Image.fromarray(image.numpy())
140
+ return image
141
+
142
+
143
+ def vae_output_to_video(self, vae_output, pattern="B C T H W", min_value=-1, max_value=1):
144
+ # Transform a torch.Tensor to list of PIL.Image
145
+ if pattern != "T H W C":
146
+ vae_output = reduce(vae_output, f"{pattern} -> T H W C", reduction="mean")
147
+ video = [self.vae_output_to_image(image, pattern="H W C", min_value=min_value, max_value=max_value) for image in vae_output]
148
+ return video
149
+
150
+ def output_audio_format_check(self, audio_output):
151
+ # output standard foramt: [C, T], output dtype: float()
152
+ # remove batch dim
153
+ if audio_output.ndim == 3:
154
+ audio_output = audio_output.squeeze(0)
155
+ return audio_output.float().cpu()
156
+
157
+ def load_models_to_device(self, model_names):
158
+ if self.vram_management_enabled:
159
+ # offload models
160
+ for name, model in self.named_children():
161
+ if name not in model_names:
162
+ if hasattr(model, "vram_management_enabled") and model.vram_management_enabled:
163
+ if hasattr(model, "offload"):
164
+ model.offload()
165
+ else:
166
+ for module in model.modules():
167
+ if hasattr(module, "offload"):
168
+ module.offload()
169
+ getattr(torch, self.device_type).empty_cache()
170
+ # onload models
171
+ for name, model in self.named_children():
172
+ if name in model_names:
173
+ if hasattr(model, "vram_management_enabled") and model.vram_management_enabled:
174
+ if hasattr(model, "onload"):
175
+ model.onload()
176
+ else:
177
+ for module in model.modules():
178
+ if hasattr(module, "onload"):
179
+ module.onload()
180
+
181
+
182
+ def generate_noise(self, shape, seed=None, rand_device="cpu", rand_torch_dtype=torch.float32, device=None, torch_dtype=None):
183
+ # Initialize Gaussian noise
184
+ generator = None if seed is None else torch.Generator(rand_device).manual_seed(seed)
185
+ noise = torch.randn(shape, generator=generator, device=rand_device, dtype=rand_torch_dtype)
186
+ noise = noise.to(dtype=torch_dtype or self.torch_dtype, device=device or self.device)
187
+ return noise
188
+
189
+
190
+ def get_vram(self):
191
+ device = self.device if not IS_NPU_AVAILABLE else get_device_name()
192
+ return getattr(torch, self.device_type).mem_get_info(device)[1] / (1024 ** 3)
193
+
194
+ def get_module(self, model, name):
195
+ if "." in name:
196
+ name, suffix = name[:name.index(".")], name[name.index(".") + 1:]
197
+ if name.isdigit():
198
+ return self.get_module(model[int(name)], suffix)
199
+ else:
200
+ return self.get_module(getattr(model, name), suffix)
201
+ else:
202
+ return getattr(model, name)
203
+
204
+
205
+
206
+ def blend_with_mask(self, base, addition, mask):
207
+ return base * (1 - mask) + addition * mask
208
+
209
+
210
+ def step(self, scheduler, latents, progress_id, noise_pred, input_latents=None, inpaint_mask=None, **kwargs):
211
+ timestep = scheduler.timesteps[progress_id]
212
+ if inpaint_mask is not None:
213
+ noise_pred_expected = scheduler.return_to_timestep(scheduler.timesteps[progress_id], latents, input_latents)
214
+ noise_pred = self.blend_with_mask(noise_pred_expected, noise_pred, inpaint_mask)
215
+ latents_next = scheduler.step(noise_pred, timestep, latents)
216
+ return latents_next
217
+
218
+
219
+ def split_pipeline_units(self, model_names: list[str]):
220
+ return PipelineUnitGraph().split_pipeline_units(self.units, model_names)
221
+
222
+
223
+ def flush_vram_management_device(self, device):
224
+ for module in self.modules():
225
+ if isinstance(module, AutoTorchModule):
226
+ module.offload_device = device
227
+ module.onload_device = device
228
+ module.preparing_device = device
229
+ module.computation_device = device
230
+
231
+
232
+ def check_quant_hot_load(self, module: torch.nn.Module):
233
+ # A quantized weight cannot absorb a fused LoRA, so hot-loading is the only option for it.
234
+ if getattr(module, "quantize_config", None) is None:
235
+ return module
236
+ return self.enable_lora_hot_loading(module)
237
+
238
+
239
+ def load_lora(
240
+ self,
241
+ module: torch.nn.Module,
242
+ lora_config: Union[ModelConfig, str] = None,
243
+ alpha=1,
244
+ hotload=None,
245
+ state_dict=None,
246
+ verbose=1,
247
+ ):
248
+ module = self.check_quant_hot_load(module)
249
+ if state_dict is None:
250
+ if isinstance(lora_config, str):
251
+ lora = load_state_dict(lora_config, torch_dtype=self.torch_dtype, device=self.device)
252
+ else:
253
+ lora_config.download_if_necessary()
254
+ lora = load_state_dict(lora_config.path, torch_dtype=self.torch_dtype, device=self.device)
255
+ else:
256
+ lora = state_dict
257
+ lora_loader = self.lora_loader(torch_dtype=self.torch_dtype, device=self.device)
258
+ lora = lora_loader.convert_state_dict(lora)
259
+ if hotload is None:
260
+ hotload = hasattr(module, "vram_management_enabled") and getattr(module, "vram_management_enabled")
261
+ if hotload:
262
+ if not (hasattr(module, "vram_management_enabled") and getattr(module, "vram_management_enabled")):
263
+ raise ValueError("VRAM Management is not enabled. LoRA hotloading is not supported.")
264
+ updated_num = 0
265
+ for _, module in module.named_modules():
266
+ if isinstance(module, LoRAHotLoadMixin):
267
+ name = module.name
268
+ lora_a_name = f'{name}.lora_A.weight'
269
+ lora_b_name = f'{name}.lora_B.weight'
270
+ if lora_a_name in lora and lora_b_name in lora:
271
+ updated_num += 1
272
+ module.lora_A_weights.append(lora[lora_a_name] * alpha)
273
+ module.lora_B_weights.append(lora[lora_b_name])
274
+ if verbose >= 1:
275
+ print(f"{updated_num} tensors are patched by LoRA. You can use `pipe.clear_lora()` to clear all LoRA layers.")
276
+ else:
277
+ lora_loader.fuse_lora_to_base_model(module, lora, alpha=alpha)
278
+
279
+
280
+ def clear_lora(self, verbose=1):
281
+ cleared_num = 0
282
+ for name, module in self.named_modules():
283
+ if isinstance(module, LoRAHotLoadMixin):
284
+ if hasattr(module, "lora_A_weights"):
285
+ if len(module.lora_A_weights) > 0:
286
+ cleared_num += 1
287
+ module.lora_A_weights.clear()
288
+ if hasattr(module, "lora_B_weights"):
289
+ module.lora_B_weights.clear()
290
+ if verbose >= 1:
291
+ print(f"{cleared_num} LoRA layers are cleared.")
292
+
293
+
294
+ def download_and_load_models(self, model_configs: list[ModelConfig] = [], vram_limit: float = None):
295
+ model_pool = ModelPool()
296
+ for model_config in model_configs:
297
+ model_config.download_if_necessary()
298
+ vram_config = model_config.vram_config()
299
+ vram_config["computation_dtype"] = vram_config["computation_dtype"] or self.torch_dtype
300
+ vram_config["computation_device"] = vram_config["computation_device"] or self.device
301
+ model_pool.auto_load_model(
302
+ model_config.path,
303
+ vram_config=vram_config,
304
+ vram_limit=vram_limit,
305
+ clear_parameters=model_config.clear_parameters,
306
+ state_dict=model_config.state_dict,
307
+ quantize=model_config.quantize,
308
+ )
309
+ return model_pool
310
+
311
+
312
+ def check_vram_management_state(self):
313
+ vram_management_enabled = False
314
+ for module in self.children():
315
+ if hasattr(module, "vram_management_enabled") and getattr(module, "vram_management_enabled"):
316
+ vram_management_enabled = True
317
+ return vram_management_enabled
318
+
319
+
320
+ def cfg_guided_model_fn(self, model_fn, cfg_scale, inputs_shared, inputs_posi, inputs_nega, **inputs_others):
321
+ # Positive side forward
322
+ if inputs_shared.get("positive_only_lora", None) is not None:
323
+ self.load_lora(self.dit, state_dict=inputs_shared["positive_only_lora"], verbose=0)
324
+ noise_pred_posi = model_fn(**inputs_posi, **inputs_shared, **inputs_others)
325
+ if inputs_shared.get("positive_only_lora", None) is not None:
326
+ self.clear_lora(verbose=0)
327
+
328
+ if cfg_scale != 1.0:
329
+ # Negative side forward
330
+ if inputs_shared.get("negative_only_lora", None) is not None:
331
+ self.load_lora(self.dit, state_dict=inputs_shared["negative_only_lora"], verbose=0)
332
+ noise_pred_nega = model_fn(**inputs_nega, **inputs_shared, **inputs_others)
333
+ if inputs_shared.get("negative_only_lora", None) is not None:
334
+ self.clear_lora(verbose=0)
335
+
336
+ if isinstance(noise_pred_posi, tuple):
337
+ # Separately handling different output types of latents, eg. video and audio latents.
338
+ noise_pred = tuple(
339
+ n_nega + cfg_scale * (n_posi - n_nega)
340
+ for n_posi, n_nega in zip(noise_pred_posi, noise_pred_nega)
341
+ )
342
+ else:
343
+ noise_pred = noise_pred_nega + cfg_scale * (noise_pred_posi - noise_pred_nega)
344
+ else:
345
+ noise_pred = noise_pred_posi
346
+ return noise_pred
347
+
348
+
349
+
350
+
351
+ def enable_lora_hot_loading(self, model: torch.nn.Module):
352
+ if hasattr(model, "vram_management_enabled") and getattr(model, "vram_management_enabled"):
353
+ return model
354
+ module_map = {torch.nn.Linear: AutoWrappedLinear}
355
+ vram_config = {
356
+ "offload_dtype": self.torch_dtype,
357
+ "offload_device": self.device,
358
+ "onload_dtype": self.torch_dtype,
359
+ "onload_device": self.device,
360
+ "preparing_dtype": self.torch_dtype,
361
+ "preparing_device": self.device,
362
+ "computation_dtype": self.torch_dtype,
363
+ "computation_device": self.device,
364
+ }
365
+ model = enable_vram_management(model, module_map, vram_config=vram_config, quantize=getattr(model, "quantize_config", None))
366
+ return model
367
+
368
+ def compile_pipeline(self, mode: str = "default", dynamic: bool = True, fullgraph: bool = False, compile_models: list = None, **kwargs):
369
+ """
370
+ compile the pipeline with torch.compile. The models that will be compiled are determined by the `compilable_models` attribute of the pipeline.
371
+ If a model has `_repeated_blocks` attribute, we will compile these blocks with regional compilation. Otherwise, we will compile the whole model.
372
+ See https://docs.pytorch.org/docs/stable/generated/torch.compile.html#torch.compile for details about compilation arguments.
373
+ Args:
374
+ mode: The compilation mode, which will be passed to `torch.compile`, options are "default", "reduce-overhead", "max-autotune" and "max-autotune-no-cudagraphs. Default to "default".
375
+ dynamic: Whether to enable dynamic graph compilation to support dynamic input shapes, which will be passed to `torch.compile`. Default to True (recommended).
376
+ fullgraph: Whether to use full graph compilation, which will be passed to `torch.compile`. Default to False (recommended).
377
+ compile_models: The list of model names to be compiled. If None, we will compile the models in `pipeline.compilable_models`. Default to None.
378
+ **kwargs: Other arguments for `torch.compile`.
379
+ """
380
+ compile_models = compile_models or getattr(self, "compilable_models", [])
381
+ if len(compile_models) == 0:
382
+ print("No compilable models in the pipeline. Skip compilation.")
383
+ return
384
+ for name in compile_models:
385
+ model = getattr(self, name, None)
386
+ if model is None:
387
+ print(f"Model '{name}' not found in the pipeline.")
388
+ continue
389
+ repeated_blocks = getattr(model, "_repeated_blocks", None)
390
+ # regional compilation for repeated blocks.
391
+ if repeated_blocks is not None:
392
+ for submod in model.modules():
393
+ if submod.__class__.__name__ in repeated_blocks:
394
+ submod.compile(mode=mode, dynamic=dynamic, fullgraph=fullgraph, **kwargs)
395
+ # compile the whole model.
396
+ else:
397
+ model.compile(mode=mode, dynamic=dynamic, fullgraph=fullgraph, **kwargs)
398
+ print(f"{name} is compiled with mode={mode}, dynamic={dynamic}, fullgraph={fullgraph}.")
399
+
400
+
401
+ class PipelineUnitGraph:
402
+ def __init__(self):
403
+ pass
404
+
405
+ def build_edges(self, units: list[PipelineUnit]):
406
+ # Establish dependencies between units
407
+ # to search for subsequent related computation units.
408
+ last_compute_unit_id = {}
409
+ edges = []
410
+ for unit_id, unit in enumerate(units):
411
+ for input_param in unit.fetch_input_params():
412
+ if input_param in last_compute_unit_id:
413
+ edges.append((last_compute_unit_id[input_param], unit_id))
414
+ for output_param in unit.fetch_output_params():
415
+ last_compute_unit_id[output_param] = unit_id
416
+ return edges
417
+
418
+ def build_chains(self, units: list[PipelineUnit]):
419
+ # Establish updating chains for each variable
420
+ # to track their computation process.
421
+ params = sum([unit.fetch_input_params() + unit.fetch_output_params() for unit in units], [])
422
+ params = sorted(list(set(params)))
423
+ chains = {param: [] for param in params}
424
+ for unit_id, unit in enumerate(units):
425
+ for param in unit.fetch_output_params():
426
+ chains[param].append(unit_id)
427
+ return chains
428
+
429
+ def search_direct_unit_ids(self, units: list[PipelineUnit], model_names: list[str]):
430
+ # Search for units that directly participate in the model's computation.
431
+ related_unit_ids = []
432
+ for unit_id, unit in enumerate(units):
433
+ for model_name in model_names:
434
+ if unit.onload_model_names is not None and model_name in unit.onload_model_names:
435
+ related_unit_ids.append(unit_id)
436
+ break
437
+ return related_unit_ids
438
+
439
+ def search_related_unit_ids(self, edges, start_unit_ids, direction="target"):
440
+ # Search for subsequent related computation units.
441
+ related_unit_ids = [unit_id for unit_id in start_unit_ids]
442
+ while True:
443
+ neighbors = []
444
+ for source, target in edges:
445
+ if direction == "target" and source in related_unit_ids and target not in related_unit_ids:
446
+ neighbors.append(target)
447
+ elif direction == "source" and source not in related_unit_ids and target in related_unit_ids:
448
+ neighbors.append(source)
449
+ neighbors = sorted(list(set(neighbors)))
450
+ if len(neighbors) == 0:
451
+ break
452
+ else:
453
+ related_unit_ids.extend(neighbors)
454
+ related_unit_ids = sorted(list(set(related_unit_ids)))
455
+ return related_unit_ids
456
+
457
+ def search_updating_unit_ids(self, units: list[PipelineUnit], chains, related_unit_ids):
458
+ # If the input parameters of this subgraph are updated outside the subgraph,
459
+ # search for the units where these updates occur.
460
+ first_compute_unit_id = {}
461
+ for unit_id in related_unit_ids:
462
+ for param in units[unit_id].fetch_input_params():
463
+ if param not in first_compute_unit_id:
464
+ first_compute_unit_id[param] = unit_id
465
+ updating_unit_ids = []
466
+ for param in first_compute_unit_id:
467
+ unit_id = first_compute_unit_id[param]
468
+ chain = chains[param]
469
+ if unit_id in chain and chain.index(unit_id) != len(chain) - 1:
470
+ for unit_id_ in chain[chain.index(unit_id) + 1:]:
471
+ if unit_id_ not in related_unit_ids:
472
+ updating_unit_ids.append(unit_id_)
473
+ related_unit_ids.extend(updating_unit_ids)
474
+ related_unit_ids = sorted(list(set(related_unit_ids)))
475
+ return related_unit_ids
476
+
477
+ def split_pipeline_units(self, units: list[PipelineUnit], model_names: list[str]):
478
+ # Split the computation graph,
479
+ # separating all model-related computations.
480
+ related_unit_ids = self.search_direct_unit_ids(units, model_names)
481
+ edges = self.build_edges(units)
482
+ chains = self.build_chains(units)
483
+ while True:
484
+ num_related_unit_ids = len(related_unit_ids)
485
+ related_unit_ids = self.search_related_unit_ids(edges, related_unit_ids, "target")
486
+ related_unit_ids = self.search_updating_unit_ids(units, chains, related_unit_ids)
487
+ if len(related_unit_ids) == num_related_unit_ids:
488
+ break
489
+ else:
490
+ num_related_unit_ids = len(related_unit_ids)
491
+ related_units = [units[i] for i in related_unit_ids]
492
+ unrelated_units = [units[i] for i in range(len(units)) if i not in related_unit_ids]
493
+ return related_units, unrelated_units
494
+
495
+
496
+ class PipelineUnitRunner:
497
+ def __init__(self):
498
+ pass
499
+
500
+ def __call__(self, unit: PipelineUnit, pipe: BasePipeline, inputs_shared: dict, inputs_posi: dict, inputs_nega: dict) -> tuple[dict, dict]:
501
+ if unit.take_over:
502
+ # Let the pipeline unit take over this function.
503
+ inputs_shared, inputs_posi, inputs_nega = unit.process(pipe, inputs_shared=inputs_shared, inputs_posi=inputs_posi, inputs_nega=inputs_nega)
504
+ elif unit.seperate_cfg:
505
+ # Positive side
506
+ processor_inputs = {name: inputs_posi.get(name_) for name, name_ in unit.input_params_posi.items()}
507
+ if unit.input_params is not None:
508
+ for name in unit.input_params:
509
+ processor_inputs[name] = inputs_shared.get(name)
510
+ processor_outputs = unit.process(pipe, **processor_inputs)
511
+ inputs_posi.update(processor_outputs)
512
+ # Negative side
513
+ if inputs_shared["cfg_scale"] != 1:
514
+ processor_inputs = {name: inputs_nega.get(name_) for name, name_ in unit.input_params_nega.items()}
515
+ if unit.input_params is not None:
516
+ for name in unit.input_params:
517
+ processor_inputs[name] = inputs_shared.get(name)
518
+ processor_outputs = unit.process(pipe, **processor_inputs)
519
+ inputs_nega.update(processor_outputs)
520
+ else:
521
+ inputs_nega.update(processor_outputs)
522
+ else:
523
+ processor_inputs = {name: inputs_shared.get(name) for name in unit.input_params}
524
+ processor_outputs = unit.process(pipe, **processor_inputs)
525
+ inputs_shared.update(processor_outputs)
526
+ return inputs_shared, inputs_posi, inputs_nega
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
deployment/runtime/qwen/diffsynth/diffusion/template.py CHANGED
@@ -1,209 +1,200 @@
1
- import torch, os, importlib, warnings, json, inspect
2
- from typing import Dict, List, Tuple, Union
3
- from ..core import ModelConfig, load_model
4
- from ..core.device.npu_compatible_device import get_device_type
5
- from ..utils.lora.merge import merge_lora
6
-
7
-
8
- KVCache = Dict[str, Tuple[torch.Tensor, torch.Tensor]]
9
-
10
-
11
- class TemplateModel(torch.nn.Module):
12
- def __init__(self):
13
- super().__init__()
14
-
15
- @torch.no_grad()
16
- def process_inputs(self, **kwargs):
17
- return {}
18
-
19
- def forward(self, **kwargs):
20
- raise NotImplementedError()
21
-
22
-
23
- def check_template_model_format(model):
24
- if not hasattr(model, "process_inputs"):
25
- raise NotImplementedError("`process_inputs` is not implemented in the Template model.")
26
- if "kwargs" not in inspect.signature(model.process_inputs).parameters:
27
- raise NotImplementedError("`**kwargs` is not included in `process_inputs`.")
28
- if not hasattr(model, "forward"):
29
- raise NotImplementedError("`forward` is not implemented in the Template model.")
30
- if "kwargs" not in inspect.signature(model.forward).parameters:
31
- raise NotImplementedError("`**kwargs` is not included in `forward`.")
32
-
33
-
34
- def load_template_model(path, torch_dtype=torch.bfloat16, device="cuda", verbose=1, state_dict=None):
35
- spec = importlib.util.spec_from_file_location("template_model", os.path.join(path, "model.py"))
36
- module = importlib.util.module_from_spec(spec)
37
- spec.loader.exec_module(module)
38
- template_model_path = getattr(module, 'TEMPLATE_MODEL_PATH') if hasattr(module, 'TEMPLATE_MODEL_PATH') else None
39
- if template_model_path is not None:
40
- # With `TEMPLATE_MODEL_PATH`, a pretrained model will be loaded.
41
- model = load_model(
42
- model_class=getattr(module, 'TEMPLATE_MODEL'),
43
- config=getattr(module, 'TEMPLATE_MODEL_CONFIG') if hasattr(module, 'TEMPLATE_MODEL_CONFIG') else None,
44
- path=os.path.join(path, getattr(module, 'TEMPLATE_MODEL_PATH')),
45
- torch_dtype=torch_dtype,
46
- device=device,
47
- state_dict=state_dict,
48
- )
49
- else:
50
- # Without `TEMPLATE_MODEL_PATH`, a randomly initialized model or a non-model module will be loaded.
51
- model = module.TEMPLATE_MODEL()
52
- if hasattr(model, "to"):
53
- model = model.to(dtype=torch_dtype, device=device)
54
- if hasattr(model, "eval"):
55
- model = model.eval()
56
- check_template_model_format(model)
57
- if verbose > 0:
58
- metadata = {
59
- "model_architecture": getattr(module, 'TEMPLATE_MODEL').__name__,
60
- "code_path": os.path.join(path, "model.py"),
61
- "weight_path": template_model_path,
62
- }
63
- print(f"Template model loaded: {json.dumps(metadata, indent=4)}")
64
- return model
65
-
66
-
67
- def load_template_data_processor(path):
68
- spec = importlib.util.spec_from_file_location("template_model", os.path.join(path, "model.py"))
69
- module = importlib.util.module_from_spec(spec)
70
- spec.loader.exec_module(module)
71
- if hasattr(module, 'TEMPLATE_DATA_PROCESSOR'):
72
- processor = getattr(module, 'TEMPLATE_DATA_PROCESSOR')
73
- return processor
74
- else:
75
- return None
76
-
77
-
78
- class TemplatePipeline(torch.nn.Module):
79
- def __init__(
80
- self,
81
- torch_dtype: torch.dtype = torch.bfloat16,
82
- device: Union[str, torch.device] = get_device_type(),
83
- model_configs: list[ModelConfig] = [],
84
- lazy_loading: bool = False,
85
- ):
86
- super().__init__()
87
- self.torch_dtype = torch_dtype
88
- self.device = device
89
- self.model_configs = model_configs
90
- self.lazy_loading = lazy_loading
91
- if lazy_loading:
92
- for model_config in model_configs:
93
- TemplatePipeline.check_vram_config(model_config)
94
- model_config.download_if_necessary()
95
- self.models = None
96
- else:
97
- models = []
98
- for model_config in model_configs:
99
- TemplatePipeline.check_vram_config(model_config)
100
- model_config.download_if_necessary()
101
- model = load_template_model(model_config.path, torch_dtype=torch_dtype, device=device, state_dict=model_config.state_dict)
102
- models.append(model)
103
- self.models = torch.nn.ModuleList(models)
104
-
105
- def merge_kv_cache(self, kv_cache_list: List[KVCache]) -> KVCache:
106
- names = {}
107
- for kv_cache in kv_cache_list:
108
- for name in kv_cache:
109
- names[name] = None
110
- kv_cache_merged = {}
111
- for name in names:
112
- kv_list = [kv_cache.get(name) for kv_cache in kv_cache_list]
113
- kv_list = [kv for kv in kv_list if kv is not None]
114
- if len(kv_list) > 0:
115
- k = torch.concat([kv[0] for kv in kv_list], dim=1)
116
- v = torch.concat([kv[1] for kv in kv_list], dim=1)
117
- kv_cache_merged[name] = (k, v)
118
- return kv_cache_merged
119
-
120
- def merge_sequential_embeddings(self, embeddings: List[torch.Tensor]):
121
- return torch.concat(embeddings, dim=-2)
122
-
123
- def merge_template_cache(self, template_cache_list):
124
- params = sorted(list(set(sum([list(template_cache.keys()) for template_cache in template_cache_list], []))))
125
- template_cache_merged = {}
126
- for param in params:
127
- data = [template_cache[param] for template_cache in template_cache_list if param in template_cache]
128
- if param == "kv_cache":
129
- data = self.merge_kv_cache(data)
130
- elif param == "lora":
131
- data = merge_lora(data)
132
- elif param == "text_embedding":
133
- data = self.merge_sequential_embeddings(data)
134
- elif len(data) == 1:
135
- data = data[0]
136
- else:
137
- print(f"Conflict detected: `{param}` appears in the outputs of multiple Template models. Only the first one will be retained.")
138
- data = data[0]
139
- template_cache_merged[param] = data
140
- return template_cache_merged
141
-
142
- @staticmethod
143
- def check_vram_config(model_config: ModelConfig):
144
- params = [
145
- model_config.offload_device, model_config.offload_dtype,
146
- model_config.onload_device, model_config.onload_dtype,
147
- model_config.preparing_device, model_config.preparing_dtype,
148
- model_config.computation_device, model_config.computation_dtype,
149
- ]
150
- for param in params:
151
- if param is not None:
152
- warnings.warn("TemplatePipeline doesn't support VRAM management. VRAM config will be ignored.")
153
-
154
- @staticmethod
155
- def from_pretrained(
156
- torch_dtype: torch.dtype = torch.bfloat16,
157
- device: Union[str, torch.device] = get_device_type(),
158
- model_configs: list[ModelConfig] = [],
159
- lazy_loading: bool = False,
160
- ):
161
- pipe = TemplatePipeline(torch_dtype, device, model_configs, lazy_loading)
162
- return pipe
163
-
164
- def fetch_model(self, model_id):
165
- if self.lazy_loading:
166
- model_config = self.model_configs[model_id]
167
- model_config.download_if_necessary()
168
- model = load_template_model(model_config.path, torch_dtype=self.torch_dtype, device=self.device, state_dict=model_config.state_dict)
169
- else:
170
- model = self.models[model_id]
171
- return model
172
-
173
- def call_single_side(self, pipe=None, inputs: List[Dict] = None):
174
- model = None
175
- onload_model_id = -1
176
- template_cache = []
177
- for i in inputs:
178
- model_id = i.get("model_id", 0)
179
- if model_id != onload_model_id:
180
- model = self.fetch_model(model_id)
181
- onload_model_id = model_id
182
- cache = model.process_inputs(pipe=pipe, **i)
183
- cache = model.forward(pipe=pipe, **cache)
184
- template_cache.append(cache)
185
- template_cache = self.merge_template_cache(template_cache)
186
- return template_cache
187
-
188
- @torch.no_grad()
189
- def __call__(
190
- self,
191
- pipe=None,
192
- template_inputs: List[Dict] = None,
193
- negative_template_inputs: List[Dict] = None,
194
- **kwargs,
195
- ):
196
- template_cache = self.call_single_side(pipe=pipe, inputs=template_inputs or [])
197
- negative_template_cache = self.call_single_side(pipe=pipe, inputs=negative_template_inputs or [])
198
- required_params = list(inspect.signature(pipe.__call__).parameters.keys())
199
- for param in template_cache:
200
- if param in required_params:
201
- kwargs[param] = template_cache[param]
202
- else:
203
- print(f"`{param}` is not included in the inputs of `{pipe.__class__.__name__}`. This parameter will be ignored.")
204
- for param in negative_template_cache:
205
- if "negative_" + param in required_params:
206
- kwargs["negative_" + param] = negative_template_cache[param]
207
- else:
208
- print(f"`{'negative_' + param}` is not included in the inputs of `{pipe.__class__.__name__}`. This parameter will be ignored.")
209
- return pipe(**kwargs)
 
1
+ import torch, os, importlib, warnings, json, inspect
2
+ from typing import Dict, List, Tuple, Union
3
+ from ..core import ModelConfig, load_model
4
+ from ..core.device.npu_compatible_device import get_device_type
5
+ from ..utils.lora.merge import merge_lora
6
+
7
+
8
+ KVCache = Dict[str, Tuple[torch.Tensor, torch.Tensor]]
9
+
10
+
11
+ class TemplateModel(torch.nn.Module):
12
+ def __init__(self):
13
+ super().__init__()
14
+
15
+ @torch.no_grad()
16
+ def process_inputs(self, **kwargs):
17
+ return {}
18
+
19
+ def forward(self, **kwargs):
20
+ raise NotImplementedError()
21
+
22
+
23
+ def check_template_model_format(model):
24
+ if not hasattr(model, "process_inputs"):
25
+ raise NotImplementedError("`process_inputs` is not implemented in the Template model.")
26
+ if "kwargs" not in inspect.signature(model.process_inputs).parameters:
27
+ raise NotImplementedError("`**kwargs` is not included in `process_inputs`.")
28
+ if not hasattr(model, "forward"):
29
+ raise NotImplementedError("`forward` is not implemented in the Template model.")
30
+ if "kwargs" not in inspect.signature(model.forward).parameters:
31
+ raise NotImplementedError("`**kwargs` is not included in `forward`.")
32
+
33
+
34
+ def load_template_model(path, torch_dtype=torch.bfloat16, device="cuda", verbose=1, state_dict=None):
35
+ spec = importlib.util.spec_from_file_location("template_model", os.path.join(path, "model.py"))
36
+ module = importlib.util.module_from_spec(spec)
37
+ spec.loader.exec_module(module)
38
+ template_model_path = getattr(module, 'TEMPLATE_MODEL_PATH') if hasattr(module, 'TEMPLATE_MODEL_PATH') else None
39
+ if template_model_path is not None:
40
+ # With `TEMPLATE_MODEL_PATH`, a pretrained model will be loaded.
41
+ model = load_model(
42
+ model_class=getattr(module, 'TEMPLATE_MODEL'),
43
+ config=getattr(module, 'TEMPLATE_MODEL_CONFIG') if hasattr(module, 'TEMPLATE_MODEL_CONFIG') else None,
44
+ path=os.path.join(path, getattr(module, 'TEMPLATE_MODEL_PATH')),
45
+ torch_dtype=torch_dtype,
46
+ device=device,
47
+ state_dict=state_dict,
48
+ )
49
+ else:
50
+ # Without `TEMPLATE_MODEL_PATH`, a randomly initialized model or a non-model module will be loaded.
51
+ model = module.TEMPLATE_MODEL()
52
+ if hasattr(model, "to"):
53
+ model = model.to(dtype=torch_dtype, device=device)
54
+ if hasattr(model, "eval"):
55
+ model = model.eval()
56
+ check_template_model_format(model)
57
+ if verbose > 0:
58
+ metadata = {
59
+ "model_architecture": getattr(module, 'TEMPLATE_MODEL').__name__,
60
+ "code_path": os.path.join(path, "model.py"),
61
+ "weight_path": template_model_path,
62
+ }
63
+ print(f"Template model loaded: {json.dumps(metadata, indent=4)}")
64
+ return model
65
+
66
+
67
+
68
+
69
+ class TemplatePipeline(torch.nn.Module):
70
+ def __init__(
71
+ self,
72
+ torch_dtype: torch.dtype = torch.bfloat16,
73
+ device: Union[str, torch.device] = get_device_type(),
74
+ model_configs: list[ModelConfig] = [],
75
+ lazy_loading: bool = False,
76
+ ):
77
+ super().__init__()
78
+ self.torch_dtype = torch_dtype
79
+ self.device = device
80
+ self.model_configs = model_configs
81
+ self.lazy_loading = lazy_loading
82
+ if lazy_loading:
83
+ for model_config in model_configs:
84
+ TemplatePipeline.check_vram_config(model_config)
85
+ model_config.download_if_necessary()
86
+ self.models = None
87
+ else:
88
+ models = []
89
+ for model_config in model_configs:
90
+ TemplatePipeline.check_vram_config(model_config)
91
+ model_config.download_if_necessary()
92
+ model = load_template_model(model_config.path, torch_dtype=torch_dtype, device=device, state_dict=model_config.state_dict)
93
+ models.append(model)
94
+ self.models = torch.nn.ModuleList(models)
95
+
96
+ def merge_kv_cache(self, kv_cache_list: List[KVCache]) -> KVCache:
97
+ names = {}
98
+ for kv_cache in kv_cache_list:
99
+ for name in kv_cache:
100
+ names[name] = None
101
+ kv_cache_merged = {}
102
+ for name in names:
103
+ kv_list = [kv_cache.get(name) for kv_cache in kv_cache_list]
104
+ kv_list = [kv for kv in kv_list if kv is not None]
105
+ if len(kv_list) > 0:
106
+ k = torch.concat([kv[0] for kv in kv_list], dim=1)
107
+ v = torch.concat([kv[1] for kv in kv_list], dim=1)
108
+ kv_cache_merged[name] = (k, v)
109
+ return kv_cache_merged
110
+
111
+ def merge_sequential_embeddings(self, embeddings: List[torch.Tensor]):
112
+ return torch.concat(embeddings, dim=-2)
113
+
114
+ def merge_template_cache(self, template_cache_list):
115
+ params = sorted(list(set(sum([list(template_cache.keys()) for template_cache in template_cache_list], []))))
116
+ template_cache_merged = {}
117
+ for param in params:
118
+ data = [template_cache[param] for template_cache in template_cache_list if param in template_cache]
119
+ if param == "kv_cache":
120
+ data = self.merge_kv_cache(data)
121
+ elif param == "lora":
122
+ data = merge_lora(data)
123
+ elif param == "text_embedding":
124
+ data = self.merge_sequential_embeddings(data)
125
+ elif len(data) == 1:
126
+ data = data[0]
127
+ else:
128
+ print(f"Conflict detected: `{param}` appears in the outputs of multiple Template models. Only the first one will be retained.")
129
+ data = data[0]
130
+ template_cache_merged[param] = data
131
+ return template_cache_merged
132
+
133
+ @staticmethod
134
+ def check_vram_config(model_config: ModelConfig):
135
+ params = [
136
+ model_config.offload_device, model_config.offload_dtype,
137
+ model_config.onload_device, model_config.onload_dtype,
138
+ model_config.preparing_device, model_config.preparing_dtype,
139
+ model_config.computation_device, model_config.computation_dtype,
140
+ ]
141
+ for param in params:
142
+ if param is not None:
143
+ warnings.warn("TemplatePipeline doesn't support VRAM management. VRAM config will be ignored.")
144
+
145
+ @staticmethod
146
+ def from_pretrained(
147
+ torch_dtype: torch.dtype = torch.bfloat16,
148
+ device: Union[str, torch.device] = get_device_type(),
149
+ model_configs: list[ModelConfig] = [],
150
+ lazy_loading: bool = False,
151
+ ):
152
+ pipe = TemplatePipeline(torch_dtype, device, model_configs, lazy_loading)
153
+ return pipe
154
+
155
+ def fetch_model(self, model_id):
156
+ if self.lazy_loading:
157
+ model_config = self.model_configs[model_id]
158
+ model_config.download_if_necessary()
159
+ model = load_template_model(model_config.path, torch_dtype=self.torch_dtype, device=self.device, state_dict=model_config.state_dict)
160
+ else:
161
+ model = self.models[model_id]
162
+ return model
163
+
164
+ def call_single_side(self, pipe=None, inputs: List[Dict] = None):
165
+ model = None
166
+ onload_model_id = -1
167
+ template_cache = []
168
+ for i in inputs:
169
+ model_id = i.get("model_id", 0)
170
+ if model_id != onload_model_id:
171
+ model = self.fetch_model(model_id)
172
+ onload_model_id = model_id
173
+ cache = model.process_inputs(pipe=pipe, **i)
174
+ cache = model.forward(pipe=pipe, **cache)
175
+ template_cache.append(cache)
176
+ template_cache = self.merge_template_cache(template_cache)
177
+ return template_cache
178
+
179
+ @torch.no_grad()
180
+ def __call__(
181
+ self,
182
+ pipe=None,
183
+ template_inputs: List[Dict] = None,
184
+ negative_template_inputs: List[Dict] = None,
185
+ **kwargs,
186
+ ):
187
+ template_cache = self.call_single_side(pipe=pipe, inputs=template_inputs or [])
188
+ negative_template_cache = self.call_single_side(pipe=pipe, inputs=negative_template_inputs or [])
189
+ required_params = list(inspect.signature(pipe.__call__).parameters.keys())
190
+ for param in template_cache:
191
+ if param in required_params:
192
+ kwargs[param] = template_cache[param]
193
+ else:
194
+ print(f"`{param}` is not included in the inputs of `{pipe.__class__.__name__}`. This parameter will be ignored.")
195
+ for param in negative_template_cache:
196
+ if "negative_" + param in required_params:
197
+ kwargs["negative_" + param] = negative_template_cache[param]
198
+ else:
199
+ print(f"`{'negative_' + param}` is not included in the inputs of `{pipe.__class__.__name__}`. This parameter will be ignored.")
200
+ return pipe(**kwargs)
 
 
 
 
 
 
 
 
 
deployment/runtime/qwen/diffsynth/pipelines/qwen_image_21_pbr.py CHANGED
@@ -1,98 +1,90 @@
1
- """Joint Albedo/MRA latent prediction using the Qwen-Image 2.1 backbone."""
2
-
3
- import torch
4
- from torch import nn
5
-
6
- from .qwen_image_21 import patchify, unpatchify
7
-
8
-
9
- class QwenImage21PBRModel(nn.Module):
10
- """Predict two separate material maps with a shared noisy target group.
11
-
12
- The modality axis is ordered ``(albedo, mra)``. MRA pixel semantics belong
13
- to the dataset contract; this module does not infer or repack AO/alpha.
14
- Conditions remain clean, and each map retains its own spatial RoPE block.
15
- """
16
-
17
- modalities = ("albedo", "mra")
18
-
19
- def __init__(self, dit):
20
- super().__init__()
21
- self.dit = dit
22
- channels = dit.img_in.in_features
23
- if channels != dit.out_channels:
24
- raise ValueError("PBR requires equal input and output latent channels")
25
- self.modality_embeddings = nn.Parameter(torch.zeros(2, channels))
26
-
27
- def forward(
28
- self,
29
- latents,
30
- timestep,
31
- prompt_embeds,
32
- prompt_embeds_mask,
33
- edit_image_pad_mask,
34
- edit_latents=None,
35
- use_gradient_checkpointing=False,
36
- use_gradient_checkpointing_offload=False,
37
- use_flex_attention=False,
38
- kv_cache=None,
39
- gradient_checkpointing_interval=1,
40
- ):
41
- if latents.ndim != 5 or latents.shape[1] != len(self.modalities):
42
- raise ValueError("Expected latents shaped [batch, 2, channels, height, width]")
43
- batch_size, _, channels, height, width = latents.shape
44
- if channels != self.modality_embeddings.shape[1]:
45
- raise ValueError("Target latent channels do not match the DiT")
46
- if height % 2 or width % 2:
47
- raise ValueError("Target latent dimensions must be even (pixel dimensions divisible by 32)")
48
- if prompt_embeds.shape[0] != batch_size or edit_image_pad_mask.shape[0] != batch_size:
49
- raise ValueError("Condition and target batch sizes must match")
50
- if not torch.equal(edit_image_pad_mask, edit_image_pad_mask[:1].expand_as(edit_image_pad_mask)):
51
- raise ValueError("Batch elements must share the same image-token layout")
52
- edit_latents = [] if edit_latents is None else edit_latents
53
- for condition in edit_latents:
54
- if condition.ndim != 4 or condition.shape[:2] != (batch_size, channels):
55
- raise ValueError("Each condition must be shaped [batch, channels, height, width]")
56
-
57
- target_tokens = []
58
- for index in range(len(self.modalities)):
59
- tokens = patchify(latents[:, index])
60
- embedding = self.modality_embeddings[index].to(device=tokens.device, dtype=tokens.dtype)
61
- target_tokens.append(tokens + embedding)
62
- hidden_states = torch.cat([*[patchify(image) for image in edit_latents], *target_tokens], dim=1)
63
- target_seq_len = len(self.modalities) * height * width
64
- target_image_pad_mask = torch.ones(
65
- (batch_size, target_seq_len // 4), dtype=torch.bool, device=latents.device
66
- )
67
- image_pad_mask = torch.cat([edit_image_pad_mask.bool(), target_image_pad_mask], dim=1)
68
- img_shapes = [[
69
- *[(1, image.shape[2], image.shape[3]) for image in edit_latents],
70
- *[(1, height, width) for _ in self.modalities],
71
- ] for _ in range(batch_size)]
72
- model_output = self.dit(
73
- hidden_states=hidden_states,
74
- timestep=timestep / 1000,
75
- encoder_hidden_states=prompt_embeds,
76
- encoder_hidden_states_mask=prompt_embeds_mask,
77
- img_shapes=img_shapes,
78
- img_mask=image_pad_mask,
79
- kv_cache=kv_cache,
80
- use_gradient_checkpointing=use_gradient_checkpointing,
81
- use_gradient_checkpointing_offload=use_gradient_checkpointing_offload,
82
- use_flex_attention=use_flex_attention,
83
- num_target_images=len(self.modalities),
84
- gradient_checkpointing_interval=gradient_checkpointing_interval,
85
- )
86
- target_output = model_output[:, -target_seq_len:]
87
- return torch.stack([
88
- unpatchify(tokens, height, width)
89
- for tokens in target_output.split(height * width, dim=1)
90
- ], dim=1)
91
-
92
-
93
- def pbr_flow_matching_losses(prediction, target):
94
- """Equal-weight latent flow losses, with separate material diagnostics."""
95
- if prediction.shape != target.shape or prediction.ndim != 5 or prediction.shape[1] != 2:
96
- raise ValueError("Prediction and target must have matching [batch, 2, channels, height, width] shapes")
97
- losses = (prediction.float() - target.float()).square().mean(dim=(0, 2, 3, 4))
98
- return {"albedo": losses[0], "mra": losses[1], "loss": losses.mean()}
 
1
+ """Joint Albedo/MRA latent prediction using the Qwen-Image 2.1 backbone."""
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+ from .qwen_image_21 import patchify, unpatchify
7
+
8
+
9
+ class QwenImage21PBRModel(nn.Module):
10
+ """Predict two separate material maps with a shared noisy target group.
11
+
12
+ The modality axis is ordered ``(albedo, mra)``. MRA pixel semantics belong
13
+ to the dataset contract; this module does not infer or repack AO/alpha.
14
+ Conditions remain clean, and each map retains its own spatial RoPE block.
15
+ """
16
+
17
+ modalities = ("albedo", "mra")
18
+
19
+ def __init__(self, dit):
20
+ super().__init__()
21
+ self.dit = dit
22
+ channels = dit.img_in.in_features
23
+ if channels != dit.out_channels:
24
+ raise ValueError("PBR requires equal input and output latent channels")
25
+ self.modality_embeddings = nn.Parameter(torch.zeros(2, channels))
26
+
27
+ def forward(
28
+ self,
29
+ latents,
30
+ timestep,
31
+ prompt_embeds,
32
+ prompt_embeds_mask,
33
+ edit_image_pad_mask,
34
+ edit_latents=None,
35
+ use_gradient_checkpointing=False,
36
+ use_gradient_checkpointing_offload=False,
37
+ use_flex_attention=False,
38
+ kv_cache=None,
39
+ gradient_checkpointing_interval=1,
40
+ ):
41
+ if latents.ndim != 5 or latents.shape[1] != len(self.modalities):
42
+ raise ValueError("Expected latents shaped [batch, 2, channels, height, width]")
43
+ batch_size, _, channels, height, width = latents.shape
44
+ if channels != self.modality_embeddings.shape[1]:
45
+ raise ValueError("Target latent channels do not match the DiT")
46
+ if height % 2 or width % 2:
47
+ raise ValueError("Target latent dimensions must be even (pixel dimensions divisible by 32)")
48
+ if prompt_embeds.shape[0] != batch_size or edit_image_pad_mask.shape[0] != batch_size:
49
+ raise ValueError("Condition and target batch sizes must match")
50
+ if not torch.equal(edit_image_pad_mask, edit_image_pad_mask[:1].expand_as(edit_image_pad_mask)):
51
+ raise ValueError("Batch elements must share the same image-token layout")
52
+ edit_latents = [] if edit_latents is None else edit_latents
53
+ for condition in edit_latents:
54
+ if condition.ndim != 4 or condition.shape[:2] != (batch_size, channels):
55
+ raise ValueError("Each condition must be shaped [batch, channels, height, width]")
56
+
57
+ target_tokens = []
58
+ for index in range(len(self.modalities)):
59
+ tokens = patchify(latents[:, index])
60
+ embedding = self.modality_embeddings[index].to(device=tokens.device, dtype=tokens.dtype)
61
+ target_tokens.append(tokens + embedding)
62
+ hidden_states = torch.cat([*[patchify(image) for image in edit_latents], *target_tokens], dim=1)
63
+ target_seq_len = len(self.modalities) * height * width
64
+ target_image_pad_mask = torch.ones(
65
+ (batch_size, target_seq_len // 4), dtype=torch.bool, device=latents.device
66
+ )
67
+ image_pad_mask = torch.cat([edit_image_pad_mask.bool(), target_image_pad_mask], dim=1)
68
+ img_shapes = [[
69
+ *[(1, image.shape[2], image.shape[3]) for image in edit_latents],
70
+ *[(1, height, width) for _ in self.modalities],
71
+ ] for _ in range(batch_size)]
72
+ model_output = self.dit(
73
+ hidden_states=hidden_states,
74
+ timestep=timestep / 1000,
75
+ encoder_hidden_states=prompt_embeds,
76
+ encoder_hidden_states_mask=prompt_embeds_mask,
77
+ img_shapes=img_shapes,
78
+ img_mask=image_pad_mask,
79
+ kv_cache=kv_cache,
80
+ use_gradient_checkpointing=use_gradient_checkpointing,
81
+ use_gradient_checkpointing_offload=use_gradient_checkpointing_offload,
82
+ use_flex_attention=use_flex_attention,
83
+ num_target_images=len(self.modalities),
84
+ gradient_checkpointing_interval=gradient_checkpointing_interval,
85
+ )
86
+ target_output = model_output[:, -target_seq_len:]
87
+ return torch.stack([
88
+ unpatchify(tokens, height, width)
89
+ for tokens in target_output.split(height * width, dim=1)
90
+ ], dim=1)
 
 
 
 
 
 
 
 
deployment/runtime/qwen/examples/qwen_image_21/pbr/infer.py CHANGED
@@ -1,390 +1,371 @@
1
- #!/usr/bin/env python3
2
- """RGB/RGBA-only endpoint inference. No GT, data manifests, downloads or hashes."""
3
- from __future__ import annotations
4
- import argparse
5
- from contextlib import nullcontext
6
- import gc
7
- import importlib.util
8
- import json
9
- import math
10
- import os
11
- from pathlib import Path
12
- import time
13
-
14
- import numpy as np
15
- from PIL import Image
16
- import torch
17
- from safetensors import safe_open
18
- from safetensors.torch import save_file
19
-
20
- from train import DEFAULTS, build_model, decode_pair, endpoint_noise
21
- from validate_local import checkpoint_component, explicit_load
22
- from encode_cache import PROMPT
23
- from inference_offload import (
24
- release_device_cache, sequential_cpu_offload, stage_on_device,
25
- text_encoder_offload_blocks,
26
- )
27
-
28
- # The isolated Qwen environment does not include the application package path.
29
- _timing_helper_path = os.environ.get('SUPRATEX_TIMING_HELPER')
30
- if _timing_helper_path:
31
- _timing_spec = importlib.util.spec_from_file_location('_supratex_timing', _timing_helper_path)
32
- if _timing_spec is None or _timing_spec.loader is None:
33
- raise RuntimeError('could not load the configured Supra-Tex timing helper')
34
- _timing_helper = importlib.util.module_from_spec(_timing_spec)
35
- _timing_spec.loader.exec_module(_timing_helper)
36
- span = _timing_helper.span
37
- else:
38
- span = nullcontext
39
-
40
-
41
- def prepare_image(path, resolution):
42
- if resolution < 32 or resolution % 32:
43
- raise ValueError('resolution must be positive and divisible by 32')
44
- with Image.open(path) as source:
45
- had_alpha = 'A' in source.getbands() or 'transparency' in source.info
46
- original = source.convert('RGBA')
47
- width, height = original.size
48
- scale = min(1., resolution / max(width, height))
49
- content = (max(1, round(width * scale)), max(1, round(height * scale)))
50
- aligned = tuple(math.ceil(value / 32) * 32 for value in content)
51
- offset = tuple((aligned[i] - content[i]) // 2 + (resolution - aligned[i]) // 2 for i in range(2))
52
- resized = original.resize(content, Image.Resampling.LANCZOS) if original.size != content else original.copy()
53
- square = Image.new('RGBA', (resolution, resolution), (0, 0, 0, 0))
54
- square.paste(resized, offset)
55
- transform = dict(source_size=[width, height], content_size=list(content), padded_size=[resolution, resolution],
56
- offset=list(offset), scale=scale, input_had_alpha=had_alpha,
57
- resampling='LANCZOS, same two-stage centered padding as training, no crop or alpha compositing',
58
- coverage='input alpha retained' if had_alpha else 'all-one source coverage; no segmentation inferred')
59
- return original, square, transform
60
-
61
-
62
- def load_checkpoint(path):
63
- path = Path(path)
64
- if path.suffix == '.safetensors':
65
- from safetensors import safe_open
66
- import math
67
-
68
- adapter = json.loads(path.with_name('adapter_config.json').read_text())
69
- if (not isinstance(adapter, dict) or adapter.get('format') != 'supratex_qwen_material_adapter'
70
- or type(adapter.get('schema_version')) is not int or adapter['schema_version'] != 1
71
- or adapter.get('adapter_type') != 'qwen_image_21_pbr_endpoint'
72
- or adapter.get('base_model_name_or_path') != 'Qwen/Qwen-Image-2.1'
73
- or adapter.get('weight_file') != path.name
74
- or type(adapter.get('source_step')) is not int or adapter['source_step'] != 100000):
75
- raise ValueError('unsupported Qwen material adapter config')
76
- inference = adapter.get('inference_config')
77
- booleans = {'lora_output_projection', 'omit_all_valid_mask', 'bf16_autocast', 'deterministic_algorithms'}
78
- required = booleans | {'resolution', 'noise_seed', 'lora_rank', 'lora_alpha', 'attention_backend'}
79
- if not isinstance(inference, dict) or set(inference) != required:
80
- raise ValueError('Qwen adapter requires complete inference config')
81
- if (type(inference['resolution']) is not int or inference['resolution'] != 1024
82
- or type(inference['noise_seed']) is not int or inference['noise_seed'] != 42
83
- or inference['attention_backend'] != 'torch'
84
- or type(inference['lora_rank']) is not int or inference['lora_rank'] <= 0
85
- or type(inference['lora_alpha']) not in {int, float}
86
- or not math.isfinite(inference['lora_alpha']) or inference['lora_alpha'] <= 0
87
- or any(type(inference[key]) is not bool for key in booleans)):
88
- raise ValueError('invalid Qwen adapter inference config')
89
- tensors = adapter.get('tensors')
90
- if not isinstance(tensors, dict) or not tensors:
91
- raise ValueError('Qwen adapter requires a nonempty tensor manifest')
92
- with safe_open(str(path), framework='pt', device='cpu') as reader:
93
- metadata = reader.metadata() or {}
94
- if (metadata.get('format') != 'pt' or metadata.get('adapter_format') != adapter['format']
95
- or metadata.get('schema_version') != str(adapter['schema_version'])
96
- or set(reader.keys()) != set(tensors)):
97
- raise ValueError('Qwen adapter tensor keys or metadata mismatch')
98
- for name, specification in tensors.items():
99
- header = reader.get_slice(name)
100
- if (not isinstance(specification, dict)
101
- or not isinstance(specification.get('shape'), list)
102
- or any(type(size) is not int or size < 0 for size in specification['shape'])
103
- or specification['shape'] != header.get_shape()
104
- or specification.get('dtype') not in {'F64', 'F32', 'F16', 'BF16'}
105
- or specification['dtype'] != header.get_dtype()):
106
- raise ValueError(f'Qwen adapter tensor shape or dtype mismatch: {name}')
107
- weights = {name: reader.get_tensor(name) for name in reader.keys()}
108
- config = DEFAULTS | inference | {'model_root': adapter['base_model_name_or_path']}
109
- return config, weights, adapter['source_step']
110
- marker = json.loads(path.with_suffix('.complete.json').read_text())
111
- if marker.get('status') != 'complete' or marker.get('bytes') != path.stat().st_size:
112
- raise ValueError('checkpoint completion record is absent or mismatched')
113
- # SupraTex adaptation: public inference weights use restricted loading.
114
- # Legacy trainer checkpoints also contain NumPy uint32 RNG arrays; these
115
- # exact reconstruction types retain compatibility without arbitrary pickle.
116
- from numpy.core.multiarray import _reconstruct
117
- with torch.serialization.safe_globals([_reconstruct, (_reconstruct, 'numpy.core.multiarray._reconstruct'),
118
- (_reconstruct, 'numpy._core.multiarray._reconstruct'),
119
- np.ndarray, np.dtype, type(np.dtype('uint32'))]):
120
- state = torch.load(path, map_location='cpu', weights_only=True)
121
- if state.get('format') != 'qwen_pbr_endpoint_v1' or marker.get('step') != state.get('step'):
122
- raise ValueError('unsupported checkpoint or inconsistent step')
123
- if not isinstance(state.get('config'), dict) or not isinstance(state.get('model'), dict):
124
- raise ValueError('checkpoint needs complete config and trainable state')
125
- config = DEFAULTS | state['config']
126
- for key in ('model_root', 'resolution', 'lora_rank', 'lora_alpha', 'noise_seed'):
127
- if key not in state['config']:
128
- raise ValueError(f'checkpoint config lacks {key}')
129
- return config, state['model'], state['step']
130
-
131
-
132
- def strict_load_trainable(model, weights):
133
- expected = {name: parameter for name, parameter in model.named_parameters() if parameter.requires_grad}
134
- if expected.keys() != weights.keys():
135
- raise ValueError('checkpoint trainable key set differs from constructed model')
136
- with torch.no_grad():
137
- for name, parameter in expected.items():
138
- tensor = weights[name]
139
- if tensor.shape != parameter.shape or tensor.dtype != parameter.dtype or not torch.isfinite(tensor).all():
140
- raise ValueError(f'checkpoint tensor mismatch/nonfinite: {name}')
141
- parameter.copy_(tensor)
142
- model.requires_grad_(False).eval()
143
-
144
-
145
- def canonical_conditions(values, resolution):
146
- """Only retain consumed conditioning; material targets are never loaded."""
147
- values = dict(values)
148
- layout = values['edit_image_pad_mask'].bool()
149
- prompt = values.get('prompt_embeds')
150
- if prompt is None:
151
- text = values['prompt_text_embeds']
152
- if layout.ndim != 2 or layout.shape[0] != 1 or text.shape[1] != int((~layout[0]).sum()):
153
- raise ValueError('compact conditioning layout mismatch')
154
- prompt = text.new_zeros((1, layout.shape[1], text.shape[-1]))
155
- prompt[:, ~layout[0]] = text
156
- if prompt.ndim != 3 or prompt.shape[0] != 1 or prompt.shape[2] != 4096 or layout.shape != prompt.shape[:2]:
157
- raise ValueError('invalid prompt/layout shape')
158
- # Model replaces image-placeholder rows from RGB VAE. Canonical zeros allow
159
- # exact native-v2 comparison without changing consumed values.
160
- prompt = prompt.clone()
161
- prompt[:, layout[0]] = 0
162
- mask = values.get('prompt_embeds_mask')
163
- mask = torch.ones_like(layout) if mask is None else mask.bool()
164
- rgb = values['rgb']
165
- if rgb.shape != (1, 64, resolution // 16, resolution // 16) or mask.shape != layout.shape:
166
- raise ValueError('invalid RGB latent/mask shape')
167
- result = dict(rgb=rgb, prompt_embeds=prompt, prompt_embeds_mask=mask, edit_image_pad_mask=layout)
168
- if any(value.is_floating_point() and not torch.isfinite(value).all() for value in result.values()):
169
- raise ValueError('nonfinite conditioning')
170
- return result
171
-
172
-
173
- def load_conditions(path, resolution, device):
174
- values = {}
175
- keys = {'rgb', 'prompt_embeds', 'prompt_text_embeds', 'prompt_embeds_mask', 'edit_image_pad_mask'}
176
- with safe_open(str(path), framework='pt', device=str(device)) as reader:
177
- for key in keys & set(reader.keys()):
178
- values[key] = reader.get_tensor(key)
179
- return canonical_conditions(values, resolution)
180
-
181
-
182
- def input_binding(path):
183
- path = Path(os.path.abspath(path)); stat = path.stat()
184
- return dict(path=str(path), size=stat.st_size, mtime_ns=stat.st_mtime_ns)
185
-
186
-
187
- def verify_cache_input(cache, image, resolution, model_references):
188
- metadata = json.loads(Path(cache).with_suffix('.json').read_text())
189
- binding = metadata.get('input', metadata.get('sources', {}).get('rgb'))
190
- if binding != input_binding(image) or metadata.get('resolution') != resolution:
191
- raise ValueError('cache conditioning is not bound to this exact input/stat/resolution')
192
- if metadata.get('model_root') not in model_references or metadata.get('prompt') != PROMPT:
193
- raise ValueError('cached model reference or prompt differs from checkpoint inference contract')
194
- return metadata['model_root']
195
-
196
-
197
- def native_conditions(square, model_root, resolution, device, low_vram=False):
198
- from transformers import AutoProcessor
199
- from diffsynth.models.qwen_image_21_vae import QwenImage21VAE
200
- from diffsynth.models.qwen_image_21_text_encoder import QwenImage21TextEncoder
201
- from diffsynth.pipelines.qwen_image_21 import QwenImage21Pipeline, QwenImage21Unit_EditImageEmbedder, QwenImage21Unit_PromptEmbedder
202
- from diffsynth.utils.state_dict_converters.qwen_image_21_text_encoder import QwenImage21TextEncoderStateDictConverter
203
- processor = AutoProcessor.from_pretrained(str(model_root / 'processor'), local_files_only=True)
204
- processor.image_processor.size = {'shortest_edge': resolution ** 2, 'longest_edge': resolution ** 2}
205
- pipe = QwenImage21Pipeline(device=device, torch_dtype=torch.bfloat16)
206
- pipe.processor = processor
207
- loading_device = 'cpu' if low_vram else device
208
- vae = explicit_load(QwenImage21VAE, checkpoint_component(model_root, 'vae'), loading_device, torch.bfloat16)
209
- pipe.vae = vae
210
- with stage_on_device(vae, device, enabled=low_vram), torch.inference_mode():
211
- edited = QwenImage21Unit_EditImageEmbedder().process(pipe, edit_image=[square], height=resolution, width=resolution,
212
- tiled=False, tile_size=256, tile_stride=192)
213
- if edited['edit_image'][0].size != square.size:
214
- raise ValueError('native edit path changed dimensions')
215
- rgb = edited['edit_latents'][0]
216
- encoder = explicit_load(QwenImage21TextEncoder, checkpoint_component(model_root, 'text_encoder'), loading_device,
217
- torch.bfloat16, QwenImage21TextEncoderStateDictConverter)
218
- pipe.text_encoder = encoder
219
- blocks = text_encoder_offload_blocks(encoder) if low_vram else ()
220
- with sequential_cpu_offload(encoder, blocks, device, enabled=low_vram), torch.inference_mode():
221
- values = QwenImage21Unit_PromptEmbedder().process(pipe, prompt=PROMPT, edit_image=[square])
222
- conditions = canonical_conditions(dict(values, rgb=rgb), resolution)
223
- pipe.text_encoder = None
224
- del encoder, pipe, values, edited, blocks
225
- gc.collect(); torch.cuda.empty_cache()
226
- return conditions, vae
227
-
228
-
229
- def predict(model, conditions, cfg):
230
- # Shape comes from observed RGB, never a GT or fabricated target tensor.
231
- shape = (conditions['rgb'].shape[0], 2, *conditions['rgb'].shape[1:])
232
- shape_carrier = conditions['rgb'].new_empty(shape)
233
- noise = endpoint_noise(shape_carrier, cfg['noise_seed'])
234
- mask = conditions['prompt_embeds_mask']
235
- if cfg['omit_all_valid_mask'] and bool(mask.all()):
236
- mask = None
237
- with torch.inference_mode(), torch.autocast('cuda', dtype=torch.bfloat16, enabled=cfg['bf16_autocast']):
238
- velocity = model(latents=noise, timestep=torch.full((noise.shape[0],), 1000., device=noise.device),
239
- prompt_embeds=conditions['prompt_embeds'], prompt_embeds_mask=mask,
240
- edit_image_pad_mask=conditions['edit_image_pad_mask'], edit_latents=[conditions['rgb']],
241
- use_gradient_checkpointing=False, use_flex_attention=False, kv_cache=None)
242
- prediction = noise.float() - velocity.float()
243
- if not torch.isfinite(prediction).all():
244
- raise FloatingPointError('nonfinite material latent prediction')
245
- return prediction
246
-
247
-
248
- def predict_and_decode(model, vae, conditions, cfg, low_vram=False):
249
- device = conditions['rgb'].device
250
- try:
251
- with sequential_cpu_offload(model, model.dit.transformer_blocks, device, enabled=low_vram):
252
- latent = predict(model, conditions, cfg)
253
- finally:
254
- if low_vram:
255
- # Qwen stores RoPE tables as an ordinary list, outside Module buffers.
256
- model.dit.pos_embed.freqs = [value.cpu() for value in model.dit.pos_embed.freqs]
257
- release_device_cache(device)
258
- with stage_on_device(vae, device, enabled=low_vram), torch.inference_mode():
259
- decoded = decode_pair(vae, latent)
260
- return latent, decoded
261
-
262
-
263
- def output_maps(decoded, square, original, transform, restore_size):
264
- if decoded.shape[0:3] != (1, 2, 4) or not torch.isfinite(decoded).all():
265
- raise ValueError('invalid/nonfinite decoded material pair')
266
- arrays = np.clip(np.rint(decoded[0].detach().float().cpu().permute(0, 2, 3, 1).numpy() * 255), 0, 255).astype(np.uint8)
267
- diagnostics = [Image.fromarray(array[:, :, 3], 'L') for array in arrays]
268
- maps = []
269
- for modality, array in enumerate(arrays):
270
- color = Image.fromarray(array[:, :, :3], 'RGB')
271
- coverage = square.getchannel('A')
272
- if restore_size:
273
- x, y = transform['offset']; w, h = transform['content_size']
274
- color = color.crop((x, y, x + w, y + h))
275
- if color.size != original.size:
276
- color = color.resize(original.size, Image.Resampling.LANCZOS)
277
- coverage = original.getchannel('A') # exact original coverage, no second interpolation
278
- out = np.array(color.convert('RGBA'))
279
- out[:, :, 3] = np.array(coverage)
280
- if modality == 1:
281
- out[:, :, 2] = 255 # placeholder, not material opacity or AO prediction
282
- maps.append(Image.fromarray(out, 'RGBA'))
283
- return maps, diagnostics
284
-
285
-
286
- def main():
287
- parser = argparse.ArgumentParser(description=__doc__)
288
- parser.add_argument('--checkpoint', required=True, type=Path,
289
- help='Native adapter_model.safetensors with sibling adapter_config.json, or legacy PT with completion marker')
290
- parser.add_argument('--input', required=True, type=Path)
291
- parser.add_argument('--output-dir', required=True, type=Path)
292
- parser.add_argument('--model-root', type=Path, help='Explicit loading location only; checkpoint model reference retained')
293
- parser.add_argument('--conditions-cache', type=Path, help='Read RGB conditioning only; sibling JSON must bind input/model/resolution')
294
- parser.add_argument('--compare-cache', type=Path, help='Native path plus exact conditioning/latent/decoded comparison against cache')
295
- parser.add_argument('--comparison-atol', type=float, default=0.)
296
- parser.add_argument('--save-conditions', action='store_true')
297
- parser.add_argument('--restore-input-size', action='store_true')
298
- parser.add_argument('--save-predicted-coverage', action='store_true')
299
- parser.add_argument('--device', default='cuda:0')
300
- parser.add_argument('--low-vram', action='store_true', default=os.environ.get('SUPRATEX_LOW_VRAM') == '1',
301
- help='CPU stage/block offload; preserves weights and sampling, uses more CPU RAM and transfers (also SUPRATEX_LOW_VRAM=1)')
302
- args = parser.parse_args()
303
- if args.conditions_cache and args.compare_cache:
304
- parser.error('--conditions-cache and --compare-cache are mutually exclusive')
305
- if args.comparison_atol < 0:
306
- parser.error('--comparison-atol must be nonnegative')
307
- with span('qwen.checkpoint_load'):
308
- cfg, weights, step = load_checkpoint(args.checkpoint)
309
- os.environ['DIFFSYNTH_ATTENTION_IMPLEMENTATION'] = cfg['attention_backend']
310
- os.environ['HF_HUB_OFFLINE'] = '1'; os.environ['TRANSFORMERS_OFFLINE'] = '1'
311
- if cfg['deterministic_algorithms']:
312
- os.environ.setdefault('CUBLAS_WORKSPACE_CONFIG', ':4096:8')
313
- torch.use_deterministic_algorithms(cfg['deterministic_algorithms'])
314
- device = torch.device(args.device)
315
- torch.cuda.set_device(device)
316
- reference_root = cfg['model_root']
317
- model_root = Path(os.path.abspath(args.model_root or reference_root))
318
- # An explicit load-location override is an operator-supplied alias, never an
319
- # implicit identity rewrite or a digest-based equivalence claim.
320
- model_references = {reference_root, str(model_root)}
321
- original, square, transform = prepare_image(args.input, cfg['resolution'])
322
- if args.output_dir.exists():
323
- raise FileExistsError('choose a fresh inference output directory')
324
- args.output_dir.mkdir(parents=True)
325
- started = time.perf_counter()
326
- torch.cuda.reset_peak_memory_stats(device)
327
- with span('qwen.conditions'):
328
- if args.conditions_cache:
329
- verify_cache_input(args.conditions_cache, args.input, cfg['resolution'], model_references)
330
- conditions = load_conditions(args.conditions_cache, cfg['resolution'], device)
331
- from diffsynth.models.qwen_image_21_vae import QwenImage21VAE
332
- vae = explicit_load(QwenImage21VAE, checkpoint_component(model_root, 'vae'),
333
- 'cpu' if args.low_vram else device, torch.bfloat16)
334
- else:
335
- conditions, vae = native_conditions(square, model_root, cfg['resolution'], device, low_vram=args.low_vram)
336
- with span('qwen.transformer_load'):
337
- load_cfg = cfg | {'model_root': str(model_root)}
338
- model = build_model(load_cfg, 'cpu' if args.low_vram else device)
339
- with span('qwen.adapter_load'):
340
- strict_load_trainable(model, weights)
341
- del weights
342
- with span('qwen.predict_decode'):
343
- latent, decoded = predict_and_decode(model, vae, conditions, cfg, low_vram=args.low_vram)
344
- comparison = None
345
- if args.compare_cache:
346
- verify_cache_input(args.compare_cache, args.input, cfg['resolution'], model_references)
347
- cached = load_conditions(args.compare_cache, cfg['resolution'], device)
348
- differences = {key: float((conditions[key].float() - cached[key].float()).abs().max()) for key in conditions}
349
- other_latent, other_decoded = predict_and_decode(model, vae, cached, cfg, low_vram=args.low_vram)
350
- differences.update(latent=float((latent - other_latent).abs().max()), decoded=float((decoded - other_decoded).abs().max()))
351
- comparison = dict(max_abs=differences, atol=args.comparison_atol, passed=all(v <= args.comparison_atol for v in differences.values()))
352
- if not comparison['passed']:
353
- # Discriminate native/cache disagreement from repeated-kernel noise
354
- # while holding conditions, checkpoint and process constant.
355
- repeat_latent, repeat_decoded = predict_and_decode(model, vae, cached, cfg, low_vram=args.low_vram)
356
- comparison['cached_repeat_control_max_abs'] = dict(
357
- latent=float((other_latent-repeat_latent).abs().max()),
358
- decoded=float((other_decoded-repeat_decoded).abs().max()))
359
- (args.output_dir / 'comparison.json').write_text(json.dumps(comparison, indent=2) + '\n')
360
- if not comparison['passed']:
361
- raise ValueError('native/cache comparison exceeded requested tolerance')
362
- with span('qwen.output_write'):
363
- maps, diagnostics = output_maps(decoded, square, original, transform, args.restore_input_size)
364
- for label, image, diag in zip(('albedo', 'mra'), maps, diagnostics):
365
- image.save(args.output_dir / f'{label}.png')
366
- if args.save_predicted_coverage:
367
- diag.save(args.output_dir / f'{label}_predicted_coverage.png')
368
- if args.save_conditions:
369
- save_file({k: v.detach().cpu().contiguous() for k, v in conditions.items()}, str(args.output_dir / 'conditions.safetensors'))
370
- (args.output_dir / 'conditions.json').write_text(json.dumps(dict(input=input_binding(args.input), resolution=cfg['resolution'],
371
- model_root=reference_root, prompt=PROMPT, transform=transform), indent=2) + '\n')
372
- report = dict(status='complete', checkpoint=os.path.abspath(args.checkpoint), checkpoint_step=step,
373
- input=input_binding(args.input), loading_model_root=str(model_root), checkpoint_model_reference=reference_root,
374
- model_override_is_operator_reference_not_hash_verified=bool(args.model_root), config=cfg, transform=transform,
375
- accepted_model_reference_aliases=sorted(model_references),
376
- low_vram=args.low_vram,
377
- memory_policy='cpu_stages_and_sequential_transformer_blocks' if args.low_vram else 'resident_models',
378
- restore_input_size=args.restore_input_size, restoration='crop padding; LANCZOS color interpolation if size changed; exact original coverage' if args.restore_input_size else 'training square canvas',
379
- output_semantics='Albedo sRGB; MRA R=linear Metallic G=linear Roughness B=1 placeholder; A=input coverage',
380
- boundary='Opaque material estimator. Input alpha retained as coverage; no segmentation, material transparency or hidden-layer recovery inferred.',
381
- path='cached_conditioning_only' if args.conditions_cache else 'native_rgb_vae_and_per_image_vl', comparison=comparison,
382
- peak_gpu_allocated_bytes=torch.cuda.max_memory_allocated(device),
383
- peak_gpu_reserved_bytes=torch.cuda.max_memory_reserved(device),
384
- elapsed_seconds=time.perf_counter()-started)
385
- (args.output_dir / 'REPORT.json').write_text(json.dumps(report, indent=2) + '\n')
386
- print(json.dumps(dict(status='complete', output=str(args.output_dir), checkpoint_step=step)))
387
-
388
-
389
- if __name__ == '__main__':
390
- main()
 
1
+ #!/usr/bin/env python3
2
+ """RGB/RGBA-only endpoint inference. No GT, data manifests, downloads or hashes."""
3
+ from __future__ import annotations
4
+ import argparse
5
+ import gc
6
+ import json
7
+ import math
8
+ import os
9
+ from pathlib import Path
10
+ import time
11
+
12
+ import numpy as np
13
+ from PIL import Image
14
+ import torch
15
+ from safetensors import safe_open
16
+ from safetensors.torch import save_file
17
+
18
+ from inference_utils import (
19
+ DEFAULTS, PROMPT, build_model, checkpoint_component, decode_pair,
20
+ endpoint_noise, explicit_load,
21
+ )
22
+ from inference_offload import (
23
+ release_device_cache, sequential_cpu_offload, stage_on_device,
24
+ text_encoder_offload_blocks,
25
+ )
26
+
27
+
28
+ def prepare_image(path, resolution):
29
+ if resolution < 32 or resolution % 32:
30
+ raise ValueError('resolution must be positive and divisible by 32')
31
+ with Image.open(path) as source:
32
+ had_alpha = 'A' in source.getbands() or 'transparency' in source.info
33
+ original = source.convert('RGBA')
34
+ width, height = original.size
35
+ scale = min(1., resolution / max(width, height))
36
+ content = (max(1, round(width * scale)), max(1, round(height * scale)))
37
+ aligned = tuple(math.ceil(value / 32) * 32 for value in content)
38
+ offset = tuple((aligned[i] - content[i]) // 2 + (resolution - aligned[i]) // 2 for i in range(2))
39
+ resized = original.resize(content, Image.Resampling.LANCZOS) if original.size != content else original.copy()
40
+ square = Image.new('RGBA', (resolution, resolution), (0, 0, 0, 0))
41
+ square.paste(resized, offset)
42
+ transform = dict(source_size=[width, height], content_size=list(content), padded_size=[resolution, resolution],
43
+ offset=list(offset), scale=scale, input_had_alpha=had_alpha,
44
+ resampling='LANCZOS, two-stage centered padding, no crop or alpha compositing',
45
+ coverage='input alpha retained' if had_alpha else 'all-one source coverage; no segmentation inferred')
46
+ return original, square, transform
47
+
48
+
49
+ def load_checkpoint(path):
50
+ path = Path(path)
51
+ if path.suffix == '.safetensors':
52
+ from safetensors import safe_open
53
+ import math
54
+
55
+ adapter = json.loads(path.with_name('adapter_config.json').read_text())
56
+ if (not isinstance(adapter, dict) or adapter.get('format') != 'supratex_qwen_material_adapter'
57
+ or type(adapter.get('schema_version')) is not int or adapter['schema_version'] != 1
58
+ or adapter.get('adapter_type') != 'qwen_image_21_pbr_endpoint'
59
+ or adapter.get('base_model_name_or_path') != 'Qwen/Qwen-Image-2.1'
60
+ or adapter.get('weight_file') != path.name
61
+ or type(adapter.get('source_step')) is not int or adapter['source_step'] != 100000):
62
+ raise ValueError('unsupported Qwen material adapter config')
63
+ inference = adapter.get('inference_config')
64
+ booleans = {'lora_output_projection', 'omit_all_valid_mask', 'bf16_autocast', 'deterministic_algorithms'}
65
+ required = booleans | {'resolution', 'noise_seed', 'lora_rank', 'lora_alpha', 'attention_backend'}
66
+ if not isinstance(inference, dict) or set(inference) != required:
67
+ raise ValueError('Qwen adapter requires complete inference config')
68
+ if (type(inference['resolution']) is not int or inference['resolution'] != 1024
69
+ or type(inference['noise_seed']) is not int or inference['noise_seed'] != 42
70
+ or inference['attention_backend'] != 'torch'
71
+ or type(inference['lora_rank']) is not int or inference['lora_rank'] <= 0
72
+ or type(inference['lora_alpha']) not in {int, float}
73
+ or not math.isfinite(inference['lora_alpha']) or inference['lora_alpha'] <= 0
74
+ or any(type(inference[key]) is not bool for key in booleans)):
75
+ raise ValueError('invalid Qwen adapter inference config')
76
+ tensors = adapter.get('tensors')
77
+ if not isinstance(tensors, dict) or not tensors:
78
+ raise ValueError('Qwen adapter requires a nonempty tensor manifest')
79
+ with safe_open(str(path), framework='pt', device='cpu') as reader:
80
+ metadata = reader.metadata() or {}
81
+ if (metadata.get('format') != 'pt' or metadata.get('adapter_format') != adapter['format']
82
+ or metadata.get('schema_version') != str(adapter['schema_version'])
83
+ or set(reader.keys()) != set(tensors)):
84
+ raise ValueError('Qwen adapter tensor keys or metadata mismatch')
85
+ for name, specification in tensors.items():
86
+ header = reader.get_slice(name)
87
+ if (not isinstance(specification, dict)
88
+ or not isinstance(specification.get('shape'), list)
89
+ or any(type(size) is not int or size < 0 for size in specification['shape'])
90
+ or specification['shape'] != header.get_shape()
91
+ or specification.get('dtype') not in {'F64', 'F32', 'F16', 'BF16'}
92
+ or specification['dtype'] != header.get_dtype()):
93
+ raise ValueError(f'Qwen adapter tensor shape or dtype mismatch: {name}')
94
+ weights = {name: reader.get_tensor(name) for name in reader.keys()}
95
+ config = DEFAULTS | inference | {'model_root': adapter['base_model_name_or_path']}
96
+ return config, weights, adapter['source_step']
97
+ marker = json.loads(path.with_suffix('.complete.json').read_text())
98
+ if marker.get('status') != 'complete' or marker.get('bytes') != path.stat().st_size:
99
+ raise ValueError('checkpoint completion record is absent or mismatched')
100
+ # SupraTex adaptation: public inference weights use restricted loading.
101
+ # Legacy trainer checkpoints also contain NumPy uint32 RNG arrays; these
102
+ # exact reconstruction types retain compatibility without arbitrary pickle.
103
+ from numpy.core.multiarray import _reconstruct
104
+ with torch.serialization.safe_globals([_reconstruct, (_reconstruct, 'numpy.core.multiarray._reconstruct'),
105
+ (_reconstruct, 'numpy._core.multiarray._reconstruct'),
106
+ np.ndarray, np.dtype, type(np.dtype('uint32'))]):
107
+ state = torch.load(path, map_location='cpu', weights_only=True)
108
+ if state.get('format') != 'qwen_pbr_endpoint_v1' or marker.get('step') != state.get('step'):
109
+ raise ValueError('unsupported checkpoint or inconsistent step')
110
+ if not isinstance(state.get('config'), dict) or not isinstance(state.get('model'), dict):
111
+ raise ValueError('checkpoint needs complete config and trainable state')
112
+ config = DEFAULTS | state['config']
113
+ for key in ('model_root', 'resolution', 'lora_rank', 'lora_alpha', 'noise_seed'):
114
+ if key not in state['config']:
115
+ raise ValueError(f'checkpoint config lacks {key}')
116
+ return config, state['model'], state['step']
117
+
118
+
119
+ def strict_load_adapter(model, weights):
120
+ expected = {name: parameter for name, parameter in model.named_parameters() if parameter.requires_grad}
121
+ if expected.keys() != weights.keys():
122
+ raise ValueError('checkpoint adapter key set differs from constructed model')
123
+ with torch.no_grad():
124
+ for name, parameter in expected.items():
125
+ tensor = weights[name]
126
+ if tensor.shape != parameter.shape or tensor.dtype != parameter.dtype or not torch.isfinite(tensor).all():
127
+ raise ValueError(f'checkpoint tensor mismatch/nonfinite: {name}')
128
+ parameter.copy_(tensor)
129
+ model.requires_grad_(False).eval()
130
+
131
+
132
+ def canonical_conditions(values, resolution):
133
+ """Only retain consumed conditioning; material targets are never loaded."""
134
+ values = dict(values)
135
+ layout = values['edit_image_pad_mask'].bool()
136
+ prompt = values.get('prompt_embeds')
137
+ if prompt is None:
138
+ text = values['prompt_text_embeds']
139
+ if layout.ndim != 2 or layout.shape[0] != 1 or text.shape[1] != int((~layout[0]).sum()):
140
+ raise ValueError('compact conditioning layout mismatch')
141
+ prompt = text.new_zeros((1, layout.shape[1], text.shape[-1]))
142
+ prompt[:, ~layout[0]] = text
143
+ if prompt.ndim != 3 or prompt.shape[0] != 1 or prompt.shape[2] != 4096 or layout.shape != prompt.shape[:2]:
144
+ raise ValueError('invalid prompt/layout shape')
145
+ # Model replaces image-placeholder rows from RGB VAE. Canonical zeros allow
146
+ # exact native-v2 comparison without changing consumed values.
147
+ prompt = prompt.clone()
148
+ prompt[:, layout[0]] = 0
149
+ mask = values.get('prompt_embeds_mask')
150
+ mask = torch.ones_like(layout) if mask is None else mask.bool()
151
+ rgb = values['rgb']
152
+ if rgb.shape != (1, 64, resolution // 16, resolution // 16) or mask.shape != layout.shape:
153
+ raise ValueError('invalid RGB latent/mask shape')
154
+ result = dict(rgb=rgb, prompt_embeds=prompt, prompt_embeds_mask=mask, edit_image_pad_mask=layout)
155
+ if any(value.is_floating_point() and not torch.isfinite(value).all() for value in result.values()):
156
+ raise ValueError('nonfinite conditioning')
157
+ return result
158
+
159
+
160
+ def load_conditions(path, resolution, device):
161
+ values = {}
162
+ keys = {'rgb', 'prompt_embeds', 'prompt_text_embeds', 'prompt_embeds_mask', 'edit_image_pad_mask'}
163
+ with safe_open(str(path), framework='pt', device=str(device)) as reader:
164
+ for key in keys & set(reader.keys()):
165
+ values[key] = reader.get_tensor(key)
166
+ return canonical_conditions(values, resolution)
167
+
168
+
169
+ def input_binding(path):
170
+ path = Path(os.path.abspath(path)); stat = path.stat()
171
+ return dict(path=str(path), size=stat.st_size, mtime_ns=stat.st_mtime_ns)
172
+
173
+
174
+ def verify_cache_input(cache, image, resolution, model_references):
175
+ metadata = json.loads(Path(cache).with_suffix('.json').read_text())
176
+ binding = metadata.get('input', metadata.get('sources', {}).get('rgb'))
177
+ if binding != input_binding(image) or metadata.get('resolution') != resolution:
178
+ raise ValueError('cache conditioning is not bound to this exact input/stat/resolution')
179
+ if metadata.get('model_root') not in model_references or metadata.get('prompt') != PROMPT:
180
+ raise ValueError('cached model reference or prompt differs from checkpoint inference contract')
181
+ return metadata['model_root']
182
+
183
+
184
+ def native_conditions(square, model_root, resolution, device, low_vram=False):
185
+ from transformers import AutoProcessor
186
+ from diffsynth.models.qwen_image_21_vae import QwenImage21VAE
187
+ from diffsynth.models.qwen_image_21_text_encoder import QwenImage21TextEncoder
188
+ from diffsynth.pipelines.qwen_image_21 import QwenImage21Pipeline, QwenImage21Unit_EditImageEmbedder, QwenImage21Unit_PromptEmbedder
189
+ from diffsynth.utils.state_dict_converters.qwen_image_21_text_encoder import QwenImage21TextEncoderStateDictConverter
190
+ processor = AutoProcessor.from_pretrained(str(model_root / 'processor'), local_files_only=True)
191
+ processor.image_processor.size = {'shortest_edge': resolution ** 2, 'longest_edge': resolution ** 2}
192
+ pipe = QwenImage21Pipeline(device=device, torch_dtype=torch.bfloat16)
193
+ pipe.processor = processor
194
+ loading_device = 'cpu' if low_vram else device
195
+ vae = explicit_load(QwenImage21VAE, checkpoint_component(model_root, 'vae'), loading_device, torch.bfloat16)
196
+ pipe.vae = vae
197
+ with stage_on_device(vae, device, enabled=low_vram), torch.inference_mode():
198
+ edited = QwenImage21Unit_EditImageEmbedder().process(pipe, edit_image=[square], height=resolution, width=resolution,
199
+ tiled=False, tile_size=256, tile_stride=192)
200
+ if edited['edit_image'][0].size != square.size:
201
+ raise ValueError('native edit path changed dimensions')
202
+ rgb = edited['edit_latents'][0]
203
+ encoder = explicit_load(QwenImage21TextEncoder, checkpoint_component(model_root, 'text_encoder'), loading_device,
204
+ torch.bfloat16, QwenImage21TextEncoderStateDictConverter)
205
+ pipe.text_encoder = encoder
206
+ blocks = text_encoder_offload_blocks(encoder) if low_vram else ()
207
+ with sequential_cpu_offload(encoder, blocks, device, enabled=low_vram), torch.inference_mode():
208
+ values = QwenImage21Unit_PromptEmbedder().process(pipe, prompt=PROMPT, edit_image=[square])
209
+ conditions = canonical_conditions(dict(values, rgb=rgb), resolution)
210
+ pipe.text_encoder = None
211
+ del encoder, pipe, values, edited, blocks
212
+ gc.collect(); torch.cuda.empty_cache()
213
+ return conditions, vae
214
+
215
+
216
+ def predict(model, conditions, cfg):
217
+ # Shape comes from observed RGB, never a GT or fabricated target tensor.
218
+ shape = (conditions['rgb'].shape[0], 2, *conditions['rgb'].shape[1:])
219
+ shape_carrier = conditions['rgb'].new_empty(shape)
220
+ noise = endpoint_noise(shape_carrier, cfg['noise_seed'])
221
+ mask = conditions['prompt_embeds_mask']
222
+ if cfg['omit_all_valid_mask'] and bool(mask.all()):
223
+ mask = None
224
+ with torch.inference_mode(), torch.autocast('cuda', dtype=torch.bfloat16, enabled=cfg['bf16_autocast']):
225
+ velocity = model(latents=noise, timestep=torch.full((noise.shape[0],), 1000., device=noise.device),
226
+ prompt_embeds=conditions['prompt_embeds'], prompt_embeds_mask=mask,
227
+ edit_image_pad_mask=conditions['edit_image_pad_mask'], edit_latents=[conditions['rgb']],
228
+ use_gradient_checkpointing=False, use_flex_attention=False, kv_cache=None)
229
+ prediction = noise.float() - velocity.float()
230
+ if not torch.isfinite(prediction).all():
231
+ raise FloatingPointError('nonfinite material latent prediction')
232
+ return prediction
233
+
234
+
235
+ def predict_and_decode(model, vae, conditions, cfg, low_vram=False):
236
+ device = conditions['rgb'].device
237
+ try:
238
+ with sequential_cpu_offload(model, model.dit.transformer_blocks, device, enabled=low_vram):
239
+ latent = predict(model, conditions, cfg)
240
+ finally:
241
+ if low_vram:
242
+ # Qwen stores RoPE tables as an ordinary list, outside Module buffers.
243
+ model.dit.pos_embed.freqs = [value.cpu() for value in model.dit.pos_embed.freqs]
244
+ release_device_cache(device)
245
+ with stage_on_device(vae, device, enabled=low_vram), torch.inference_mode():
246
+ decoded = decode_pair(vae, latent)
247
+ return latent, decoded
248
+
249
+
250
+ def output_maps(decoded, square, original, transform, restore_size):
251
+ if decoded.shape[0:3] != (1, 2, 4) or not torch.isfinite(decoded).all():
252
+ raise ValueError('invalid/nonfinite decoded material pair')
253
+ arrays = np.clip(np.rint(decoded[0].detach().float().cpu().permute(0, 2, 3, 1).numpy() * 255), 0, 255).astype(np.uint8)
254
+ diagnostics = [Image.fromarray(array[:, :, 3], 'L') for array in arrays]
255
+ maps = []
256
+ for modality, array in enumerate(arrays):
257
+ color = Image.fromarray(array[:, :, :3], 'RGB')
258
+ coverage = square.getchannel('A')
259
+ if restore_size:
260
+ x, y = transform['offset']; w, h = transform['content_size']
261
+ color = color.crop((x, y, x + w, y + h))
262
+ if color.size != original.size:
263
+ color = color.resize(original.size, Image.Resampling.LANCZOS)
264
+ coverage = original.getchannel('A') # exact original coverage, no second interpolation
265
+ out = np.array(color.convert('RGBA'))
266
+ out[:, :, 3] = np.array(coverage)
267
+ if modality == 1:
268
+ out[:, :, 2] = 255 # placeholder, not material opacity or AO prediction
269
+ maps.append(Image.fromarray(out, 'RGBA'))
270
+ return maps, diagnostics
271
+
272
+
273
+ def main():
274
+ parser = argparse.ArgumentParser(description=__doc__)
275
+ parser.add_argument('--checkpoint', required=True, type=Path,
276
+ help='Native adapter_model.safetensors with sibling adapter_config.json, or legacy PT with completion marker')
277
+ parser.add_argument('--input', required=True, type=Path)
278
+ parser.add_argument('--output-dir', required=True, type=Path)
279
+ parser.add_argument('--model-root', type=Path, help='Explicit loading location only; checkpoint model reference retained')
280
+ parser.add_argument('--conditions-cache', type=Path, help='Read RGB conditioning only; sibling JSON must bind input/model/resolution')
281
+ parser.add_argument('--compare-cache', type=Path, help='Native path plus exact conditioning/latent/decoded comparison against cache')
282
+ parser.add_argument('--comparison-atol', type=float, default=0.)
283
+ parser.add_argument('--save-conditions', action='store_true')
284
+ parser.add_argument('--restore-input-size', action='store_true')
285
+ parser.add_argument('--save-predicted-coverage', action='store_true')
286
+ parser.add_argument('--device', default='cuda:0')
287
+ parser.add_argument('--low-vram', action='store_true', default=os.environ.get('SUPRATEX_LOW_VRAM') == '1',
288
+ help='CPU stage/block offload; preserves weights and sampling, uses more CPU RAM and transfers (also SUPRATEX_LOW_VRAM=1)')
289
+ args = parser.parse_args()
290
+ if args.conditions_cache and args.compare_cache:
291
+ parser.error('--conditions-cache and --compare-cache are mutually exclusive')
292
+ if args.comparison_atol < 0:
293
+ parser.error('--comparison-atol must be nonnegative')
294
+ cfg, weights, step = load_checkpoint(args.checkpoint)
295
+ os.environ['DIFFSYNTH_ATTENTION_IMPLEMENTATION'] = cfg['attention_backend']
296
+ os.environ['HF_HUB_OFFLINE'] = '1'; os.environ['TRANSFORMERS_OFFLINE'] = '1'
297
+ if cfg['deterministic_algorithms']:
298
+ os.environ.setdefault('CUBLAS_WORKSPACE_CONFIG', ':4096:8')
299
+ torch.use_deterministic_algorithms(cfg['deterministic_algorithms'])
300
+ device = torch.device(args.device)
301
+ torch.cuda.set_device(device)
302
+ reference_root = cfg['model_root']
303
+ model_root = Path(os.path.abspath(args.model_root or reference_root))
304
+ # An explicit load-location override is an operator-supplied alias, never an
305
+ # implicit identity rewrite or a digest-based equivalence claim.
306
+ model_references = {reference_root, str(model_root)}
307
+ original, square, transform = prepare_image(args.input, cfg['resolution'])
308
+ if args.output_dir.exists():
309
+ raise FileExistsError('choose a fresh inference output directory')
310
+ args.output_dir.mkdir(parents=True)
311
+ started = time.perf_counter()
312
+ torch.cuda.reset_peak_memory_stats(device)
313
+ if args.conditions_cache:
314
+ verify_cache_input(args.conditions_cache, args.input, cfg['resolution'], model_references)
315
+ conditions = load_conditions(args.conditions_cache, cfg['resolution'], device)
316
+ from diffsynth.models.qwen_image_21_vae import QwenImage21VAE
317
+ vae = explicit_load(QwenImage21VAE, checkpoint_component(model_root, 'vae'),
318
+ 'cpu' if args.low_vram else device, torch.bfloat16)
319
+ else:
320
+ conditions, vae = native_conditions(square, model_root, cfg['resolution'], device, low_vram=args.low_vram)
321
+ load_cfg = cfg | {'model_root': str(model_root)}
322
+ model = build_model(load_cfg, 'cpu' if args.low_vram else device)
323
+ strict_load_adapter(model, weights)
324
+ del weights
325
+ latent, decoded = predict_and_decode(model, vae, conditions, cfg, low_vram=args.low_vram)
326
+ comparison = None
327
+ if args.compare_cache:
328
+ verify_cache_input(args.compare_cache, args.input, cfg['resolution'], model_references)
329
+ cached = load_conditions(args.compare_cache, cfg['resolution'], device)
330
+ differences = {key: float((conditions[key].float() - cached[key].float()).abs().max()) for key in conditions}
331
+ other_latent, other_decoded = predict_and_decode(model, vae, cached, cfg, low_vram=args.low_vram)
332
+ differences.update(latent=float((latent - other_latent).abs().max()), decoded=float((decoded - other_decoded).abs().max()))
333
+ comparison = dict(max_abs=differences, atol=args.comparison_atol, passed=all(v <= args.comparison_atol for v in differences.values()))
334
+ if not comparison['passed']:
335
+ # Discriminate native/cache disagreement from repeated-kernel noise
336
+ # while holding conditions, checkpoint and process constant.
337
+ repeat_latent, repeat_decoded = predict_and_decode(model, vae, cached, cfg, low_vram=args.low_vram)
338
+ comparison['cached_repeat_control_max_abs'] = dict(
339
+ latent=float((other_latent-repeat_latent).abs().max()),
340
+ decoded=float((other_decoded-repeat_decoded).abs().max()))
341
+ (args.output_dir / 'comparison.json').write_text(json.dumps(comparison, indent=2) + '\n')
342
+ if not comparison['passed']:
343
+ raise ValueError('native/cache comparison exceeded requested tolerance')
344
+ maps, diagnostics = output_maps(decoded, square, original, transform, args.restore_input_size)
345
+ for label, image, diag in zip(('albedo', 'mra'), maps, diagnostics):
346
+ image.save(args.output_dir / f'{label}.png')
347
+ if args.save_predicted_coverage:
348
+ diag.save(args.output_dir / f'{label}_predicted_coverage.png')
349
+ if args.save_conditions:
350
+ save_file({k: v.detach().cpu().contiguous() for k, v in conditions.items()}, str(args.output_dir / 'conditions.safetensors'))
351
+ (args.output_dir / 'conditions.json').write_text(json.dumps(dict(input=input_binding(args.input), resolution=cfg['resolution'],
352
+ model_root=reference_root, prompt=PROMPT, transform=transform), indent=2) + '\n')
353
+ report = dict(status='complete', checkpoint=os.path.abspath(args.checkpoint), checkpoint_step=step,
354
+ input=input_binding(args.input), loading_model_root=str(model_root), checkpoint_model_reference=reference_root,
355
+ model_override_is_operator_reference_not_hash_verified=bool(args.model_root), config=cfg, transform=transform,
356
+ accepted_model_reference_aliases=sorted(model_references),
357
+ low_vram=args.low_vram,
358
+ memory_policy='cpu_stages_and_sequential_transformer_blocks' if args.low_vram else 'resident_models',
359
+ restore_input_size=args.restore_input_size, restoration='crop padding; LANCZOS color interpolation if size changed; exact original coverage' if args.restore_input_size else 'training square canvas',
360
+ output_semantics='Albedo sRGB; MRA R=linear Metallic G=linear Roughness B=1 placeholder; A=input coverage',
361
+ boundary='Opaque material estimator. Input alpha retained as coverage; no segmentation, material transparency or hidden-layer recovery inferred.',
362
+ path='cached_conditioning_only' if args.conditions_cache else 'native_rgb_vae_and_per_image_vl', comparison=comparison,
363
+ peak_gpu_allocated_bytes=torch.cuda.max_memory_allocated(device),
364
+ peak_gpu_reserved_bytes=torch.cuda.max_memory_reserved(device),
365
+ elapsed_seconds=time.perf_counter()-started)
366
+ (args.output_dir / 'REPORT.json').write_text(json.dumps(report, indent=2) + '\n')
367
+ print(json.dumps(dict(status='complete', output=str(args.output_dir), checkpoint_step=step)))
368
+
369
+
370
+ if __name__ == '__main__':
371
+ main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
deployment/runtime/qwen/examples/qwen_image_21/pbr/inference_utils.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model construction, local checkpoint loading, and decoding for PBR inference."""
2
+ from __future__ import annotations
3
+
4
+ import inspect
5
+ import json
6
+ from pathlib import Path
7
+ import time
8
+
9
+ import torch
10
+ from safetensors import safe_open
11
+ from safetensors.torch import load_file
12
+
13
+
14
+ # Only settings consumed by inference; legacy checkpoint fields are not defaults.
15
+ DEFAULTS = dict(
16
+ lora_rank=32, lora_alpha=32, noise_seed=42, resolution=512,
17
+ omit_all_valid_mask=False, bf16_autocast=False,
18
+ attention_backend='torch', deterministic_algorithms=False,
19
+ lora_output_projection=False,
20
+ )
21
+
22
+
23
+ PROMPT = ("Generate aligned albedo and metallic/roughness material maps for the object in the input image. "
24
+ "Pack MRA with red=metallic, green=roughness, and blue=constant one placeholder. "
25
+ "No observed ambient occlusion is available.")
26
+
27
+
28
+ def log(message):
29
+ print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}", flush=True)
30
+
31
+
32
+ def read_json(path):
33
+ if not path.is_file():
34
+ raise FileNotFoundError(f"Required local file is absent: {path}")
35
+ return json.loads(path.read_text(encoding="utf-8"))
36
+
37
+
38
+ def checkpoint_component(root, component):
39
+ """Resolve an explicit component and verify every indexed shard before GPU work."""
40
+ directory = root / component
41
+ config = read_json(directory / "config.json")
42
+ if not isinstance(config, dict):
43
+ raise ValueError(f"Expected an object config: {directory / 'config.json'}")
44
+ indexes = sorted(directory.glob("*.safetensors.index.json"))
45
+ if len(indexes) > 1:
46
+ raise ValueError(f"Ambiguous safetensors indexes under {directory}")
47
+ if indexes:
48
+ index = read_json(indexes[0])
49
+ weight_map = index.get("weight_map")
50
+ if not isinstance(weight_map, dict) or not weight_map:
51
+ raise ValueError(f"Invalid weight_map: {indexes[0]}")
52
+ names = sorted(set(weight_map.values()))
53
+ if any(Path(name).is_absolute() or ".." in Path(name).parts for name in names):
54
+ raise ValueError(f"Indexed shard path leaves component directory: {indexes[0]}")
55
+ files = [directory / name for name in names]
56
+ else:
57
+ pattern = "model*.safetensors" if component == "text_encoder" else "diffusion_pytorch_model*.safetensors"
58
+ files = sorted(directory.glob(pattern))
59
+ if not files:
60
+ raise FileNotFoundError(f"No local safetensors checkpoint found under {directory}")
61
+ for filename in files:
62
+ if not filename.is_file() or filename.stat().st_size == 0:
63
+ raise FileNotFoundError(f"Missing or empty checkpoint shard: {filename}")
64
+ keys = set()
65
+ for filename in files:
66
+ with safe_open(str(filename), framework="pt", device="cpu") as handle:
67
+ shard_keys = set(handle.keys())
68
+ duplicates = keys & shard_keys
69
+ if duplicates:
70
+ raise ValueError(f"Duplicate state keys between shards: {sorted(duplicates)[:5]}")
71
+ keys.update(shard_keys)
72
+ if indexes and keys != set(weight_map):
73
+ raise ValueError(f"Index and shard state keys disagree under {directory}")
74
+ return {"directory": directory, "config": config, "files": files, "tensor_count": len(keys)}
75
+
76
+
77
+ def explicit_load(model_class, component, device, dtype, converter=None):
78
+ """Instantiate the named class and strictly assign the local tensor state."""
79
+ from diffsynth.core.vram.initialization import skip_model_initialization
80
+
81
+ constructor = inspect.signature(model_class.__init__).parameters
82
+ config = {key: value for key, value in component["config"].items() if key in constructor and key != "self"}
83
+ log(f"Loading {model_class.__name__} explicitly ({len(component['files'])} shards, device={device}, dtype={dtype})")
84
+ started = time.perf_counter()
85
+ # Set the default dtype to preserve generated float32 text-encoder rotary buffers;
86
+ # checkpoint parameters are assigned below and then cast explicitly.
87
+ with skip_model_initialization():
88
+ model = model_class(**config)
89
+ state = {}
90
+ for filename in component["files"]:
91
+ state.update(load_file(str(filename), device="cpu"))
92
+ if converter is not None:
93
+ state = converter(state)
94
+ model.load_state_dict(state, strict=True, assign=True)
95
+ del state
96
+ model = model.to(device=device, dtype=dtype).eval()
97
+ if any(parameter.is_meta for parameter in model.parameters()):
98
+ raise RuntimeError(f"{model_class.__name__}: parameters remain on meta after strict checkpoint load")
99
+ model.requires_grad_(False)
100
+ log(f"Loaded {model_class.__name__} in {time.perf_counter() - started:.2f}s")
101
+ return model
102
+
103
+
104
+ def endpoint_noise(target, seed):
105
+ # Draw the fixed endpoint field on CPU before moving it to the model device.
106
+ noise = torch.randn(target.shape, generator=torch.Generator(device='cpu').manual_seed(seed), dtype=torch.float32)
107
+ return noise.to(device=target.device, dtype=target.dtype)
108
+
109
+
110
+ def build_model(cfg, device):
111
+ from peft import LoraConfig, inject_adapter_in_model
112
+ from diffsynth.models.qwen_image_21_dit import QwenImage21DiT
113
+ from diffsynth.pipelines.qwen_image_21_pbr import QwenImage21PBRModel
114
+ dit = explicit_load(QwenImage21DiT, checkpoint_component(Path(cfg['model_root']), 'transformer'), device, torch.bfloat16)
115
+ if len(dit.transformer_blocks) != 32:
116
+ raise ValueError('Expected complete pretrained 32-layer backbone')
117
+ lora = LoraConfig(r=cfg['lora_rank'], lora_alpha=cfg['lora_alpha'], lora_dropout=0., bias='none',
118
+ target_modules=['attn.to_q', 'attn.to_k', 'attn.to_v', 'attn.to_out.0']
119
+ + (['proj_out'] if cfg['lora_output_projection'] else []))
120
+ dit = inject_adapter_in_model(lora, dit)
121
+ model = QwenImage21PBRModel(dit).to(device)
122
+ for parameter in model.parameters():
123
+ if parameter.requires_grad:
124
+ parameter.data = parameter.data.float()
125
+ return model
126
+
127
+
128
+ def decode_pair(vae, latents, clamp_output=True):
129
+ """Decode the albedo and MRA latent maps in their fixed modality order."""
130
+ decoded = []
131
+ for index in range(2):
132
+ z = latents[:, index].to(dtype=torch.bfloat16)
133
+ value = vae.decode(z, tiled=False, clamp_output=clamp_output)
134
+ decoded.append((value.float() + 1.) / 2.)
135
+ return torch.stack(decoded, dim=1)
deployment/runtime/qwen/requirements.txt CHANGED
@@ -1,7 +1,6 @@
1
- transformers==4.57.3
2
- diffusers==0.37.1
3
- accelerate==1.13.0
4
- peft==0.19.1
5
- ftfy==6.3.1
6
- pandas==2.3.3
7
- modelscope==1.34.0
 
1
+ transformers==4.57.3
2
+ diffusers==0.37.1
3
+ accelerate==1.13.0
4
+ peft==0.19.1
5
+ ftfy==6.3.1
6
+ modelscope==1.34.0
 
deployment/runtime/requirements-python.txt CHANGED
@@ -1,31 +1,29 @@
1
- # Recorded from the working Python 3.10 / PyTorch 2.6.0 CUDA 12.4 environment.
2
- # Install torch/torchvision and compiled extensions separately; see DEPENDENCIES.md.
3
- numpy==2.2.6
4
- Pillow==12.0.0
5
- scipy==1.15.3
6
- easydict==1.13
7
- tqdm==4.67.1
8
- einops==0.8.2
9
- safetensors==0.7.0
10
- huggingface-hub==0.36.2
11
- trimesh==4.10.1
12
- opencv-python-headless==4.13.0.92
13
- imageio==2.37.2
14
- imageio-ffmpeg==0.6.0
15
- numba==0.65.1
16
- transformers==4.57.3
17
- diffusers==0.37.1
18
- accelerate==1.13.0
19
- requests==2.34.2
20
- timm==1.0.22
21
- kornia==0.8.2
22
- plyfile==1.1.3
23
- zstandard==0.25.0
24
- ninja==1.13.0
25
- pandas==2.3.3
26
- peft==0.19.1
27
- ftfy==6.3.1
28
- lpips==0.1.4
29
- tensorboard==2.20.0
30
- av==17.1.0
31
- modelscope==1.34.0
 
1
+ # Recorded from the working Python 3.10 / PyTorch 2.6.0 CUDA 12.4 environment.
2
+ # Install torch/torchvision and compiled extensions separately; see DEPENDENCIES.md.
3
+ numpy==2.2.6
4
+ Pillow==12.0.0
5
+ scipy==1.15.3
6
+ easydict==1.13
7
+ tqdm==4.67.1
8
+ einops==0.8.2
9
+ safetensors==0.7.0
10
+ huggingface-hub==0.36.2
11
+ trimesh==4.10.1
12
+ opencv-python-headless==4.13.0.92
13
+ imageio==2.37.2
14
+ imageio-ffmpeg==0.6.0
15
+ numba==0.65.1
16
+ transformers==4.57.3
17
+ diffusers==0.37.1
18
+ accelerate==1.13.0
19
+ requests==2.34.2
20
+ timm==1.0.22
21
+ kornia==0.8.2
22
+ plyfile==1.1.3
23
+ zstandard==0.25.0
24
+ ninja==1.13.0
25
+ pandas==2.3.3
26
+ peft==0.19.1
27
+ ftfy==6.3.1
28
+ av==17.1.0
29
+ modelscope==1.34.0
 
 
hf_space/ovoxel_results.py CHANGED
@@ -1,117 +1,117 @@
1
- """Persist final o-voxels and their rendered views in the mounted data Bucket."""
2
- import json
3
- from pathlib import Path
4
- import re
5
- import shutil
6
- from html import escape
7
- from urllib.parse import quote
8
-
9
- PREVIEW_MODES = (
10
- ('normal', 'Normal', 'normal.png'),
11
- ('clay', 'Clay', 'clay.png'),
12
- ('base_color', 'Base color', 'basecolor.png'),
13
- ('shaded_studio', 'HDRI Studio', 'hdri_studio.png'),
14
- ('shaded_forest', 'HDRI Forest', 'hdri_forest.png'),
15
- ('shaded_sunset', 'HDRI Sunset', 'hdri_sunset.png'),
16
- ('shaded_courtyard', 'HDRI Courtyard', 'hdri_courtyard.png'),
17
- ('shaded', 'Studio', 'hdri_studio.png'), # Previously saved previews.
18
- )
19
-
20
- ROOT = Path('/data/generated/ovoxel-results-v1')
21
-
22
-
23
- def directory(identifier):
24
- if not isinstance(identifier, str) or re.fullmatch(r'[0-9a-f]{32}', identifier) is None:
25
- raise ValueError('Invalid generated asset identifier')
26
- return ROOT/identifier
27
-
28
-
29
- def persist(source, report):
30
- source = Path(source)
31
- identifier = source.parent.name
32
- destination = directory(identifier)
33
- destination.mkdir(parents=True, exist_ok=True)
34
- for name in ('asset.npz','input.png','report.json','preview.json','timings.jsonl'):
35
- shutil.copyfile(source.parent/name, destination/name)
36
- shutil.copytree(source.parent/'views', destination/'views', dirs_exist_ok=True)
37
- (destination/'ready.json').write_text(json.dumps({'complete':True,'identifier':identifier,
38
- 'project_revision':report['project_revision'],'output_kind':'final_ovoxel'}))
39
- return identifier
40
-
41
-
42
- def preview_html(identifier):
43
- root = directory(identifier)
44
- metadata = json.loads((root/'preview.json').read_text())
45
- default = metadata.get('default_mode', 'shaded' if 'shaded' in metadata['modes'] else 'shaded_forest')
46
- if default not in metadata['modes']:
47
- default = metadata['modes'][0]
48
- images = []
49
- for mode in metadata['modes']:
50
- for view in range(metadata['views']):
51
- url = '/gradio_api/file='+quote(str(root/'views'/f'{mode}-{view:02d}.webp'),safe='/')
52
- hidden = '' if mode == default and view == 0 else ' hidden'
53
- images.append(f'<img src="{url}" data-mode="{mode}" data-view="{view}" alt="{mode.replace("_"," ")} view {view+1}"{hidden}>')
54
- buttons = []
55
- for mode, label, icon in PREVIEW_MODES:
56
- if mode not in metadata['modes']:
57
- continue
58
- # Inline the tiny swatches; preview frames remain individually cached files.
59
- import base64
60
- icon_path = Path(__file__).resolve().parents[1]/'assets/preview-lighting'/icon
61
- swatch = base64.b64encode(icon_path.read_bytes()).decode('ascii')
62
- active = mode == default
63
- buttons.append(f'<button type="button" data-mode="{mode}" class="{"active" if active else ""}" '
64
- f'title="{label}" aria-label="{label}" aria-pressed="{str(active).lower()}">'
65
- f'<img src="data:image/png;base64,{swatch}" alt=""><span class="st-mode-name">{label}</span></button>')
66
- return (f'<div class="st-ovoxel-preview" data-mode="{escape(default, quote=True)}" data-view="0">'
67
- '<div class="st-ovoxel-frames">'+''.join(images)+'</div>'
68
- '<div class="st-ovoxel-modes" role="group" aria-label="Render mode">'+''.join(buttons)+'</div>'
69
- f'<label class="st-ovoxel-angle">View angle <input type="range" min="0" max="{metadata["views"]-1}" value="0" step="1" aria-label="View angle"></label></div>')
70
-
71
-
72
- def extract(identifier, extraction_token=None):
73
- from hf_space import extraction_progress as progress
74
- from hf_space import generation_progress
75
- from hf_space.timing import observe_stages
76
- token = extraction_token if generation_progress.valid(extraction_token) else progress.start()
77
- try:
78
- progress.stage(token, 'load')
79
- with observe_stages(lambda name: progress.stage(token, name)):
80
- output = _extract(identifier, token)
81
- progress.finish(token)
82
- return output
83
- except Exception:
84
- progress.finish(token, failed=True)
85
- raise
86
-
87
-
88
- def _extract(identifier, token):
89
- """Runs only inside the explicitly requested GPU extraction callback."""
90
- root = directory(identifier)
91
- if not (root/'ready.json').is_file():
92
- raise ValueError('The generated o-voxel has not finished saving')
93
- output = root/'asset.glb'
94
- if output.is_file():
95
- return str(output)
96
- import numpy as np
97
- import torch
98
- from supratex.representations import MeshWithVoxel
99
- from supratex.inference.pipeline import _export, LAYOUT, _runtime
100
- if not torch.cuda.is_available():
101
- raise RuntimeError('GLB extraction requires CUDA')
102
- with np.load(root/'asset.npz',allow_pickle=False) as state:
103
- resolution = int(state['resolution'])
104
- representation = MeshWithVoxel(torch.from_numpy(state['vertices']).cuda(),
105
- torch.from_numpy(state['faces']).cuda(),state['origin'].tolist(),float(state['voxel_size']),
106
- torch.from_numpy(state['coords']).cuda(),torch.from_numpy(state['attrs']).cuda(),
107
- torch.Size([1,6,resolution,resolution,resolution]),layout=LAYOUT)
108
- import time
109
- started = time.monotonic()
110
- _export(output, representation,4096,500000,'png',_runtime())
111
- from hf_space import extraction_progress
112
- extraction_progress.stage(token, 'validate')
113
- from hf_space.backend import validate_glb
114
- metadata = validate_glb(output)
115
- (root/'extraction.json').write_text(json.dumps({'status':'complete','device':'cuda',
116
- 'elapsed_seconds':time.monotonic()-started,**metadata},indent=2))
117
- return str(output)
 
1
+ """Persist final o-voxels and their rendered views in the mounted data Bucket."""
2
+ import json
3
+ from pathlib import Path
4
+ import re
5
+ import shutil
6
+ from html import escape
7
+ from urllib.parse import quote
8
+
9
+ PREVIEW_MODES = (
10
+ ('normal', 'Normal', 'normal.png'),
11
+ ('clay', 'Clay', 'clay.png'),
12
+ ('base_color', 'Base color', 'basecolor.png'),
13
+ ('shaded_studio', 'HDRI Studio', 'hdri_studio.png'),
14
+ ('shaded_forest', 'HDRI Forest', 'hdri_forest.png'),
15
+ ('shaded_sunset', 'HDRI Sunset', 'hdri_sunset.png'),
16
+ ('shaded_courtyard', 'HDRI Courtyard', 'hdri_courtyard.png'),
17
+ ('shaded', 'Studio', 'hdri_studio.png'), # Previously saved previews.
18
+ )
19
+
20
+ ROOT = Path('/data/generated/ovoxel-results-v1')
21
+
22
+
23
+ def directory(identifier):
24
+ if not isinstance(identifier, str) or re.fullmatch(r'[0-9a-f]{32}', identifier) is None:
25
+ raise ValueError('Invalid generated asset identifier')
26
+ return ROOT/identifier
27
+
28
+
29
+ def persist(source, report):
30
+ source = Path(source)
31
+ identifier = source.parent.name
32
+ destination = directory(identifier)
33
+ destination.mkdir(parents=True, exist_ok=True)
34
+ for name in ('asset.npz','input.png','report.json','preview.json','timings.jsonl'):
35
+ shutil.copyfile(source.parent/name, destination/name)
36
+ shutil.copytree(source.parent/'views', destination/'views', dirs_exist_ok=True)
37
+ (destination/'ready.json').write_text(json.dumps({'complete':True,'identifier':identifier,
38
+ 'project_revision':report['project_revision'],'output_kind':'final_ovoxel'}))
39
+ return identifier
40
+
41
+
42
+ def preview_html(identifier):
43
+ root = directory(identifier)
44
+ metadata = json.loads((root/'preview.json').read_text())
45
+ default = metadata.get('default_mode', 'shaded' if 'shaded' in metadata['modes'] else 'shaded_forest')
46
+ if default not in metadata['modes']:
47
+ default = metadata['modes'][0]
48
+ images = []
49
+ for mode in metadata['modes']:
50
+ for view in range(metadata['views']):
51
+ url = '/gradio_api/file='+quote(str(root/'views'/f'{mode}-{view:02d}.webp'),safe='/')
52
+ hidden = '' if mode == default and view == 0 else ' hidden'
53
+ images.append(f'<img src="{url}" data-mode="{mode}" data-view="{view}" alt="{mode.replace("_"," ")} view {view+1}"{hidden}>')
54
+ buttons = []
55
+ for mode, label, icon in PREVIEW_MODES:
56
+ if mode not in metadata['modes']:
57
+ continue
58
+ # Inline the tiny swatches; preview frames remain individually cached files.
59
+ import base64
60
+ icon_path = Path(__file__).resolve().parents[1]/'assets/preview-lighting'/icon
61
+ swatch = base64.b64encode(icon_path.read_bytes()).decode('ascii')
62
+ active = mode == default
63
+ buttons.append(f'<button type="button" data-mode="{mode}" class="{"active" if active else ""}" '
64
+ f'title="{label}" aria-label="{label}" aria-pressed="{str(active).lower()}">'
65
+ f'<img src="data:image/png;base64,{swatch}" alt=""><span class="st-mode-name">{label}</span></button>')
66
+ return (f'<div class="st-ovoxel-preview" data-mode="{escape(default, quote=True)}" data-view="0">'
67
+ '<div class="st-ovoxel-frames">'+''.join(images)+'</div>'
68
+ '<div class="st-ovoxel-modes" role="group" aria-label="Render mode">'+''.join(buttons)+'</div>'
69
+ f'<label class="st-ovoxel-angle">View angle <input type="range" min="0" max="{metadata["views"]-1}" value="0" step="1" aria-label="View angle"></label></div>')
70
+
71
+
72
+ def extract(identifier, extraction_token=None):
73
+ from hf_space import extraction_progress as progress
74
+ from hf_space import generation_progress
75
+ from hf_space.timing import observe_stages
76
+ token = extraction_token if generation_progress.valid(extraction_token) else progress.start()
77
+ try:
78
+ progress.stage(token, 'load')
79
+ with observe_stages(lambda name: progress.stage(token, name)):
80
+ output = _extract(identifier, token)
81
+ progress.finish(token)
82
+ return output
83
+ except Exception:
84
+ progress.finish(token, failed=True)
85
+ raise
86
+
87
+
88
+ def _extract(identifier, token):
89
+ """Runs only inside the explicitly requested GPU extraction callback."""
90
+ root = directory(identifier)
91
+ if not (root/'ready.json').is_file():
92
+ raise ValueError('The generated o-voxel has not finished saving')
93
+ output = root/'asset.glb'
94
+ if output.is_file():
95
+ return str(output)
96
+ import numpy as np
97
+ import torch
98
+ from supratex.representations import MeshWithVoxel
99
+ from supratex.inference.pipeline import _export, LAYOUT, _runtime
100
+ if not torch.cuda.is_available():
101
+ raise RuntimeError('GLB extraction requires CUDA')
102
+ with np.load(root/'asset.npz',allow_pickle=False) as state:
103
+ resolution = int(state['resolution'])
104
+ representation = MeshWithVoxel(torch.from_numpy(state['vertices']).cuda(),
105
+ torch.from_numpy(state['faces']).cuda(),state['origin'].tolist(),float(state['voxel_size']),
106
+ torch.from_numpy(state['coords']).cuda(),torch.from_numpy(state['attrs']).cuda(),
107
+ torch.Size([1,6,resolution,resolution,resolution]),layout=LAYOUT)
108
+ import time
109
+ started = time.monotonic()
110
+ _export(output, representation,4096,500000,'png',_runtime(), low_vram=True)
111
+ from hf_space import extraction_progress
112
+ extraction_progress.stage(token, 'validate')
113
+ from hf_space.backend import validate_glb
114
+ metadata = validate_glb(output)
115
+ (root/'extraction.json').write_text(json.dumps({'status':'complete','device':'cuda',
116
+ 'elapsed_seconds':time.monotonic()-started,**metadata},indent=2))
117
+ return str(output)
hf_space/release_backend.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bind the pinned official single-view CLI to the existing Gradio callbacks."""
2
+ from pathlib import Path
3
+ import json
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ import time
10
+ import traceback
11
+ import uuid
12
+
13
+ from hf_space import native_build, generation_progress
14
+
15
+ ROOT=Path(__file__).resolve().parents[1]
16
+ MODEL_REVISION='1bc02eb969a8ed0f1418c2bbe1e409a8481f834c'
17
+ CODE_REVISION='e4dfbbde6a8d76e25deef5aba5d836f03994967d'
18
+ CHECKPOINT='denoiser_step0030000.safetensors'
19
+ MODEL_ROOT=Path('/data/models')
20
+ STATE=Path('/tmp/supratex-release-preparation.json')
21
+ READY=Path('/tmp/supratex-latest-ready')
22
+ _lock=threading.Lock()
23
+ _worker=None
24
+
25
+ def model_status():
26
+ return json.loads(STATE.read_text()) if STATE.exists() else {'stage':'pending'}
27
+
28
+ def update(**changes):
29
+ value=model_status()|changes|{'updated_unix':time.time()}
30
+ temporary=STATE.with_suffix('.tmp')
31
+ temporary.write_text(json.dumps(value,indent=2))
32
+ temporary.replace(STATE)
33
+ print('[release-prepare] '+json.dumps(changes),flush=True)
34
+
35
+ def prepare_models():
36
+ try:
37
+ update(stage='waiting_native_build',startup_progress={'label':'Preparing runtime','completed':0,'total':0})
38
+ while native_build.status().get('stage') not in ('compiled','failed'):
39
+ time.sleep(2)
40
+ if native_build.status()['stage']!='compiled':
41
+ raise RuntimeError('Native runtime preparation failed')
42
+ repository=os.environ.get('SUPRATEX_MODEL_REPO','supra3d/supra-tex')
43
+ revision=os.environ.get('SUPRATEX_MODEL_REVISION',MODEL_REVISION)
44
+ if (repository,revision)!=('supra3d/supra-tex',MODEL_REVISION):
45
+ raise RuntimeError('This source release requires the validated, pinned model revision')
46
+ from scripts import prepare_models as prep
47
+ from hf_space.download_models import download
48
+ plan=prep.build_plan(prep.parse_args(['--repo',repository,'--revision',revision,
49
+ '--with-rmbg','--output-dir',str(MODEL_ROOT)]),resolve_remote=True)
50
+ operations=sorted(plan['downloads'],key=lambda item:not item.get('gated',False))
51
+ for index,operation in enumerate(operations):
52
+ if operation['name']=='project':
53
+ operation=dict(operation,destination='project/'+revision)
54
+ update(stage='downloading_foundations',project_revision=revision,
55
+ startup_progress={'label':'Checking model files: '+operation['name'],
56
+ 'completed':index,'total':len(operations),'unit':'groups'})
57
+ download(operation,MODEL_ROOT)
58
+ from hf_space.weight_cache import mirror_models,mirror_project
59
+ update(stage='staging_local_weights',startup_progress={
60
+ 'label':'Preparing local weight files','completed':0,'total':0})
61
+ project_root=MODEL_ROOT/'project'/revision
62
+ sources=[*(MODEL_ROOT/'external').rglob('*.safetensors'),*project_root.rglob('*.safetensors')]
63
+ total=sum(path.stat().st_size for path in sources)
64
+ copied={}
65
+ def file_progress(source,completed,size):
66
+ copied[str(source)]=completed
67
+ update(startup_progress={'label':'Preparing local weight files','completed':sum(copied.values()),
68
+ 'total':total,'unit':'bytes'})
69
+ foundations=mirror_models(MODEL_ROOT,progress=file_progress)
70
+ project=mirror_project(project_root,revision,progress=file_progress)
71
+ arguments=['--repo',str(project),'--output-dir',str(READY)]
72
+ for flag,name in [('qwen-base','qwen-base'),('geometry','geometry'),('dino','dino'),
73
+ ('vae','vae'),('moge','moge2'),('rmbg','rmbg2')]:
74
+ arguments+=['--'+flag,str(foundations/name)]
75
+ arguments+=['--naf-weights',str(foundations/'naf/naf_release.pth')]
76
+ config=prep.prepare(prep.build_plan(prep.parse_args(arguments)))
77
+ prepared=json.loads(config.read_text())
78
+ if not prepared['checkpoint'].endswith(CHECKPOINT):
79
+ raise RuntimeError('The prepared pipeline did not select step-30000 completion weights')
80
+ update(stage='ready_released',config=str(config),project_repository=repository,
81
+ project_revision=revision,code_revision=CODE_REVISION,weight_origin='released',
82
+ actual_training_steps=30000,random_weights_present=False,
83
+ checkpoint=CHECKPOINT,model_loading='official_stage_loading',
84
+ startup_progress={'label':'Model files ready','completed':1,'total':1,'unit':'groups'})
85
+ except BaseException:
86
+ update(stage='failed',error=traceback.format_exc())
87
+ traceback.print_exc()
88
+
89
+ def start_preparation():
90
+ global _worker
91
+ with _lock:
92
+ if model_status().get('stage')=='ready_released':return None
93
+ if _worker is None or not _worker.is_alive():
94
+ _worker=threading.Thread(target=prepare_models,daemon=True,name='latest-release-prepare')
95
+ _worker.start()
96
+ return _worker
97
+
98
+ def ensure_prepared(generation_token=None):
99
+ worker=start_preparation()
100
+ while worker is not None and worker.is_alive():
101
+ state=model_status()
102
+ generation_progress.update(generation_token,status='preparing',
103
+ label=state.get('startup_progress',{}).get('label','Preparing model files'))
104
+ worker.join(timeout=1)
105
+ state=model_status()
106
+ if state.get('stage')!='ready_released':
107
+ raise RuntimeError('Latest-release preparation failed: '+state.get('error','unknown failure')[-2000:])
108
+
109
+ def generate(image,seed,progress=None,timeout_seconds=1800,generation_token=None):
110
+ state=model_status()
111
+ if state.get('stage')!='ready_released':raise RuntimeError('Model files are not ready')
112
+ identifier=uuid.uuid4().hex
113
+ job=Path('/tmp/supratex-jobs')/identifier
114
+ job.mkdir(parents=True)
115
+ image.save(job/'input.png')
116
+ output=job/'asset.npz'
117
+ command=[sys.executable,str(ROOT/'scripts/run_example.py'),'--config',state['config'],
118
+ '--input',str(job/'input.png'),'--output',str(output),'--attention','sdpa','--low-vram',
119
+ '--seed',str(int(seed)),'--preview-only']
120
+ environment=os.environ.copy()
121
+ environment.update(OMP_NUM_THREADS='4',
122
+ SUPRATEX_RUNTIME_PROFILE='torch28',PYTHONUNBUFFERED='1',
123
+ SUPRATEX_TIMING_PATH=str(job/'timings.jsonl'),SUPRATEX_TIMING_ORIGIN_NS=str(time.monotonic_ns()))
124
+ started=time.monotonic()
125
+ generation_progress.update(generation_token,status='running',job=identifier,
126
+ generation_started_unix=time.time(),label='Running the latest single-view pipeline')
127
+ if progress:progress(.05,'Running the latest single-view pipeline')
128
+ process=subprocess.Popen(command,cwd=ROOT,env=environment,stdout=subprocess.PIPE,
129
+ stderr=subprocess.STDOUT,text=True,start_new_session=True)
130
+ def stop_processes():
131
+ if process.poll() is None:
132
+ try:os.killpg(process.pid,signal.SIGKILL)
133
+ except ProcessLookupError:pass
134
+ timer=threading.Timer(timeout_seconds,stop_processes)
135
+ timer.start()
136
+ try:
137
+ with (job/'inference.log').open('w') as log:
138
+ for line in process.stdout:
139
+ log.write(line);log.flush()
140
+ print(line,end='',flush=True)
141
+ if line.startswith('[1/2]'):
142
+ generation_progress.update(generation_token,label='Generating geometry and reference materials')
143
+ elif line.startswith('[2/2]'):
144
+ generation_progress.update(generation_token,label='Completing surface materials and rendering o-voxel previews')
145
+ code=process.wait()
146
+ finally:
147
+ timer.cancel()
148
+ if process.poll() is None:
149
+ stop_processes();process.wait()
150
+ receipt_path=job/'asset_work/run.json'
151
+ receipt=json.loads(receipt_path.read_text()) if receipt_path.exists() else {}
152
+ if code or receipt.get('status')!='complete' or not output.is_file():
153
+ raise RuntimeError('Latest single-view generation failed: '+(job/'inference.log').read_text(errors='replace')[-5000:])
154
+ import numpy as np
155
+ with np.load(output,allow_pickle=False) as asset:
156
+ required={'vertices','faces','coords','attrs','resolution','origin','voxel_size'}
157
+ if not required<=set(asset.files) or not len(asset['coords']) or not len(asset['faces']):
158
+ raise RuntimeError('Generated o-voxel geometry is incomplete')
159
+ if asset['attrs'].shape[1]!=6 or not np.isfinite(asset['attrs']).all():
160
+ raise RuntimeError('Generated o-voxel materials are invalid')
161
+ voxel_count=len(asset['coords'])
162
+ preview=json.loads((job/'preview.json').read_text())
163
+ if len(list((job/'views').glob('*.webp')))!=preview['views']*len(preview['modes']):
164
+ raise RuntimeError('The o-voxel preview render set is incomplete')
165
+ if list(job.rglob('*.glb')) or preview.get('automatic_glb_export') is not False:
166
+ raise RuntimeError('Generation must not export a mesh before selection')
167
+ report={'status':'complete','asset_id':identifier,'code_revision':CODE_REVISION,
168
+ 'project_revision':MODEL_REVISION,'checkpoint':CHECKPOINT,'weight_origin':'released',
169
+ 'elapsed_seconds':time.monotonic()-started,'output_kind':'final_ovoxel','backview':False,
170
+ 'refine':False,'model_loading':'official_stage_loading','run':receipt,
171
+ 'preview':preview,'voxel_count':voxel_count,'automatic_glb_export':False}
172
+ (job/'report.json').write_text(json.dumps(report,indent=2))
173
+ if not (job/'timings.jsonl').exists():
174
+ (job/'timings.jsonl').write_text(json.dumps({'name':'preview_generation','elapsed_seconds':report['elapsed_seconds']})+'\n')
175
+ if progress:progress(1.,'Complete')
176
+ return str(output),str(output),report
hf_space/startup_progress.py CHANGED
@@ -1,23 +1,23 @@
1
- """English startup progress, measured from copied bytes and loaded models."""
2
- import html
3
-
4
-
5
- def render(state):
6
- progress = state.get('models', {}).get('startup_progress', {})
7
- done = max(0, progress.get('completed', 0))
8
- total = max(0, progress.get('total', 0))
9
- label = html.escape(progress.get('label', 'Preparing runtime'))
10
- unit = progress.get('unit')
11
- if total and unit == 'bytes':
12
- detail = f'{done / 1024**3:.1f} / {total / 1024**3:.1f} GB'
13
- elif total:
14
- detail = f'{int(done)} / {int(total)} ' + ('models' if unit == 'models' else 'groups')
15
- else:
16
- detail = ''
17
- value = f' value="{min(100, done / total * 100):.2f}"' if total else ''
18
- return ('<div class="st-startup" role="status" aria-live="polite">'
19
- '<h3>Loading weights</h3>'
20
- '<p class="st-startup-note">Models load automatically in the background when the demo starts. All users share the loaded models.</p>'
21
- '<div class="st-startup-heading"><span>Loading progress</span><span>' + detail + '</span></div>'
22
- '<progress max="100"' + value + ' aria-label="Weight loading progress"></progress>'
23
- '<div class="st-startup-stage">' + label + '</div></div>')
 
1
+ """English startup progress, measured from copied bytes and loaded models."""
2
+ import html
3
+
4
+
5
+ def render(state):
6
+ progress = state.get('models', {}).get('startup_progress', {})
7
+ done = max(0, progress.get('completed', 0))
8
+ total = max(0, progress.get('total', 0))
9
+ label = html.escape(progress.get('label', 'Preparing runtime'))
10
+ unit = progress.get('unit')
11
+ if total and unit == 'bytes':
12
+ detail = f'{done / 1024**3:.1f} / {total / 1024**3:.1f} GB'
13
+ elif total:
14
+ detail = f'{int(done)} / {int(total)} ' + ('models' if unit == 'models' else 'groups')
15
+ else:
16
+ detail = ''
17
+ value = f' value="{min(100, done / total * 100):.2f}"' if total else ''
18
+ return ('<div class="st-startup" role="status" aria-live="polite">'
19
+ '<h3>Loading weights</h3>'
20
+ '<p class="st-startup-note">Model files are prepared in the background and cached for all users.</p>'
21
+ '<div class="st-startup-heading"><span>Loading progress</span><span>' + detail + '</span></div>'
22
+ '<progress max="100"' + value + ' aria-label="Weight loading progress"></progress>'
23
+ '<div class="st-startup-stage">' + label + '</div></div>')
infer.py CHANGED
@@ -1,227 +1,350 @@
1
- #!/usr/bin/env python3
2
- """Generate a textured PBR mesh from one RGB/RGBA image."""
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import json
7
- import math
8
- from pathlib import Path
9
- from hf_space.timing import span
10
-
11
-
12
- def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
13
- parser = argparse.ArgumentParser(description=__doc__)
14
- parser.add_argument("--input", type=Path,
15
- default=Path(__file__).resolve().parent / "assets/pitcher_plant.png",
16
- help="source RGB/RGBA image (default: assets/pitcher_plant.png)")
17
- parser.add_argument("--output", required=True, type=Path, help="new destination .glb file")
18
- parser.add_argument("--preview-only", action="store_true", help="save final o-voxel and GPU multiview snapshots; skip GLB extraction")
19
- parser.add_argument("--work-dir", type=Path, help="fresh intermediate directory (default: <output stem>_work beside the GLB)")
20
- parser.add_argument("--moge-model", required=True, help="local MoGe-2 checkpoint/directory or HF repo ID")
21
- parser.add_argument("--rmbg-model", help="local RMBG-2 directory or HF repo ID; required for RGB input")
22
- parser.add_argument("--use-input-alpha", action="store_true", help="require and reuse foreground RGBA alpha")
23
- parser.add_argument("--resolution-level", type=int, default=9, help="MoGe-2 resolution level")
24
- parser.add_argument("--geometry-package", required=True, type=Path, help="native or paper geometry source checkout; see docs/geometry.md")
25
- parser.add_argument("--geometry-model", required=True, help="local geometry weights directory or HF repo ID")
26
- parser.add_argument("--geometry-revision", help="geometry weights HF revision")
27
- parser.add_argument("--geometry-initial-mesh-scale", type=float, default=1.0)
28
- parser.add_argument("--geometry-target-padding", type=int, default=3)
29
- parser.add_argument("--geometry-max-iterations", type=int, default=2)
30
- parser.add_argument("--firered-package", type=Path, default=Path(__file__).resolve().parent / "supratex/frontend/material_model")
31
- parser.add_argument("--material-backend", choices=("qwen-image", "firered"), default="qwen-image")
32
- parser.add_argument("--material-base", help="selected backend base model: local directory or HF repo ID")
33
- parser.add_argument("--firered-base", help="legacy FireRed base option; requires --material-backend firered")
34
- parser.add_argument("--qwen-runtime", type=Path, help="native Qwen source snapshot (default: ckpts/qwen-runtime)")
35
- parser.add_argument("--qwen-deps", type=Path, help="Qwen dependency directory (default: ckpts/qwen-deps)")
36
- parser.add_argument("--material-model", help="local backend checkpoint bundle or HF repo ID")
37
- parser.add_argument("--material-revision", help="material checkpoint HF revision")
38
- parser.add_argument("--material-weights", type=Path)
39
- parser.add_argument("--material-spec", type=Path)
40
- parser.add_argument("--material-checkpoint-manifest", type=Path)
41
- parser.add_argument("--pretrained", help="local Supra-Tex completion bundle or HF repo ID")
42
- parser.add_argument("--revision", help="completion bundle HF revision")
43
- parser.add_argument("--model-config", type=Path, help="completion ABI JSON; overrides bundle config")
44
- parser.add_argument("--checkpoint", type=Path, help="completion weights; overrides bundle checkpoint")
45
- parser.add_argument("--trellis-root", type=Path, help="TRELLIS.2 VAE directory (all four components for HF releases)")
46
- parser.add_argument("--cache-dir", type=Path, help="shared HF download cache for all adapters")
47
- parser.add_argument("--local-files-only", action="store_true", help="resolve models locally without Hub downloads")
48
- parser.add_argument("--attention", choices=("auto", "flash_attn_3", "flash_attn", "sdpa", "xformers"), default="auto",
49
- help="inference attention backend; auto selects by GPU and installed extensions")
50
- parser.add_argument("--low-vram", action=argparse.BooleanOptionalAction, default=None,
51
- help="CPU offload for Qwen and 3D stages; auto enabled on GPUs with up to 24 GiB")
52
- parser.add_argument("--seed", type=int, default=42, help="geometry and completion seed (material runner uses its fixed seed 42)")
53
- parser.add_argument("--texture-size", type=int, default=4096)
54
- parser.add_argument("--decimation-target", type=int, default=500000)
55
- parser.add_argument("--texture-format", choices=("png", "webp"), default="png")
56
- parser.add_argument("--gutter", action="store_true", help="enable chart-isolated one-texel base-color gutter padding (default: disabled)")
57
- parser.add_argument("--dual-view", action="store_true", help="add v4 back rendering, Astra/image_gen generation, and dual-view texturing")
58
- parser.add_argument("--back-view-dir", type=Path, help="reuse a prepared, geometry-bound back package instead of generating a new back")
59
- args = parser.parse_args(argv)
60
- if args.back_view_dir is not None and not args.dual_view:
61
- parser.error("--back-view-dir requires --dual-view")
62
- if args.dual_view and (args.texture_size != 4096 or args.texture_format != "png"
63
- or args.decimation_target != 500000 or args.gutter):
64
- parser.error("dual-view reprojection requires 4096 PNG, a 500000-face target, and no --gutter")
65
- material_paths = (args.material_weights, args.material_spec, args.material_checkpoint_manifest)
66
- if args.material_backend == "qwen-image":
67
- if args.firered_base or any(material_paths):
68
- parser.error("FireRed options require --material-backend firered")
69
- args.qwen_runtime = args.qwen_runtime or Path(__file__).resolve().parent / "ckpts/qwen-runtime"
70
- args.qwen_deps = args.qwen_deps or Path(__file__).resolve().parent / "ckpts/qwen-deps"
71
- args.material_base = args.material_base or str(Path(__file__).resolve().parent / "ckpts/qwen-base")
72
- args.material_model = args.material_model or str(Path(__file__).resolve().parent / "ckpts/qwen-material")
73
- else:
74
- if args.qwen_runtime is not None or args.qwen_deps is not None:
75
- parser.error("--qwen-runtime and --qwen-deps require --material-backend qwen-image")
76
- if args.material_base and args.firered_base:
77
- parser.error("provide only one of --material-base and --firered-base")
78
- if not (args.material_base or args.firered_base):
79
- parser.error("FireRed requires --material-base or --firered-base")
80
- if args.material_model and any(material_paths):
81
- parser.error("--material-model cannot be combined with explicit material artifact paths")
82
- if not args.material_model and not all(material_paths):
83
- parser.error("provide --material-model or all three --material-weights, --material-spec, --material-checkpoint-manifest")
84
- if not args.pretrained and not all((args.model_config, args.checkpoint, args.trellis_root)):
85
- parser.error("provide --pretrained or all of --model-config, --checkpoint, and --trellis-root")
86
- if args.seed < 0 or args.texture_size <= 0 or args.decimation_target <= 0:
87
- parser.error("seed must be nonnegative; texture-size and decimation-target must be positive")
88
- if not math.isfinite(args.geometry_initial_mesh_scale) or args.geometry_initial_mesh_scale <= 0:
89
- parser.error("geometry-initial-mesh-scale must be finite and positive")
90
- if not 1 <= args.geometry_target_padding <= 30 or args.geometry_max_iterations < 1:
91
- parser.error("geometry-target-padding must be in [1, 30]; geometry-max-iterations must be positive")
92
- if not 0 <= args.resolution_level <= 9:
93
- parser.error("resolution-level must be in [0, 9]")
94
- return args
95
-
96
-
97
- def _completion_paths(args: argparse.Namespace) -> tuple[Path, Path, Path]:
98
- """Resolve local/Hub artifacts and check missing completion files before GPU work."""
99
- from supratex.weights import COMPLETION_ALLOW_PATTERNS, resolve_completion_artifacts, resolve_model_dir
100
- root = None
101
- if args.pretrained:
102
- root = resolve_model_dir(args.pretrained, revision=args.revision, cache_dir=args.cache_dir,
103
- local_files_only=args.local_files_only,
104
- allow_patterns=COMPLETION_ALLOW_PATTERNS)
105
- return resolve_completion_artifacts(root, model_config=args.model_config,
106
- checkpoint=args.checkpoint, trellis_root=args.trellis_root)
107
-
108
-
109
- def run(args: argparse.Namespace, *, resident=None) -> Path:
110
- """Run the existing image frontend, then complete and export its generated mesh."""
111
- image, output = args.input.expanduser().resolve(), args.output.expanduser().resolve()
112
- work = (args.work_dir or output.parent / f"{output.stem}_work").expanduser().resolve()
113
- expected_suffix = '.npz' if args.preview_only else '.glb'
114
- if output.suffix.lower() != expected_suffix:
115
- raise ValueError(f"--output must have a {expected_suffix} suffix")
116
- if output.exists():
117
- raise FileExistsError(f"refusing to overwrite existing output: {output}")
118
- if work.exists() and (not work.is_dir() or any(work.iterdir())):
119
- raise FileExistsError(f"--work-dir must be a fresh empty directory: {work}")
120
- if not image.is_file():
121
- raise FileNotFoundError(image)
122
- from PIL import Image
123
- with Image.open(image) as source:
124
- alpha = source.getchannel("A").getextrema() if source.mode == "RGBA" else (255, 255)
125
- has_alpha = alpha[0] < 255
126
- if args.use_input_alpha and not has_alpha:
127
- raise ValueError("--use-input-alpha requires a non-opaque RGBA input")
128
- if not has_alpha and not args.rmbg_model:
129
- raise ValueError("RGB/opaque input requires --rmbg-model")
130
- source.load()
131
- from supratex.inference.runtime import configure_inference_runtime
132
- runtime = configure_inference_runtime(attention=getattr(args, "attention", "auto"),
133
- low_vram=getattr(args, "low_vram", None),
134
- material_backend=args.material_backend)
135
- # The adapters validate source compatibility and full checkpoint contents.
136
- from supratex.frontend.geometry import validate_source
137
- validate_source(args.geometry_package.expanduser())
138
- if args.material_backend == "qwen-image":
139
- from supratex.frontend.qwen_image import validate
140
- validate(args.qwen_runtime, args.material_base, args.material_model, deps=args.qwen_deps,
141
- revision=args.material_revision, cache_dir=args.cache_dir, local_files_only=args.local_files_only)
142
- elif not (args.firered_package.expanduser() / "infer.py").is_file():
143
- raise FileNotFoundError(f"material entrypoint is missing: {args.firered_package / 'infer.py'}")
144
- material_paths = {}
145
- for name in ("material_weights", "material_spec", "material_checkpoint_manifest"):
146
- path = getattr(args, name)
147
- if path is not None:
148
- path = path.expanduser().resolve()
149
- if not path.is_file():
150
- raise FileNotFoundError(f"missing {name}: {path}")
151
- material_paths[name] = path
152
- if args.dual_view and args.back_view_dir is None:
153
- import shutil
154
- if shutil.which("codex") is None:
155
- raise RuntimeError("automatic back generation requires Codex with gpt-6-astra and built-in image_gen")
156
- config, checkpoint, encoders = _completion_paths(args)
157
- from supratex.frontend.pipeline import run as run_frontend
158
- from supratex.inference import run_completion
159
- work.mkdir(parents=True, exist_ok=True)
160
- handoff = work / "input"
161
- receipt = {"status": "running", "stage": "frontend", "input": str(image), "output": str(output),
162
- "work_dir": str(work), "seed": args.seed, "gutter": args.gutter,
163
- "material_backend": args.material_backend, "material_seed": 42,
164
- "runtime": runtime,
165
- "model_config": str(config), "checkpoint": str(checkpoint), "dual_view": args.dual_view}
166
- def record() -> None:
167
- temporary = work / ".run.json.tmp"
168
- temporary.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")
169
- temporary.replace(work / "run.json")
170
- record()
171
- try:
172
- print(f"[1/2] Foreground, MoGe-2, optimized geometry, material maps -> {handoff}", flush=True)
173
- with span('frontend'):
174
- result = run_frontend(image, handoff, moge_model=args.moge_model, rmbg_model=args.rmbg_model,
175
- use_input_alpha=args.use_input_alpha, resolution_level=args.resolution_level,
176
- geometry_package=args.geometry_package.expanduser().resolve(), geometry_model=args.geometry_model,
177
- geometry_revision=args.geometry_revision, geometry_seed=args.seed,
178
- geometry_initial_mesh_scale=args.geometry_initial_mesh_scale,
179
- geometry_target_padding=args.geometry_target_padding,
180
- geometry_max_iterations=args.geometry_max_iterations,
181
- firered_package=args.firered_package.expanduser().resolve(), firered_base=args.firered_base,
182
- material_backend=args.material_backend, material_base=args.material_base,
183
- qwen_runtime=args.qwen_runtime if args.material_backend == "qwen-image" else None,
184
- qwen_deps=args.qwen_deps if args.material_backend == "qwen-image" else None,
185
- material_model=args.material_model, **material_paths,
186
- revision=args.material_revision, cache_dir=args.cache_dir, local_files_only=args.local_files_only,
187
- resident=resident,
188
- **({"record_material_provenance": True} if args.dual_view else {}))
189
- optional = {}
190
- if args.dual_view:
191
- receipt.update(stage="back_view", frontend=result)
192
- record()
193
- if args.back_view_dir is None:
194
- from supratex.backview.workflow import build_back_view
195
- back_dir = build_back_view(handoff, work / "back_view", cache_dir=args.cache_dir,
196
- local_files_only=args.local_files_only)
197
- else:
198
- back_dir = args.back_view_dir.expanduser().resolve()
199
- optional = {"back_view_dir": back_dir, "work_dir": work / "dual_view"}
200
- receipt["back_view_dir"] = str(back_dir)
201
- receipt.update(stage="completion", frontend=result)
202
- record()
203
- print(f"[2/2] Surface observations, PBR completion, {'o-voxel preview' if args.preview_only else 'GLB export'} -> {output}", flush=True)
204
- with span('completion'):
205
- run_completion(input_dir=handoff, output=output, model_config=config, checkpoint=checkpoint,
206
- trellis_root=encoders, seed=args.seed, texture_size=args.texture_size,
207
- decimation_target=args.decimation_target, texture_format=args.texture_format, gutter=args.gutter,
208
- resident=resident, preview_only=args.preview_only, **optional)
209
- if not output.is_file() or output.stat().st_size == 0:
210
- raise RuntimeError(f"completion did not emit a nonempty GLB: {output}")
211
- except Exception as error:
212
- receipt.update(status="failed", error=f"{type(error).__name__}: {error}")
213
- record()
214
- raise
215
- receipt.update(status="complete", stage="complete")
216
- record()
217
- print(f"Textured mesh: {output}\nIntermediate files and run receipt: {work}", flush=True)
218
- return output
219
-
220
-
221
- def main() -> None:
222
- with span('pipeline'):
223
- run(parse_args())
224
-
225
-
226
- if __name__ == "__main__":
227
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate a textured PBR mesh from one RGB/RGBA image."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import math
8
+ import os
9
+ from pathlib import Path
10
+ import sys
11
+
12
+
13
+ def _pipeline_overrides(args: argparse.Namespace) -> dict:
14
+ """Validate explicit model selections before resolving a release."""
15
+ overrides = {}
16
+ for component, attribute in (("completion", "pretrained"), ("material", "material_model"),
17
+ ("qwen-base", "material_base"), ("geometry", "geometry_model"),
18
+ ("vae", "trellis_root"), ("moge2", "moge_model"), ("rmbg2", "rmbg_model")):
19
+ value = getattr(args, attribute)
20
+ if value is not None:
21
+ if component not in ("completion", "material") and not Path(value).expanduser().exists():
22
+ raise ValueError(f"--pipeline requires a local --{attribute.replace('_', '-')} override")
23
+ overrides[component] = value
24
+ if args.revision is not None:
25
+ if args.pretrained is None:
26
+ raise ValueError("--revision requires --pretrained with --pipeline; use --pipeline-revision for the release")
27
+ overrides["completion_revision"] = args.revision
28
+ if args.material_revision is not None:
29
+ if args.material_model is None:
30
+ raise ValueError("--material-revision requires --material-model with --pipeline")
31
+ overrides["material_revision"] = args.material_revision
32
+ return overrides
33
+
34
+
35
+ def _prepare_pipeline(args: argparse.Namespace, overrides: dict, descriptor: dict, *, has_alpha: bool) -> dict:
36
+ """Prepare the release already selected during argument validation."""
37
+ from scripts.prepare_models import prepare_pipeline
38
+ from scripts.run_example import read_config
39
+
40
+ source = descriptor["repo_id"] if descriptor["kind"] == "hub" else args.pipeline
41
+ revision = descriptor.get("revision") if descriptor["kind"] == "hub" else args.pipeline_revision
42
+ prepared = prepare_pipeline(source, output_dir=args.models_dir, revision=revision,
43
+ cache_dir=args.cache_dir, local_files_only=args.local_files_only,
44
+ with_rmbg=not has_alpha,
45
+ overrides=overrides)
46
+ return read_config(prepared)
47
+
48
+
49
+ def _validate_inference_options(args: argparse.Namespace) -> None:
50
+ """Check explicit values first, then call again after release defaults."""
51
+ from scripts.prepare_models import _pipeline
52
+
53
+ if args.preview_only and (args.dual_view or args.refine):
54
+ raise ValueError("o-voxel preview is single-view and precedes mesh refinement/export")
55
+ if args.back_view_dir is not None and not args.dual_view:
56
+ raise ValueError("--back-view-dir requires --dual-view")
57
+ _pipeline.validate_export_options(args)
58
+ if not math.isfinite(args.geometry_initial_mesh_scale) or args.geometry_initial_mesh_scale <= 0:
59
+ raise ValueError("geometry-initial-mesh-scale must be finite and positive")
60
+ if not 1 <= args.geometry_target_padding <= 30 or args.geometry_max_iterations < 1:
61
+ raise ValueError("geometry-target-padding must be in [1, 30]; geometry-max-iterations must be positive")
62
+ if not 0 <= args.resolution_level <= 9:
63
+ raise ValueError("resolution-level must be in [0, 9]")
64
+
65
+
66
+ def _validate_input_output(args: argparse.Namespace, *, require_rmbg: bool = True) -> tuple[Path, Path, Path, bool]:
67
+ """Check local inputs and destinations without creating or downloading files."""
68
+ image, output = args.input.expanduser().resolve(), args.output.expanduser().absolute()
69
+ expected_suffix = ".npz" if args.preview_only else ".glb"
70
+ if output.suffix.lower() != expected_suffix:
71
+ raise ValueError(f"--output must have a {expected_suffix} suffix")
72
+ if output.exists() or output.is_symlink():
73
+ raise FileExistsError(f"refusing to overwrite existing output: {output}")
74
+ output = output.resolve()
75
+ work = (args.work_dir or output.parent / f"{output.stem}_work").expanduser().absolute()
76
+ if work.is_symlink() and not work.exists():
77
+ raise FileExistsError(f"--work-dir must be a fresh empty directory: {work}")
78
+ work = work.resolve()
79
+ if work.exists() and (not work.is_dir() or any(work.iterdir())):
80
+ raise FileExistsError(f"--work-dir must be a fresh empty directory: {work}")
81
+ for destination in (output, work):
82
+ for parent in destination.parents:
83
+ if parent.exists():
84
+ if not parent.is_dir():
85
+ raise NotADirectoryError(f"destination parent must be a directory: {parent}")
86
+ break
87
+ if not image.is_file():
88
+ raise FileNotFoundError(f"input image is missing: {image}")
89
+ from PIL import Image
90
+ with Image.open(image) as source:
91
+ low, high = source.getchannel("A").getextrema() if source.mode == "RGBA" else (255, 255)
92
+ has_alpha = low < 255 and high > 0
93
+ if args.use_input_alpha and not has_alpha:
94
+ raise ValueError("--use-input-alpha requires a non-opaque RGBA input with foreground pixels")
95
+ if require_rmbg and not has_alpha and not args.rmbg_model:
96
+ raise ValueError("RGB/opaque input requires --rmbg-model")
97
+ source.load()
98
+ if args.dual_view and args.back_view_dir is None:
99
+ import shutil
100
+ if shutil.which("codex") is None:
101
+ raise RuntimeError("automatic back generation requires Codex with gpt-6-astra and built-in image_gen")
102
+ return image, output, work, has_alpha
103
+
104
+
105
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
106
+ parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
107
+ parser.add_argument("--pipeline", help="SUPRA-Tex release directory, pipeline.json, or HF repo ID; prepares and loads all stages")
108
+ parser.add_argument("--pipeline-revision", help="HF revision of the complete SUPRA-Tex release")
109
+ parser.add_argument("--models-dir", type=Path, help="prepared model directory for --pipeline (default: ckpts/ready)")
110
+ parser.add_argument("--input", type=Path,
111
+ help="source RGB/RGBA image (default: assets/spiny_whelk_shell.png, or "
112
+ "assets/cinema_signage.png with --dual-view)")
113
+ parser.add_argument("--output", required=True, type=Path, help="new destination .glb file")
114
+ parser.add_argument("--preview-only", action="store_true", help="save/render final o-voxel; export mesh separately")
115
+ parser.add_argument("--work-dir", type=Path, help="fresh intermediate directory (default: <output stem>_work beside the GLB)")
116
+ parser.add_argument("--moge-model", help="local MoGe-2 checkpoint/directory or HF repo ID")
117
+ parser.add_argument("--rmbg-model", help="local RMBG-2 directory or HF repo ID; required for RGB input")
118
+ parser.add_argument("--use-input-alpha", action="store_true", help="require and reuse foreground RGBA alpha")
119
+ parser.add_argument("--resolution-level", type=int, default=9, help="MoGe-2 resolution level")
120
+ parser.add_argument("--geometry-package", type=Path, help="native or paper geometry source checkout; see docs/geometry.md")
121
+ parser.add_argument("--geometry-model", help="local geometry weights directory or HF repo ID")
122
+ parser.add_argument("--geometry-revision", help="geometry weights HF revision")
123
+ parser.add_argument("--geometry-initial-mesh-scale", type=float, default=1.0)
124
+ parser.add_argument("--geometry-scale-search", action="store_true",
125
+ help="enable bounded mesh-scale calibration (default: Pixal3D fixed scale)")
126
+ parser.add_argument("--parallel-frontend", action="store_true",
127
+ help="run geometry and Qwen material concurrently on the first two visible GPUs")
128
+ parser.add_argument("--geometry-target-padding", type=int, default=3, help="scale-search target padding")
129
+ parser.add_argument("--geometry-max-iterations", type=int, default=2, help="scale-search update limit")
130
+ parser.add_argument("--firered-package", type=Path, default=Path(__file__).resolve().parent / "supratex/frontend/material_model")
131
+ parser.add_argument("--material-backend", choices=("qwen-image", "firered"), default="qwen-image")
132
+ parser.add_argument("--material-base", help="selected backend base model: local directory or HF repo ID")
133
+ parser.add_argument("--firered-base", help="legacy FireRed base option; requires --material-backend firered")
134
+ parser.add_argument("--qwen-runtime", type=Path, help="native Qwen source snapshot (default: bundled runtime)")
135
+ parser.add_argument("--qwen-deps", type=Path, help="optional Qwen dependency overlay; otherwise use the active Python environment")
136
+ parser.add_argument("--material-model", help="local backend checkpoint bundle or HF repo ID")
137
+ parser.add_argument("--material-revision", help="material checkpoint HF revision")
138
+ parser.add_argument("--material-weights", type=Path)
139
+ parser.add_argument("--material-spec", type=Path)
140
+ parser.add_argument("--material-checkpoint-manifest", type=Path)
141
+ parser.add_argument("--pretrained", help="local Supra-Tex completion bundle or HF repo ID")
142
+ parser.add_argument("--revision", help="completion bundle HF revision")
143
+ parser.add_argument("--model-config", type=Path, help="completion ABI JSON; overrides bundle config")
144
+ parser.add_argument("--checkpoint", type=Path, help="completion weights; overrides bundle checkpoint")
145
+ parser.add_argument("--trellis-root", type=Path, help="TRELLIS.2 VAE directory (all four components for HF releases)")
146
+ parser.add_argument("--cache-dir", type=Path, help="shared HF download cache for all adapters")
147
+ parser.add_argument("--local-files-only", action="store_true", help="resolve models locally without Hub downloads")
148
+ parser.add_argument("--attention", choices=("auto", "flash_attn_3", "flash_attn", "sdpa", "xformers"),
149
+ help="inference attention backend; auto selects by GPU and installed extensions")
150
+ parser.add_argument("--low-vram", action=argparse.BooleanOptionalAction, default=None,
151
+ help="CPU offload for Qwen and 3D stages; auto enabled on GPUs with up to 24 GiB")
152
+ parser.add_argument("--seed", type=int, help="geometry and completion seed (material runner uses its fixed seed 42)")
153
+ parser.add_argument("--texture-size", type=int)
154
+ parser.add_argument("--decimation-target", type=int)
155
+ parser.add_argument("--texture-format", choices=("png", "webp"))
156
+ parser.add_argument("--gutter", action="store_true", help="enable chart-isolated one-texel base-color gutter padding (default: disabled); "
157
+ "with --refine, the observation-guided gutter is used")
158
+ parser.add_argument("--refine", action="store_true",
159
+ help="refine the visible texture after export: write the observed reference-view albedo into the atlas "
160
+ "and repair its UV gutters from the observation (sharper fine text and detail; needs the 4096 PNG "
161
+ "atlas; adds about 2 minutes on an RTX 3090)")
162
+ parser.add_argument("--remove-floaters", action=argparse.BooleanOptionalAction, default=True,
163
+ help="before completion, drop disconnected geometry pieces that project mostly outside the "
164
+ "image foreground (default: on); extra geometry attached to the object is kept")
165
+ parser.add_argument("--dual-view", action="store_true", help="generate a back view (needs the external Codex CLI) and texture with both views; includes --refine for both views")
166
+ parser.add_argument("--back-view-dir", type=Path, help="reuse a prepared, geometry-bound back package instead of generating a new back")
167
+ args = parser.parse_args(argv)
168
+ from scripts.run_example import MODEL_KEYS, _pipeline_config, default_input, inference_defaults
169
+ if args.input is None:
170
+ args.input = default_input(args.dual_view)
171
+ if not args.pipeline and (args.pipeline_revision is not None or args.models_dir is not None):
172
+ parser.error("--pipeline-revision and --models-dir require --pipeline")
173
+ material_paths = (args.material_weights, args.material_spec, args.material_checkpoint_manifest)
174
+ if args.pipeline and args.material_backend != "qwen-image":
175
+ parser.error("--pipeline requires --material-backend qwen-image")
176
+ if args.material_backend == "qwen-image" and (args.firered_base or any(material_paths)):
177
+ parser.error("FireRed options require --material-backend firered")
178
+ try:
179
+ # Reject explicit argument/input errors before fetching any metadata.
180
+ _validate_inference_options(args)
181
+ if args.pipeline:
182
+ overrides = _pipeline_overrides(args)
183
+ _, _, _, has_alpha = _validate_input_output(args, require_rmbg=False)
184
+ from scripts.prepare_models import _pipeline
185
+ manifest, descriptor = _pipeline.resolve_pipeline(
186
+ args.pipeline, revision=args.pipeline_revision, cache_dir=args.cache_dir,
187
+ local_files_only=args.local_files_only)
188
+ defaults = manifest["defaults"]
189
+ else:
190
+ defaults = inference_defaults()
191
+ for key, value in defaults.items():
192
+ if getattr(args, key) is None:
193
+ setattr(args, key, value)
194
+ # Release defaults can introduce a dual-view conflict; check before downloads.
195
+ _validate_inference_options(args)
196
+ if args.pipeline:
197
+ prepared = _prepare_pipeline(args, overrides, descriptor, has_alpha=has_alpha)
198
+ except (OSError, ValueError, RuntimeError) as error:
199
+ parser.error(str(error))
200
+ if args.pipeline:
201
+ explicit_pretrained = args.pretrained is not None
202
+ direct_overrides = {"geometry_package", "qwen_runtime", "qwen_deps", "model_config", "checkpoint"}
203
+ for key in MODEL_KEYS:
204
+ if prepared.get(key) is not None and (key not in direct_overrides or getattr(args, key) is None):
205
+ if explicit_pretrained and key in ("model_config", "checkpoint"):
206
+ continue
207
+ action = parser._option_string_actions["--" + key.replace("_", "-")]
208
+ value = prepared[key]
209
+ setattr(args, key, action.type(value) if action.type else value)
210
+ args.local_files_only = True
211
+ args._pipeline_config = prepared
212
+ if not all((args.moge_model, args.geometry_package, args.geometry_model)):
213
+ parser.error("provide --pipeline or all of --moge-model, --geometry-package, and --geometry-model")
214
+ if args.material_backend == "qwen-image":
215
+ args.qwen_runtime = args.qwen_runtime or _pipeline_config().runtime_root(Path(__file__).resolve().parent) / "qwen"
216
+ args.material_base = args.material_base or str(Path(__file__).resolve().parent / "ckpts/qwen-base")
217
+ args.material_model = args.material_model or str(Path(__file__).resolve().parent / "ckpts/qwen-material")
218
+ else:
219
+ if args.qwen_runtime is not None or args.qwen_deps is not None:
220
+ parser.error("--qwen-runtime and --qwen-deps require --material-backend qwen-image")
221
+ if args.material_base and args.firered_base:
222
+ parser.error("provide only one of --material-base and --firered-base")
223
+ if not (args.material_base or args.firered_base):
224
+ parser.error("FireRed requires --material-base or --firered-base")
225
+ if args.material_model and any(material_paths):
226
+ parser.error("--material-model cannot be combined with explicit material artifact paths")
227
+ if not args.material_model and not all(material_paths):
228
+ parser.error("provide --material-model or all three --material-weights, --material-spec, --material-checkpoint-manifest")
229
+ if not args.pretrained and not all((args.model_config, args.checkpoint, args.trellis_root)):
230
+ parser.error("provide --pretrained or all of --model-config, --checkpoint, and --trellis-root")
231
+ return args
232
+
233
+
234
+ def _completion_paths(args: argparse.Namespace) -> tuple[Path, Path, Path]:
235
+ """Resolve local/Hub artifacts and check missing completion files before GPU work."""
236
+ from supratex.weights import COMPLETION_ALLOW_PATTERNS, resolve_completion_artifacts, resolve_model_dir
237
+ root = None
238
+ if args.pretrained:
239
+ root = resolve_model_dir(args.pretrained, revision=args.revision, cache_dir=args.cache_dir,
240
+ local_files_only=args.local_files_only,
241
+ allow_patterns=COMPLETION_ALLOW_PATTERNS)
242
+ return resolve_completion_artifacts(root, model_config=args.model_config,
243
+ checkpoint=args.checkpoint, trellis_root=args.trellis_root)
244
+
245
+
246
+ def run(args: argparse.Namespace) -> Path:
247
+ """Run the existing image frontend, then complete and export its generated mesh."""
248
+ if getattr(args, "_pipeline_config", None) is not None:
249
+ from scripts.run_example import runtime_environment
250
+ environment = runtime_environment(args._pipeline_config, args.attention)
251
+ for key in ("ATTN_BACKEND", "SPARSE_ATTN_BACKEND"):
252
+ if key not in environment:
253
+ os.environ.pop(key, None)
254
+ os.environ.update(environment)
255
+ for source in reversed(environment["PYTHONPATH"].split(os.pathsep)):
256
+ if source not in sys.path:
257
+ sys.path.insert(0, source)
258
+ image, output, work, _ = _validate_input_output(args)
259
+ from supratex.inference.runtime import configure_inference_runtime
260
+ runtime = configure_inference_runtime(attention=getattr(args, "attention", "auto"),
261
+ low_vram=getattr(args, "low_vram", None),
262
+ material_backend=args.material_backend)
263
+ # The adapters validate source compatibility and full checkpoint contents.
264
+ from supratex.frontend.geometry import validate_source
265
+ validate_source(args.geometry_package.expanduser())
266
+ if args.material_backend == "qwen-image":
267
+ from supratex.frontend.qwen_image import validate
268
+ validate(args.qwen_runtime, args.material_base, args.material_model, deps=args.qwen_deps,
269
+ revision=args.material_revision, cache_dir=args.cache_dir, local_files_only=args.local_files_only)
270
+ elif not (args.firered_package.expanduser() / "infer.py").is_file():
271
+ raise FileNotFoundError(f"material entrypoint is missing: {args.firered_package / 'infer.py'}")
272
+ material_paths = {}
273
+ for name in ("material_weights", "material_spec", "material_checkpoint_manifest"):
274
+ path = getattr(args, name)
275
+ if path is not None:
276
+ path = path.expanduser().resolve()
277
+ if not path.is_file():
278
+ raise FileNotFoundError(f"missing {name}: {path}")
279
+ material_paths[name] = path
280
+ config, checkpoint, encoders = _completion_paths(args)
281
+ from supratex.frontend.pipeline import run as run_frontend
282
+ from supratex.inference import run_completion
283
+ work.mkdir(parents=True, exist_ok=True)
284
+ handoff = work / "input"
285
+ receipt = {"status": "running", "stage": "frontend", "input": str(image), "output": str(output),
286
+ "work_dir": str(work), "seed": args.seed, "gutter": args.gutter,
287
+ "material_backend": args.material_backend, "material_seed": 42,
288
+ "runtime": runtime,
289
+ "model_config": str(config), "checkpoint": str(checkpoint), "dual_view": args.dual_view,
290
+ "refine": args.refine, "remove_floaters": args.remove_floaters, "preview_only": args.preview_only}
291
+ def record() -> None:
292
+ temporary = work / ".run.json.tmp"
293
+ temporary.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")
294
+ temporary.replace(work / "run.json")
295
+ record()
296
+ try:
297
+ print(f"[1/2] Foreground, MoGe-2, geometry, material maps -> {handoff}", flush=True)
298
+ result = run_frontend(image, handoff, moge_model=args.moge_model, rmbg_model=args.rmbg_model,
299
+ use_input_alpha=args.use_input_alpha, resolution_level=args.resolution_level,
300
+ geometry_package=args.geometry_package.expanduser().resolve(), geometry_model=args.geometry_model,
301
+ geometry_revision=args.geometry_revision, geometry_seed=args.seed,
302
+ geometry_initial_mesh_scale=args.geometry_initial_mesh_scale,
303
+ geometry_scale_search=args.geometry_scale_search,
304
+ parallel_frontend=args.parallel_frontend,
305
+ geometry_target_padding=args.geometry_target_padding,
306
+ geometry_max_iterations=args.geometry_max_iterations,
307
+ firered_package=args.firered_package.expanduser().resolve(), firered_base=args.firered_base,
308
+ material_backend=args.material_backend, material_base=args.material_base,
309
+ qwen_runtime=args.qwen_runtime if args.material_backend == "qwen-image" else None,
310
+ qwen_deps=args.qwen_deps if args.material_backend == "qwen-image" else None,
311
+ material_model=args.material_model, **material_paths,
312
+ revision=args.material_revision, cache_dir=args.cache_dir, local_files_only=args.local_files_only,
313
+ **({"record_material_provenance": True} if args.dual_view else {}))
314
+ optional = {"refine": True, "work_dir": work / "refine"} if args.refine else {}
315
+ if args.dual_view:
316
+ receipt.update(stage="back_view", frontend=result)
317
+ record()
318
+ if args.back_view_dir is None:
319
+ from supratex.backview.workflow import build_back_view
320
+ back_dir = build_back_view(handoff, work / "back_view", cache_dir=args.cache_dir,
321
+ local_files_only=args.local_files_only)
322
+ else:
323
+ back_dir = args.back_view_dir.expanduser().resolve()
324
+ optional = {"back_view_dir": back_dir, "work_dir": work / "dual_view"}
325
+ receipt["back_view_dir"] = str(back_dir)
326
+ receipt.update(stage="completion", frontend=result)
327
+ record()
328
+ print(f"[2/2] Surface observations, PBR completion, {'o-voxel preview' if args.preview_only else 'GLB export'} -> {output}", flush=True)
329
+ run_completion(input_dir=handoff, output=output, model_config=config, checkpoint=checkpoint,
330
+ trellis_root=encoders, seed=args.seed, texture_size=args.texture_size,
331
+ decimation_target=args.decimation_target, texture_format=args.texture_format, gutter=args.gutter,
332
+ remove_floaters=args.remove_floaters, preview_only=args.preview_only, **optional)
333
+ if not output.is_file() or output.stat().st_size == 0:
334
+ raise RuntimeError(f"completion did not emit a nonempty result: {output}")
335
+ except Exception as error:
336
+ receipt.update(status="failed", error=f"{type(error).__name__}: {error}")
337
+ record()
338
+ raise
339
+ receipt.update(status="complete", stage="complete")
340
+ record()
341
+ print(f"Textured mesh: {output}\nIntermediate files and run receipt: {work}", flush=True)
342
+ return output
343
+
344
+
345
+ def main() -> None:
346
+ run(parse_args())
347
+
348
+
349
+ if __name__ == "__main__":
350
+ main()
pyproject.toml CHANGED
@@ -1,31 +1,29 @@
1
- [build-system]
2
- requires = ["setuptools>=68"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "supra-tex"
7
- version = "0.1.0"
8
- description = "Supra-Tex: super-aligned 3D texture generation"
9
- readme = "README.md"
10
- license = {file = "LICENSE"}
11
- requires-python = ">=3.10"
12
- dependencies = [
13
- "torch>=2.6", "torchvision>=0.21", "numpy", "Pillow", "scipy",
14
- "easydict", "tqdm", "einops", "safetensors", "huggingface-hub",
15
- "trimesh", "opencv-python-headless", "imageio", "imageio-ffmpeg", "numba",
16
- ]
17
-
18
- [project.optional-dependencies]
19
- training = ["pandas>=2", "transformers==4.57.3", "diffusers==0.37.1", "lpips", "plyfile", "tensorboard"]
20
- logging = ["wandb"]
21
- frontend = ["transformers==4.57.3", "diffusers==0.37.1", "accelerate", "requests", "timm"]
22
-
23
- [tool.setuptools]
24
- license-files = ["LICENSE"]
25
-
26
- [tool.setuptools.packages.find]
27
- include = ["supratex*"]
28
-
29
- [tool.setuptools.package-data]
30
- "supratex.backview.harness" = ["prompts/*.txt", "README.md"]
31
-
 
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "supra-tex"
7
+ version = "0.1.0"
8
+ description = "SUPRA-Tex: 3D Generation with Super-Aligned Texture"
9
+ readme = "README.md"
10
+ license = {file = "LICENSE"}
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "torch>=2.6", "torchvision>=0.21", "numpy", "Pillow", "scipy",
14
+ "easydict", "tqdm", "einops", "safetensors", "huggingface-hub",
15
+ "trimesh", "opencv-python-headless", "imageio", "imageio-ffmpeg", "numba",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ frontend = ["transformers==4.57.3", "diffusers==0.37.1", "accelerate", "requests", "timm"]
20
+
21
+ [tool.setuptools]
22
+ license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
23
+
24
+ [tool.setuptools.packages.find]
25
+ include = ["supratex*"]
26
+
27
+ [tool.setuptools.package-data]
28
+ "supratex.backview.harness" = ["prompts/*.txt", "README.md"]
29
+
 
 
scripts/check_runtime.py CHANGED
@@ -1,179 +1,172 @@
1
- #!/usr/bin/env python3
2
- """Check the source release's real runtime imports and prepared model files."""
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import json
7
- import os
8
- from pathlib import Path
9
- import subprocess
10
- import sys
11
-
12
- ROOT = Path(__file__).resolve().parents[1]
13
- RUNTIME = ROOT / "deployment/runtime"
14
-
15
-
16
- def runtime_environment(config: dict | None = None) -> dict[str, str]:
17
- env = os.environ.copy()
18
- env.update(PYTHONNOUSERSITE="1", PYTHONDONTWRITEBYTECODE="1", HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1",
19
- FLEX_GEMM_USE_AUTOTUNE_CACHE="0", FLEX_GEMM_AUTOSAVE_AUTOTUNE_CACHE="0",
20
- PYTHONPATH=os.pathsep.join(str(p) for p in
21
- (ROOT, RUNTIME / "moge", RUNTIME / "utils3d")))
22
- if config and config.get("torch_home"):
23
- env["TORCH_HOME"] = config["torch_home"]
24
- return env
25
-
26
-
27
- def run_import_check(code: str, *, config: dict | None = None) -> str:
28
- result = subprocess.run([sys.executable, "-c", code], cwd=ROOT,
29
- env=runtime_environment(config), capture_output=True, text=True, timeout=300)
30
- if result.returncode:
31
- raise RuntimeError((result.stderr or result.stdout)[-5000:].strip())
32
- return result.stdout.strip()
33
-
34
-
35
- def import_checks(attention: str) -> list[tuple[str, str]]:
36
- bootstrap = ("from supratex.inference.runtime import configure_inference_runtime; "
37
- f"configure_inference_runtime(attention={attention!r}, material_backend='qwen-image'); ")
38
- return [
39
- ("Python, PyTorch and CUDA", """
40
- import os, sys, torch, torchvision
41
- if sys.version_info[:2] != (3, 10):
42
- raise RuntimeError('The supported installation uses Python 3.10')
43
- profile = os.environ.get('SUPRATEX_RUNTIME_PROFILE', 'recorded')
44
- versions = {'recorded': ('2.6.0', '12.4', '0.21.0'), 'torch28': ('2.8.0', '12.8', '0.23.0')}
45
- if profile not in versions:
46
- raise RuntimeError('Unknown SUPRATEX_RUNTIME_PROFILE: ' + profile)
47
- expected_torch, expected_cuda, expected_vision = versions[profile]
48
- if torch.__version__.split('+')[0] != expected_torch or torch.version.cuda != expected_cuda:
49
- raise RuntimeError('Expected PyTorch/CUDA ' + str((expected_torch, expected_cuda)) + '; got ' + str((torch.__version__, torch.version.cuda)))
50
- if torchvision.__version__.split('+')[0] != expected_vision:
51
- raise RuntimeError('Expected torchvision ' + expected_vision)
52
- if not torch.cuda.is_available():
53
- raise RuntimeError('CUDA is unavailable; check the NVIDIA driver and GPU access')
54
- x = torch.tensor([1., 2.], device='cuda')
55
- assert (x * x).sum().item() == 5.
56
- torch.cuda.synchronize()
57
- print(torch.cuda.get_device_name(0))
58
- """),
59
- ("Native extensions, MoGe and utils3d", """
60
- import torch, natten, natten.functional
61
- import o_voxel, flex_gemm, cumesh, nvdiffrast.torch
62
- import moge.model.v2, utils3d
63
- if not (hasattr(natten.functional, 'na2d') or
64
- all(hasattr(natten.functional, n) for n in ('na2d_av', 'na2d_qk'))):
65
- raise RuntimeError('NATTEN does not expose the NAF attention API')
66
- assert hasattr(utils3d, 'pt'), 'Bundled utils3d API is required'
67
- print('Native modules and bundled camera utilities imported')
68
- """),
69
- ("Geometry and NAF implementation", bootstrap +
70
- f"import sys; sys.path.insert(0, {str(RUNTIME / 'geometry')!r}); "
71
- f"sys.path.insert(0, {str(RUNTIME / 'naf')!r}); "
72
- "from pixal3d.pipelines import Pixal3DImageTo3DPipeline; "
73
- "from pixal3d.trainers.flow_matching.mixins.image_conditioned_proj import DinoV3ProjFeatureExtractor; "
74
- "import hubconf; print('Geometry, projection conditioner and NAF imported')"),
75
- ("Qwen models, PBR pipeline and inference helpers",
76
- f"import sys; sys.path.insert(0, {str(RUNTIME / 'qwen')!r}); "
77
- f"sys.path.insert(0, {str(RUNTIME / 'qwen/examples/qwen_image_21/pbr')!r}); "
78
- "import peft; "
79
- "import diffsynth.models.qwen_image_21_dit; "
80
- "import diffsynth.models.qwen_image_21_text_encoder; "
81
- "import diffsynth.models.qwen_image_21_vae; "
82
- "import diffsynth.pipelines.qwen_image_21; "
83
- "import diffsynth.pipelines.qwen_image_21_pbr; "
84
- "from train import build_model, decode_pair, endpoint_noise; "
85
- "from validate_local import checkpoint_component, explicit_load; "
86
- "from encode_cache import PROMPT; "
87
- "import inference_offload; print('Actual Qwen model and helper imports passed')"),
88
- ("Completion implementation", bootstrap +
89
- "from supratex.inference import run_completion; "
90
- "from supratex.models import InterleavedFlowTransformer; "
91
- "print('Completion implementation imported')"),
92
- ]
93
-
94
-
95
- def require_nonempty(*paths: Path) -> None:
96
- for path in paths:
97
- if not path.is_file() or path.stat().st_size == 0:
98
- raise FileNotFoundError(f"Required nonempty file is missing: {path}")
99
-
100
-
101
- def file_checks(config: dict) -> list[tuple[str, object]]:
102
- sys.path.insert(0, str(ROOT))
103
- from supratex.frontend.geometry import validate_source, validate_model
104
- from supratex.frontend.qwen_image import validate
105
- from supratex.weights import resolve_completion_artifacts
106
-
107
- def completion():
108
- resolve_completion_artifacts(Path(config["pretrained"]),
109
- model_config=config.get("model_config"),
110
- checkpoint=config.get("checkpoint"),
111
- trellis_root=Path(config["trellis_root"]))
112
-
113
- moge = Path(config["moge_model"])
114
- checks = [
115
- ("Bundled geometry source", lambda: validate_source(Path(config["geometry_package"]))),
116
- ("Geometry and DINO model files", lambda: validate_model(
117
- Path(config["geometry_package"]), Path(config["geometry_model"]))),
118
- ("Qwen base and released material adapter", lambda: validate(
119
- Path(config["qwen_runtime"]), config["material_base"], config["material_model"],
120
- deps=Path(config["qwen_deps"]), local_files_only=True)),
121
- ("Released completion and four sparse VAEs", completion),
122
- ("MoGe weights", lambda: require_nonempty(moge / "model.pt" if moge.is_dir() else moge)),
123
- ("NAF local source and weights", lambda: require_nonempty(
124
- Path(config["torch_home"]) / "hub/valeoai_NAF_main/hubconf.py",
125
- Path(config["torch_home"]) / "hub/checkpoints/naf_release.pth")),
126
- ]
127
- if config.get("rmbg_model"):
128
- checks.append(("RGB foreground model", lambda: require_nonempty(*(
129
- Path(config["rmbg_model"]) / name for name in
130
- ("config.json", "model.safetensors", "BiRefNet_config.py", "birefnet.py", "preprocessor_config.json")))))
131
- return checks
132
-
133
-
134
- def main(argv: list[str] | None = None) -> int:
135
- parser = argparse.ArgumentParser(description=__doc__)
136
- parser.add_argument("--config", type=Path, default=ROOT / "ckpts/ready/models.json")
137
- scope = parser.add_mutually_exclusive_group()
138
- scope.add_argument("--imports-only", action="store_true", help="check installed software without model weights")
139
- scope.add_argument("--files-only", action="store_true", help="validate model layout and headers without CUDA checks")
140
- parser.add_argument("--attention", choices=("auto", "sdpa", "flash_attn", "flash_attn_3", "xformers"), default="sdpa")
141
- args = parser.parse_args(argv)
142
- results = []
143
-
144
- def check(name, operation):
145
- print(f"[check] {name}", file=sys.stderr, flush=True)
146
- try:
147
- detail = operation()
148
- entry = {"check": name, "status": "passed"}
149
- if isinstance(detail, str) and detail:
150
- entry["detail"] = detail
151
- results.append(entry)
152
- except Exception as error:
153
- results.append({"check": name, "status": "failed", "error": str(error)})
154
-
155
- config = None
156
- if not args.imports_only:
157
- try:
158
- sys.path.insert(0, str(Path(__file__).resolve().parent))
159
- from run_example import read_config
160
- config = read_config(args.config)
161
- os.environ.update(HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1")
162
- os.environ["TORCH_HOME"] = config["torch_home"]
163
- checks = file_checks(config)
164
- except Exception as error:
165
- results.append({"check": "Prepared model configuration", "status": "failed", "error": str(error)})
166
- else:
167
- for name, operation in checks:
168
- check(name, operation)
169
- if not args.files_only:
170
- for name, code in import_checks(args.attention):
171
- check(name, lambda code=code: run_import_check(code, config=config))
172
- passed = bool(results) and all(entry["status"] == "passed" for entry in results)
173
- print(json.dumps({"status": "passed" if passed else "failed", "checks": results,
174
- "scope": "Model files and actual runtime imports; full image-to-GLB inference is a separate check"}, indent=2))
175
- return 0 if passed else 1
176
-
177
-
178
- if __name__ == "__main__":
179
- raise SystemExit(main())
 
1
+ #!/usr/bin/env python3
2
+ """Check the source release's real runtime imports and prepared model files."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import subprocess
10
+ import sys
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ RUNTIME = ROOT / "deployment/runtime"
14
+
15
+
16
+ def runtime_environment(config: dict | None = None) -> dict[str, str]:
17
+ env = os.environ.copy()
18
+ env.update(PYTHONNOUSERSITE="1", PYTHONDONTWRITEBYTECODE="1", HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1",
19
+ FLEX_GEMM_USE_AUTOTUNE_CACHE="0", FLEX_GEMM_AUTOSAVE_AUTOTUNE_CACHE="0",
20
+ PYTHONPATH=os.pathsep.join(str(p) for p in
21
+ (ROOT, RUNTIME / "moge", RUNTIME / "utils3d")))
22
+ if config and config.get("torch_home"):
23
+ env["TORCH_HOME"] = config["torch_home"]
24
+ return env
25
+
26
+
27
+ def run_import_check(code: str, *, config: dict | None = None) -> str:
28
+ result = subprocess.run([sys.executable, "-c", code], cwd=ROOT,
29
+ env=runtime_environment(config), capture_output=True, text=True, timeout=300)
30
+ if result.returncode:
31
+ raise RuntimeError((result.stderr or result.stdout)[-5000:].strip())
32
+ return result.stdout.strip()
33
+
34
+
35
+ def import_checks(attention: str) -> list[tuple[str, str]]:
36
+ bootstrap = ("from supratex.inference.runtime import configure_inference_runtime; "
37
+ f"configure_inference_runtime(attention={attention!r}, material_backend='qwen-image'); ")
38
+ return [
39
+ ("Python, PyTorch and CUDA", """
40
+ import sys, torch, torchvision
41
+ if sys.version_info[:2] != (3, 10):
42
+ raise RuntimeError('The supported installation uses Python 3.10')
43
+ if torch.__version__.split('+')[0] != '2.6.0' or torch.version.cuda != '12.4':
44
+ raise RuntimeError('Expected PyTorch 2.6.0 with CUDA 12.4; got ' + str((torch.__version__, torch.version.cuda)))
45
+ if torchvision.__version__.split('+')[0] != '0.21.0':
46
+ raise RuntimeError('Expected torchvision 0.21.0')
47
+ if not torch.cuda.is_available():
48
+ raise RuntimeError('CUDA is unavailable; check the NVIDIA driver and GPU access')
49
+ x = torch.tensor([1., 2.], device='cuda')
50
+ assert (x * x).sum().item() == 5.
51
+ torch.cuda.synchronize()
52
+ print(torch.cuda.get_device_name(0))
53
+ """),
54
+ ("Native extensions, MoGe and utils3d", """
55
+ import torch, natten, natten.functional
56
+ import o_voxel, flex_gemm, cumesh, nvdiffrast.torch
57
+ import moge.model.v2, utils3d
58
+ if not (hasattr(natten.functional, 'na2d') or
59
+ all(hasattr(natten.functional, n) for n in ('na2d_av', 'na2d_qk'))):
60
+ raise RuntimeError('NATTEN does not expose the NAF attention API')
61
+ assert hasattr(utils3d, 'pt'), 'Bundled utils3d API is required'
62
+ print('Native modules and bundled camera utilities imported')
63
+ """),
64
+ ("Geometry and NAF implementation", bootstrap +
65
+ f"import sys; sys.path.insert(0, {str(RUNTIME / 'geometry')!r}); "
66
+ f"sys.path.insert(0, {str(RUNTIME / 'naf')!r}); "
67
+ "from pixal3d.pipelines import Pixal3DImageTo3DPipeline; "
68
+ "from pixal3d.modules.projected_image_feature_extractor import DinoV3ProjFeatureExtractor; "
69
+ "import hubconf; print('Geometry, projection conditioner and NAF imported')"),
70
+ ("Qwen models, PBR pipeline and inference helpers",
71
+ f"import sys; sys.path.insert(0, {str(RUNTIME / 'qwen')!r}); "
72
+ f"sys.path.insert(0, {str(RUNTIME / 'qwen/examples/qwen_image_21/pbr')!r}); "
73
+ "import peft; "
74
+ "import diffsynth.models.qwen_image_21_dit; "
75
+ "import diffsynth.models.qwen_image_21_text_encoder; "
76
+ "import diffsynth.models.qwen_image_21_vae; "
77
+ "import diffsynth.pipelines.qwen_image_21; "
78
+ "import diffsynth.pipelines.qwen_image_21_pbr; "
79
+ "from inference_utils import build_model, decode_pair, endpoint_noise, checkpoint_component, explicit_load, PROMPT; "
80
+ "import inference_offload; print('Actual Qwen model and helper imports passed')"),
81
+ ("Completion implementation", bootstrap +
82
+ "from supratex.inference import run_completion; "
83
+ "from supratex.models import InterleavedFlowTransformer; "
84
+ "print('Completion implementation imported')"),
85
+ ]
86
+
87
+
88
+ def require_nonempty(*paths: Path) -> None:
89
+ for path in paths:
90
+ if not path.is_file() or path.stat().st_size == 0:
91
+ raise FileNotFoundError(f"Required nonempty file is missing: {path}")
92
+
93
+
94
+ def file_checks(config: dict) -> list[tuple[str, object]]:
95
+ sys.path.insert(0, str(ROOT))
96
+ from supratex.frontend.geometry import validate_source, validate_model
97
+ from supratex.frontend.qwen_image import validate
98
+ from supratex.weights import resolve_completion_artifacts
99
+
100
+ def completion():
101
+ resolve_completion_artifacts(Path(config["pretrained"]),
102
+ model_config=config.get("model_config"),
103
+ checkpoint=config.get("checkpoint"),
104
+ trellis_root=Path(config["trellis_root"]))
105
+
106
+ moge = Path(config["moge_model"])
107
+ checks = [
108
+ ("Bundled geometry source", lambda: validate_source(Path(config["geometry_package"]))),
109
+ ("Geometry and DINO model files", lambda: validate_model(
110
+ Path(config["geometry_package"]), Path(config["geometry_model"]))),
111
+ ("Qwen base and released material adapter", lambda: validate(
112
+ Path(config["qwen_runtime"]), config["material_base"], config["material_model"],
113
+ deps=Path(config["qwen_deps"]) if config.get("qwen_deps") else None, local_files_only=True)),
114
+ ("Released completion and four sparse VAEs", completion),
115
+ ("MoGe weights", lambda: require_nonempty(moge / "model.pt" if moge.is_dir() else moge)),
116
+ ("NAF local source and weights", lambda: require_nonempty(
117
+ Path(config["torch_home"]) / "hub/valeoai_NAF_main/hubconf.py",
118
+ Path(config["torch_home"]) / "hub/checkpoints/naf_release.pth")),
119
+ ]
120
+ if config.get("rmbg_model"):
121
+ checks.append(("RGB foreground model", lambda: require_nonempty(*(
122
+ Path(config["rmbg_model"]) / name for name in
123
+ ("config.json", "model.safetensors", "BiRefNet_config.py", "birefnet.py")))))
124
+ return checks
125
+
126
+
127
+ def main(argv: list[str] | None = None) -> int:
128
+ parser = argparse.ArgumentParser(description=__doc__)
129
+ parser.add_argument("--config", type=Path, default=ROOT / "ckpts/ready/models.json")
130
+ scope = parser.add_mutually_exclusive_group()
131
+ scope.add_argument("--imports-only", action="store_true", help="check installed software without model weights")
132
+ scope.add_argument("--files-only", action="store_true", help="validate model layout and headers without CUDA checks")
133
+ parser.add_argument("--attention", choices=("auto", "sdpa", "flash_attn", "flash_attn_3", "xformers"), default="sdpa")
134
+ args = parser.parse_args(argv)
135
+ results = []
136
+
137
+ def check(name, operation):
138
+ print(f"[check] {name}", file=sys.stderr, flush=True)
139
+ try:
140
+ detail = operation()
141
+ entry = {"check": name, "status": "passed"}
142
+ if isinstance(detail, str) and detail:
143
+ entry["detail"] = detail
144
+ results.append(entry)
145
+ except Exception as error:
146
+ results.append({"check": name, "status": "failed", "error": str(error)})
147
+
148
+ config = None
149
+ if not args.imports_only:
150
+ try:
151
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
152
+ from run_example import read_config
153
+ config = read_config(args.config)
154
+ os.environ.update(HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1")
155
+ os.environ["TORCH_HOME"] = config["torch_home"]
156
+ checks = file_checks(config)
157
+ except Exception as error:
158
+ results.append({"check": "Prepared model configuration", "status": "failed", "error": str(error)})
159
+ else:
160
+ for name, operation in checks:
161
+ check(name, operation)
162
+ if not args.files_only:
163
+ for name, code in import_checks(args.attention):
164
+ check(name, lambda code=code: run_import_check(code, config=config))
165
+ passed = bool(results) and all(entry["status"] == "passed" for entry in results)
166
+ print(json.dumps({"status": "passed" if passed else "failed", "checks": results,
167
+ "scope": "Model files and actual runtime imports; full image-to-GLB inference is a separate check"}, indent=2))
168
+ return 0 if passed else 1
169
+
170
+
171
+ if __name__ == "__main__":
172
+ raise SystemExit(main())
 
 
 
 
 
 
 
scripts/convert_geometry_weights.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Write runtime-dtype copies of the prepared native geometry weights.
3
+
4
+ The native flow checkpoints store float32 for blocks that run in bfloat16.
5
+ A cast copy halves the bytes read on every load (faster on slow disks) and
6
+ loads bit-identical tensors. Copies are written beside the originals as
7
+ ``<stem>.runtime.safetensors`` (about 8 GB) and are used automatically.
8
+ Run it in the inference environment with a GPU visible; no GPU memory is used.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ from pathlib import Path
15
+ import sys
16
+
17
+ ROOT = Path(__file__).resolve().parents[1]
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ parser = argparse.ArgumentParser(description=__doc__)
22
+ parser.add_argument("--config", type=Path, default=ROOT / "ckpts/ready/models.json",
23
+ help="prepared models.json (default: ckpts/ready/models.json)")
24
+ args = parser.parse_args(argv)
25
+ config_path = args.config.expanduser().resolve()
26
+ config = json.loads(config_path.read_text(encoding="utf-8"))
27
+ package = (config_path.parent / config["geometry_package"]).resolve()
28
+ model_root = (config_path.parent / config["geometry_model"]).resolve()
29
+ sys.path[:0] = [str(package), str(ROOT)]
30
+ from supratex.frontend.geometry import NATIVE_GEOMETRY_MODELS
31
+ from supratex.frontend.geometry_loading import write_runtime_weights
32
+
33
+ stems = json.loads((model_root / "pipeline.json").read_text(encoding="utf-8"))["args"]["models"]
34
+ for name in NATIVE_GEOMETRY_MODELS:
35
+ written = write_runtime_weights(model_root / stems[name])
36
+ print(f"{name}: {written or 'already compact; skipped'}", flush=True)
37
+ return 0
38
+
39
+
40
+ if __name__ == "__main__":
41
+ raise SystemExit(main())
scripts/install_runtime.py CHANGED
@@ -1,276 +1,356 @@
1
- #!/usr/bin/env python3
2
- """Install the recorded Linux/Python 3.10/CUDA 12.4 runtime into its own venv.
3
-
4
- No model weights are downloaded. Native sources come from the recorded public
5
- revisions in deployment/docs/DEPENDENCIES.md. Run --dry-run to inspect commands
6
- without creating directories, invoking subprocesses, or accessing the network.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import argparse
12
- import json
13
- import os
14
- from pathlib import Path
15
- import platform
16
- import re
17
- import shlex
18
- import shutil
19
- import subprocess
20
- import sys
21
-
22
-
23
- ROOT = Path(__file__).resolve().parents[1]
24
- SOURCES = (
25
- ("CuMesh", "https://github.com/JeffreyXiang/CuMesh.git", "ef40f3ae4612ea3ec934b4151e019dc95c5d276b", ""),
26
- ("FlexGEMM", "https://github.com/JeffreyXiang/FlexGEMM.git", "b1965ad0ae4bc8488d13b5280afb54439ce27359", ""),
27
- ("nvdiffrast", "https://github.com/NVlabs/nvdiffrast.git", "253ac4fcea7de5f396371124af597e6cc957bfae", ""),
28
- ("TRELLIS.2", "https://github.com/microsoft/TRELLIS.2.git", "5565d240c4a494caaf9ece7a554542b76ffa36d3", "o-voxel"),
29
- )
30
- O_VOXEL_FLAGS = " ".join((
31
- "--extended-lambda", "-U__CUDA_NO_HALF_OPERATORS__",
32
- "-U__CUDA_NO_HALF_CONVERSIONS__", "-U__CUDA_NO_HALF2_OPERATORS__",
33
- ))
34
- CONSTRAINTS = "torch==2.6.0+cu124\ntorchvision==0.21.0+cu124\n"
35
- BUILD_TOOLS = ("pip==25.1.1", "setuptools==80.9.0", "wheel==0.45.1", "packaging==25.0")
36
- # Official https://whl.natten.org/ index links this exact 0.17.5 release asset.
37
- # The former shi-labs.com wheel index has an expired TLS certificate.
38
- NATTEN_WHEEL = (
39
- "https://github.com/SHI-Labs/NATTEN/releases/download/v0.17.5/"
40
- "natten-0.17.5%2Btorch260cu124-cp310-cp310-linux_x86_64.whl"
41
- )
42
- NATTEN_VERSION = "0.17.5+torch260cu124"
43
- PYTHON_PROBE = (
44
- "import json,sys; import venv,ensurepip; "
45
- "print(json.dumps({'version':list(sys.version_info[:2]),"
46
- "'prefix':sys.prefix,'base_prefix':sys.base_prefix}))"
47
- )
48
- TORCH_PROBE = (
49
- "import torch,torchvision; "
50
- "assert torch.__version__ == '2.6.0+cu124', torch.__version__; "
51
- "assert torchvision.__version__ == '0.21.0+cu124', torchvision.__version__; "
52
- "assert torch.version.cuda == '12.4', torch.version.cuda; "
53
- "assert torch.cuda.is_available(), 'No usable NVIDIA CUDA GPU/driver'; "
54
- "torch.empty(1, device='cuda'); torch.cuda.synchronize(); "
55
- "print('Torch 2.6.0 / CUDA 12.4 GPU check passed')"
56
- )
57
-
58
-
59
- class InstallError(RuntimeError):
60
- """An actionable installation failure, with no automatic destructive repair."""
61
-
62
-
63
- def clean_environment(venv: Path) -> dict[str, str]:
64
- """Do not inherit Python overlays or pip destinations from the invoking shell."""
65
- env = {
66
- key: value for key, value in os.environ.items()
67
- if not key.startswith("PIP_") and key not in {
68
- "PYTHONPATH", "PYTHONHOME", "PYTHONUSERBASE", "VIRTUAL_ENV",
69
- "CONDA_PREFIX", "CONDA_DEFAULT_ENV",
70
- }
71
- }
72
- env.update({
73
- "PIP_CONFIG_FILE": os.devnull,
74
- "PIP_REQUIRE_VIRTUALENV": "true",
75
- "PIP_DISABLE_PIP_VERSION_CHECK": "1",
76
- "PIP_CACHE_DIR": str(venv / ".cache" / "pip"),
77
- "TORCH_EXTENSIONS_DIR": str(venv / ".cache" / "torch_extensions"),
78
- "ATTN_BACKEND": "sdpa",
79
- "SPARSE_ATTN_BACKEND": "sdpa",
80
- "BUILD_TARGET": "cuda",
81
- })
82
- return env
83
-
84
-
85
- def capture(command: list[str], env: dict[str, str]) -> str:
86
- try:
87
- return subprocess.check_output(command, env=env, text=True, stderr=subprocess.STDOUT).strip()
88
- except (OSError, subprocess.CalledProcessError) as exc:
89
- detail = getattr(exc, "output", str(exc))
90
- raise InstallError(f"Command failed: {shlex.join(command)}\n{detail}") from exc
91
-
92
-
93
- def validate_venv_destination(venv: Path) -> None:
94
- if venv in {Path(sys.prefix).resolve(), Path(sys.base_prefix).resolve()}:
95
- raise InstallError("--venv cannot be the invoking Python environment. Choose a separate directory.")
96
- if not venv.exists():
97
- return
98
- config = venv / "pyvenv.cfg"
99
- if not config.is_file() or not (venv / "bin" / "python").is_file():
100
- raise InstallError(f"Refusing to overwrite {venv}: existing destination is not a complete venv. Choose a new --venv.")
101
- settings = dict(
102
- line.lower().strip().split("=", 1)
103
- for line in config.read_text().splitlines() if "=" in line
104
- )
105
- settings = {key.strip(): value.strip() for key, value in settings.items()}
106
- if settings.get("include-system-site-packages") != "false":
107
- raise InstallError(f"{venv} must have include-system-site-packages = false. Choose a new --venv.")
108
-
109
-
110
- def prerequisites(python: str, venv: Path, env: dict[str, str]) -> dict[str, str]:
111
- """Validate before any directory creation, download, or package installation."""
112
- if platform.system() != "Linux":
113
- raise InstallError("This recorded native runtime requires Linux.")
114
- if platform.machine().lower() not in {"x86_64", "amd64"}:
115
- raise InstallError("The pinned NATTEN 0.17.5 wheel requires Linux x86_64.")
116
- validate_venv_destination(venv)
117
- try:
118
- info = json.loads(capture([python, "-I", "-c", PYTHON_PROBE], env))
119
- except (ValueError, KeyError) as exc:
120
- raise InstallError("Could not determine the selected Python version.") from exc
121
- if info.get("version") != [3, 10]:
122
- raise InstallError(f"Python 3.10 is required; {python} reports {info.get('version')}. Use --python /path/to/python3.10.")
123
- if Path(info["prefix"]).resolve() == venv:
124
- raise InstallError("--venv cannot be the selected --python environment. Choose a separate directory.")
125
- if not shutil.which("git", path=env.get("PATH")):
126
- raise InstallError("git is missing from PATH; install git before rerunning.")
127
- compiler = shlex.split(env.get("CXX", "c++"))
128
- if not compiler or not shutil.which(compiler[0], path=env.get("PATH")):
129
- raise InstallError("A C++ compiler is required; install g++/c++ or set CXX to a working compiler.")
130
- capture([*compiler, "--version"], env)
131
- cuda_home = env.get("CUDA_HOME") or env.get("CUDA_PATH")
132
- nvcc = str(Path(cuda_home).expanduser() / "bin" / "nvcc") if cuda_home else shutil.which("nvcc", path=env.get("PATH"))
133
- if not nvcc or not Path(nvcc).is_file():
134
- raise InstallError("CUDA 12.4 nvcc is required. Install the CUDA toolkit and set CUDA_HOME or add its bin directory to PATH.")
135
- version = capture([nvcc, "--version"], env)
136
- if not re.search(r"release\s+12\.4(?:\D|$)", version):
137
- raise InstallError(f"CUDA toolkit 12.4 is required; {nvcc} reports:\n{version}")
138
- result = env.copy()
139
- result["CUDA_HOME"] = str(Path(cuda_home).expanduser().resolve() if cuda_home else Path(nvcc).resolve().parent.parent)
140
- result["PATH"] = os.pathsep.join((str(venv / "bin"), str(Path(result["CUDA_HOME"]) / "bin"), env.get("PATH", "")))
141
- return result
142
-
143
-
144
- def validate_checkout(path: Path, url: str, revision: str, env: dict[str, str]) -> None:
145
- """Reuse only the exact, clean checkout; never reset or clean user sources."""
146
- if not (path / ".git").exists():
147
- raise InstallError(f"Refusing to overwrite {path}: not a Git checkout. Choose a fresh --venv.")
148
- git = ["git", "-C", str(path)]
149
- top = capture([*git, "rev-parse", "--show-toplevel"], env)
150
- origin = capture([*git, "remote", "get-url", "origin"], env)
151
- head = capture([*git, "rev-parse", "HEAD"], env)
152
- dirty = capture([*git, "status", "--porcelain", "--untracked-files=no"], env)
153
- if Path(top).resolve() != path.resolve() or origin.rstrip("/") != url or head != revision or dirty:
154
- raise InstallError(
155
- f"Refusing to alter existing checkout {path}: expected origin {url}, HEAD {revision}, "
156
- "and no tracked changes (including submodules). Repair it manually or choose a fresh --venv."
157
- )
158
-
159
-
160
- def run(command: list[str], env: dict[str, str], dry_run: bool, extra_env: dict[str, str] | None = None) -> None:
161
- display = ["env", *(f"{key}={value}" for key, value in extra_env.items()), *command] if extra_env else command
162
- print("$ " + shlex.join(display), flush=True)
163
- if not dry_run:
164
- child_env = dict(env, **(extra_env or {}))
165
- try:
166
- subprocess.run(command, env=child_env, check=True)
167
- except (OSError, subprocess.CalledProcessError) as exc:
168
- raise InstallError(f"Installation stopped at: {shlex.join(command)}\n{exc}\nExisting files were kept; no rollback or deletion was attempted.") from exc
169
-
170
-
171
- def install_natten(pip: list[str], env: dict[str, str], dry_run: bool) -> None:
172
- # A direct wheel URL otherwise downloads again on each resumed installation.
173
- # Query only the target venv; the final import checks still verify its ABI.
174
- if dry_run:
175
- print(f"# Skip NATTEN download only if this venv already has distribution version {NATTEN_VERSION}; final import checks still run.")
176
- else:
177
- probe = (
178
- "from importlib.metadata import version, PackageNotFoundError\n"
179
- "try:\n print(version('natten'))\n"
180
- "except PackageNotFoundError:\n print('')\n"
181
- )
182
- installed = capture([pip[0], "-I", "-c", probe], env)
183
- if installed == NATTEN_VERSION:
184
- print(f"# NATTEN {NATTEN_VERSION} already installed in target venv; skipping download.")
185
- return
186
- run([*pip, "--no-deps", "--no-index", "--only-binary=:all:", NATTEN_WHEEL], env, dry_run)
187
-
188
-
189
- def install(args: argparse.Namespace) -> None:
190
- venv = Path(args.venv).expanduser().resolve()
191
- python = shutil.which(args.python) or str(Path(args.python).expanduser().absolute())
192
- env = clean_environment(venv)
193
- managed = venv / "supra-tex-runtime"
194
- sources = managed / "sources"
195
- constraints = managed / "constraints.txt"
196
- target_python = str(venv / "bin" / "python")
197
- pip = [target_python, "-I", "-m", "pip", "install", "--no-input"]
198
- dry = args.dry_run
199
- print(f"{'DRY RUN: no subprocesses, downloads, or writes.' if dry else 'Installing runtime.'}\nSource: {ROOT}\nVirtual environment: {venv}")
200
- print("Prerequisites: Linux x86_64; selected Python 3.10 with venv/ensurepip; git; C++ compiler; CUDA toolkit 12.4 nvcc; usable NVIDIA GPU/driver.")
201
- print("Child processes ignore inherited Python overlays and pip configuration/destinations; caches stay in the venv. Default attention is SDPA; FlashAttention is optional.")
202
- print("Native source pins identify upstream inputs, not verified equivalent binaries. FlexGEMM upstream setup also writes ~/.flex_gemm/autotune_cache.json.")
203
- if not dry:
204
- for required in (ROOT / "deployment/runtime/requirements-python.txt", ROOT / "deployment/runtime/moge/pyproject.toml", ROOT / "deployment/runtime/utils3d/pyproject.toml", ROOT / "scripts/check_runtime.py"):
205
- if not required.is_file():
206
- raise InstallError(f"Incomplete source release: missing {required}")
207
- env = prerequisites(python, venv, env)
208
- # Check all existing source trees before changing this environment.
209
- for name, url, revision, _ in SOURCES:
210
- path = sources / name
211
- if path.exists():
212
- validate_checkout(path, url, revision, env)
213
- if not venv.exists():
214
- run([python, "-I", "-m", "venv", str(venv)], env, dry)
215
- else:
216
- print("# Reuse only an isolated Python 3.10 venv; never clear an existing directory.")
217
- if not dry:
218
- info = json.loads(capture([target_python, "-I", "-c", PYTHON_PROBE], env))
219
- if info.get("version") != [3, 10] or Path(info["prefix"]).resolve() != venv or info["prefix"] == info["base_prefix"]:
220
- raise InstallError(f"{venv} is not an isolated Python 3.10 environment. Choose a new --venv.")
221
- managed.mkdir(parents=True, exist_ok=True)
222
- sources.mkdir(exist_ok=True)
223
- constraints.write_text(CONSTRAINTS)
224
- print(f"# Write {constraints}: torch==2.6.0+cu124; torchvision==0.21.0+cu124")
225
- run([*pip, *BUILD_TOOLS], env, dry)
226
- run([*pip, "torch==2.6.0+cu124", "torchvision==0.21.0+cu124", "--index-url", "https://download.pytorch.org/whl/cu124"], env, dry)
227
- run([*pip, "--constraint", str(constraints), "-r", str(ROOT / "deployment/runtime/requirements-python.txt")], env, dry)
228
- run([*pip, "--no-build-isolation", "--no-deps", str(ROOT / "deployment/runtime/moge"), str(ROOT / "deployment/runtime/utils3d")], env, dry)
229
- run([*pip, "--no-build-isolation", "--no-deps", "-e", str(ROOT)], env, dry)
230
- install_natten(pip, env, dry)
231
- run([target_python, "-I", "-c", TORCH_PROBE], env, dry)
232
- for name, url, revision, subdir in SOURCES:
233
- path = sources / name
234
- if not path.exists():
235
- run(["git", "clone", "--no-checkout", url, str(path)], env, dry)
236
- run(["git", "-C", str(path), "checkout", "--detach", revision], env, dry)
237
- else:
238
- print(f"# Verify origin={url}, HEAD={revision}, and clean tracked files: {path}; no reset/clean.")
239
- run(["git", "-C", str(path), "submodule", "update", "--init", "--recursive"], env, dry)
240
- flags = None
241
- if name == "CuMesh":
242
- print("# CuMesh/cubvh: append --extended-lambda for its CUDA device lambdas without patching source.")
243
- flags = {"NVCC_APPEND_FLAGS": " ".join(filter(None, (env.get("NVCC_APPEND_FLAGS"), "--extended-lambda")))}
244
- elif subdir == "o-voxel":
245
- print("# o-voxel: NVCC_APPEND_FLAGS appends the recorded extended-lambda/half flags without patching source. This build still requires destination validation.")
246
- flags = {"NVCC_APPEND_FLAGS": " ".join(filter(None, (env.get("NVCC_APPEND_FLAGS"), O_VOXEL_FLAGS)))}
247
- run([*pip, "--no-build-isolation", "--no-deps", str(path / subdir)], env, dry, flags)
248
- if args.flash_attn:
249
- run([*pip, "--no-build-isolation", "--no-deps", "flash-attn==2.8.3"], env, dry)
250
- run([target_python, "-I", "-c", "import flash_attn; print(flash_attn.__version__)"], env, dry)
251
- run([target_python, "-I", "-m", "pip", "check"], env, dry)
252
- run([target_python, "-I", "-c", TORCH_PROBE], env, dry)
253
- run([target_python, "-I", str(ROOT / "scripts/check_runtime.py"), "--imports-only"], env, dry)
254
- if dry:
255
- print("Dry run complete. Prerequisites, builds, and imports have NOT been validated.")
256
- else:
257
- print(f"Runtime dependency checks passed in {venv}. Model acquisition and an image-to-GLB run are still required for inference acceptance. Use --attention sdpa for the default runtime.")
258
-
259
-
260
- def main(argv: list[str] | None = None) -> int:
261
- parser = argparse.ArgumentParser(description=__doc__)
262
- parser.add_argument("--venv", default=".venv", help="Isolated environment directory (default: .venv relative to the current directory)")
263
- parser.add_argument("--python", default=sys.executable, help="Python 3.10 executable used to create the venv (default: invoking Python)")
264
- parser.add_argument("--dry-run", action="store_true", help="Print the complete plan without subprocesses, network, or filesystem writes")
265
- parser.add_argument("--flash-attn", action="store_true", help="Also build optional FlashAttention 2.8.3; default SDPA needs neither FA2 nor FA3")
266
- args = parser.parse_args(argv)
267
- try:
268
- install(args)
269
- except (InstallError, OSError, ValueError) as exc:
270
- print(f"ERROR: {exc}", file=sys.stderr)
271
- return 1
272
- return 0
273
-
274
-
275
- if __name__ == "__main__":
276
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Install the recorded Linux/Python 3.10/CUDA 12.4 runtime into Conda or a venv.
3
+
4
+ No model weights are downloaded. Native sources come from the recorded public
5
+ revisions in deployment/docs/DEPENDENCIES.md. Run --dry-run to inspect commands
6
+ without creating directories, invoking subprocesses, or accessing the network.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import platform
16
+ import re
17
+ import shlex
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+
23
+
24
+ ROOT = Path(__file__).resolve().parents[1]
25
+ SOURCES = (
26
+ ("CuMesh", "https://github.com/JeffreyXiang/CuMesh.git", "ef40f3ae4612ea3ec934b4151e019dc95c5d276b", ""),
27
+ ("FlexGEMM", "https://github.com/JeffreyXiang/FlexGEMM.git", "b1965ad0ae4bc8488d13b5280afb54439ce27359", ""),
28
+ ("nvdiffrast", "https://github.com/NVlabs/nvdiffrast.git", "253ac4fcea7de5f396371124af597e6cc957bfae", ""),
29
+ ("TRELLIS.2", "https://github.com/microsoft/TRELLIS.2.git", "5565d240c4a494caaf9ece7a554542b76ffa36d3", "o-voxel"),
30
+ )
31
+ O_VOXEL_FLAGS = " ".join((
32
+ "--extended-lambda", "-U__CUDA_NO_HALF_OPERATORS__",
33
+ "-U__CUDA_NO_HALF_CONVERSIONS__", "-U__CUDA_NO_HALF2_OPERATORS__",
34
+ ))
35
+ CONSTRAINTS = "torch==2.6.0+cu124\ntorchvision==0.21.0+cu124\n"
36
+ BUILD_TOOLS = ("pip==25.1.1", "setuptools==80.9.0", "wheel==0.45.1", "packaging==25.0")
37
+ # Official https://whl.natten.org/ index links this exact 0.17.5 release asset.
38
+ # The former shi-labs.com wheel index has an expired TLS certificate.
39
+ NATTEN_WHEEL = (
40
+ "https://github.com/SHI-Labs/NATTEN/releases/download/v0.17.5/"
41
+ "natten-0.17.5%2Btorch260cu124-cp310-cp310-linux_x86_64.whl"
42
+ )
43
+ NATTEN_VERSION = "0.17.5+torch260cu124"
44
+ PYTHON_PROBE = (
45
+ "import json,sys; "
46
+ "print(json.dumps({'version':list(sys.version_info[:2]),"
47
+ "'prefix':sys.prefix,'base_prefix':sys.base_prefix}))"
48
+ )
49
+ TORCH_PROBE = (
50
+ "import torch,torchvision; "
51
+ "assert torch.__version__ == '2.6.0+cu124', torch.__version__; "
52
+ "assert torchvision.__version__ == '0.21.0+cu124', torchvision.__version__; "
53
+ "assert torch.version.cuda == '12.4', torch.version.cuda; "
54
+ "assert torch.cuda.is_available(), 'No usable NVIDIA CUDA GPU/driver'; "
55
+ "torch.empty(1, device='cuda'); torch.cuda.synchronize(); "
56
+ "print('Torch 2.6.0 / CUDA 12.4 GPU check passed')"
57
+ )
58
+
59
+
60
+ class InstallError(RuntimeError):
61
+ """An actionable installation failure, with no automatic destructive repair."""
62
+
63
+
64
+ def clean_environment(venv: Path, *, conda: bool = False) -> dict[str, str]:
65
+ """Do not inherit Python overlays or pip destinations from the invoking shell."""
66
+ env = {
67
+ key: value for key, value in os.environ.items()
68
+ if not key.startswith("PIP_") and key not in {
69
+ "PYTHONPATH", "PYTHONHOME", "PYTHONUSERBASE", "VIRTUAL_ENV",
70
+ "CONDA_PREFIX", "CONDA_DEFAULT_ENV",
71
+ }
72
+ }
73
+ env.update({
74
+ "PIP_CONFIG_FILE": os.devnull,
75
+ "PIP_REQUIRE_VIRTUALENV": "false" if conda else "true",
76
+ "PIP_DISABLE_PIP_VERSION_CHECK": "1",
77
+ "PIP_CACHE_DIR": str(venv / ".cache" / "pip"),
78
+ "TORCH_EXTENSIONS_DIR": str(venv / ".cache" / "torch_extensions"),
79
+ "FLEX_GEMM_AUTOTUNE_CACHE_PATH": str(venv / ".cache" / "flex_gemm" / "autotune_cache.json"),
80
+ "ATTN_BACKEND": "sdpa",
81
+ "SPARSE_ATTN_BACKEND": "sdpa",
82
+ "BUILD_TARGET": "cuda",
83
+ })
84
+ if conda:
85
+ env["CONDA_PREFIX"] = str(venv)
86
+ if "CONDA_DEFAULT_ENV" in os.environ:
87
+ env["CONDA_DEFAULT_ENV"] = os.environ["CONDA_DEFAULT_ENV"]
88
+ return env
89
+
90
+
91
+ def capture(command: list[str], env: dict[str, str]) -> str:
92
+ try:
93
+ return subprocess.check_output(command, env=env, text=True, stderr=subprocess.STDOUT).strip()
94
+ except (OSError, subprocess.CalledProcessError) as exc:
95
+ detail = getattr(exc, "output", str(exc))
96
+ raise InstallError(f"Command failed: {shlex.join(command)}\n{detail}") from exc
97
+
98
+
99
+ def validate_venv_destination(venv: Path) -> None:
100
+ if venv in {Path(sys.prefix).resolve(), Path(sys.base_prefix).resolve()}:
101
+ raise InstallError("--venv cannot be the invoking Python environment. Choose a separate directory.")
102
+ if not venv.exists():
103
+ return
104
+ config = venv / "pyvenv.cfg"
105
+ if not config.is_file() or not (venv / "bin" / "python").is_file():
106
+ raise InstallError(f"Refusing to overwrite {venv}: existing destination is not a complete venv. Choose a new --venv.")
107
+ settings = dict(
108
+ line.lower().strip().split("=", 1)
109
+ for line in config.read_text().splitlines() if "=" in line
110
+ )
111
+ settings = {key.strip(): value.strip() for key, value in settings.items()}
112
+ if settings.get("include-system-site-packages") != "false":
113
+ raise InstallError(f"{venv} must have include-system-site-packages = false. Choose a new --venv.")
114
+
115
+
116
+ def active_conda_environment() -> Path:
117
+ prefix = os.environ.get("CONDA_PREFIX")
118
+ if not prefix:
119
+ raise InstallError("Activate a dedicated Conda environment first: conda activate supra-tex.")
120
+ destination = Path(prefix).expanduser().resolve()
121
+ conda_exe = os.environ.get("CONDA_EXE")
122
+ if os.environ.get("CONDA_DEFAULT_ENV") == "base" or (
123
+ conda_exe and Path(conda_exe).resolve().parent.parent == destination
124
+ ):
125
+ raise InstallError("Create and activate a dedicated Conda environment; do not install into base.")
126
+ if (Path(sys.prefix).resolve() != destination
127
+ or Path(sys.base_prefix).resolve() != destination):
128
+ raise InstallError("Run this script with the active Conda environment's Python, without a nested venv.")
129
+ if not (destination / "conda-meta").is_dir() or not (destination / "bin/python").is_file():
130
+ raise InstallError(f"{destination} is not a complete Conda Python environment.")
131
+ return destination
132
+
133
+
134
+ def validate_target_python(info: dict, destination: Path, *, conda: bool = False) -> None:
135
+ valid = info.get("version") == [3, 10] and Path(info["prefix"]).resolve() == destination
136
+ if conda:
137
+ valid = valid and Path(info["base_prefix"]).resolve() == destination
138
+ else:
139
+ valid = valid and info["prefix"] != info["base_prefix"]
140
+ if not valid:
141
+ raise InstallError(f"{destination} is not the expected Python 3.10 {'Conda environment' if conda else 'isolated venv'}.")
142
+
143
+
144
+ def prerequisites(python: str, venv: Path, env: dict[str, str], *, conda: bool = False) -> dict[str, str]:
145
+ """Validate before any directory creation, download, or package installation."""
146
+ if platform.system() != "Linux":
147
+ raise InstallError("This recorded native runtime requires Linux.")
148
+ if platform.machine().lower() not in {"x86_64", "amd64"}:
149
+ raise InstallError("The pinned NATTEN 0.17.5 wheel requires Linux x86_64.")
150
+ if not conda:
151
+ validate_venv_destination(venv)
152
+ try:
153
+ probe = PYTHON_PROBE if conda else "import venv,ensurepip; " + PYTHON_PROBE
154
+ info = json.loads(capture([python, "-I", "-c", probe], env))
155
+ except (ValueError, KeyError) as exc:
156
+ raise InstallError("Could not determine the selected Python version.") from exc
157
+ if info.get("version") != [3, 10]:
158
+ hint = "Create a Conda environment with python=3.10." if conda else "Use --python /path/to/python3.10."
159
+ raise InstallError(f"Python 3.10 is required; {python} reports {info.get('version')}. {hint}")
160
+ if conda:
161
+ validate_target_python(info, venv, conda=True)
162
+ elif Path(info["prefix"]).resolve() == venv:
163
+ raise InstallError("--venv cannot be the selected --python environment. Choose a separate directory.")
164
+ if not shutil.which("git", path=env.get("PATH")):
165
+ raise InstallError("git is missing from PATH; install git before rerunning.")
166
+ compiler = shlex.split(env.get("CXX", "c++"))
167
+ if not compiler or not shutil.which(compiler[0], path=env.get("PATH")):
168
+ raise InstallError("A C++ compiler is required; install g++/c++ or set CXX to a working compiler.")
169
+ capture([*compiler, "--version"], env)
170
+ cuda_home = env.get("CUDA_HOME") or env.get("CUDA_PATH")
171
+ nvcc = str(Path(cuda_home).expanduser() / "bin" / "nvcc") if cuda_home else shutil.which("nvcc", path=env.get("PATH"))
172
+ if not nvcc or not Path(nvcc).is_file():
173
+ raise InstallError("CUDA 12.4 nvcc is required. Install the CUDA toolkit and set CUDA_HOME or add its bin directory to PATH.")
174
+ version = capture([nvcc, "--version"], env)
175
+ if not re.search(r"release\s+12\.4(?:\D|$)", version):
176
+ raise InstallError(f"CUDA toolkit 12.4 is required; {nvcc} reports:\n{version}")
177
+ result = env.copy()
178
+ result["CUDA_HOME"] = str(Path(cuda_home).expanduser().resolve() if cuda_home else Path(nvcc).resolve().parent.parent)
179
+ result["PATH"] = os.pathsep.join((str(venv / "bin"), str(Path(result["CUDA_HOME"]) / "bin"), env.get("PATH", "")))
180
+ return result
181
+
182
+
183
+ def validate_checkout(path: Path, url: str, revision: str, env: dict[str, str]) -> None:
184
+ """Reuse only the exact, clean checkout; never reset or clean user sources."""
185
+ if not (path / ".git").exists():
186
+ raise InstallError(f"Refusing to overwrite {path}: not a Git checkout. Choose a fresh environment.")
187
+ git = ["git", "-C", str(path)]
188
+ top = capture([*git, "rev-parse", "--show-toplevel"], env)
189
+ origin = capture([*git, "remote", "get-url", "origin"], env)
190
+ head = capture([*git, "rev-parse", "HEAD"], env)
191
+ dirty = capture([*git, "status", "--porcelain", "--untracked-files=no"], env)
192
+ if Path(top).resolve() != path.resolve() or origin.rstrip("/") != url or head != revision or dirty:
193
+ raise InstallError(
194
+ f"Refusing to alter existing checkout {path}: expected origin {url}, HEAD {revision}, "
195
+ "and no tracked changes (including submodules). Repair it manually or choose a fresh environment."
196
+ )
197
+
198
+
199
+ def run(command: list[str], env: dict[str, str], dry_run: bool, extra_env: dict[str, str] | None = None) -> None:
200
+ display = ["env", *(f"{key}={value}" for key, value in extra_env.items()), *command] if extra_env else command
201
+ print("$ " + shlex.join(display), flush=True)
202
+ if not dry_run:
203
+ child_env = dict(env, **(extra_env or {}))
204
+ try:
205
+ subprocess.run(command, env=child_env, check=True)
206
+ except (OSError, subprocess.CalledProcessError) as exc:
207
+ raise InstallError(f"Installation stopped at: {shlex.join(command)}\n{exc}\nExisting files were kept; no rollback or deletion was attempted.") from exc
208
+
209
+
210
+ def install_natten(pip: list[str], env: dict[str, str], dry_run: bool) -> None:
211
+ # A direct wheel URL otherwise downloads again on each resumed installation.
212
+ # Query only the target environment; the final import checks still verify its ABI.
213
+ if dry_run:
214
+ print(f"# Skip NATTEN download only if this environment already has distribution version {NATTEN_VERSION}; final import checks still run.")
215
+ else:
216
+ probe = (
217
+ "from importlib.metadata import version, PackageNotFoundError\n"
218
+ "try:\n print(version('natten'))\n"
219
+ "except PackageNotFoundError:\n print('')\n"
220
+ )
221
+ installed = capture([pip[0], "-I", "-c", probe], env)
222
+ if installed == NATTEN_VERSION:
223
+ print(f"# NATTEN {NATTEN_VERSION} already installed in target environment; skipping download.")
224
+ return
225
+ run([*pip, "--no-deps", "--no-index", "--only-binary=:all:", NATTEN_WHEEL], env, dry_run)
226
+
227
+
228
+ def install_flex_gemm(pip: list[str], source: Path, managed: Path,
229
+ env: dict[str, str], dry_run: bool) -> None:
230
+ """Redirect the pinned setup.py cache write without altering its checkout."""
231
+ print(f"# FlexGEMM: build a temporary source copy under {managed}; "
232
+ f"install and runtime cache: {env['FLEX_GEMM_AUTOTUNE_CACHE_PATH']}.")
233
+ command = [*pip, "--no-build-isolation", "--no-deps"]
234
+ if dry_run:
235
+ run([*command, str(managed / "flex-gemm-build-<temporary>" / "FlexGEMM")], env, True)
236
+ return
237
+ old = '''os.makedirs(os.path.expanduser("~/.flex_gemm"), exist_ok=True)
238
+ shutil.copyfile(
239
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "autotune_cache.json"),
240
+ os.path.expanduser('~/.flex_gemm/autotune_cache.json'),
241
+ )'''
242
+ new = '''cache_path = os.environ["FLEX_GEMM_AUTOTUNE_CACHE_PATH"]
243
+ os.makedirs(os.path.dirname(cache_path), exist_ok=True)
244
+ shutil.copyfile(
245
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "autotune_cache.json"),
246
+ cache_path,
247
+ )'''
248
+ setup = (source / "setup.py").read_text()
249
+ if setup.count(old) != 1:
250
+ raise InstallError("FlexGEMM setup.py does not match the pinned cache-write block; refusing an unverified patch.")
251
+ with tempfile.TemporaryDirectory(prefix="flex-gemm-build-", dir=managed) as temporary:
252
+ build_source = Path(temporary) / "FlexGEMM"
253
+ shutil.copytree(source, build_source, ignore=shutil.ignore_patterns(".git"))
254
+ (build_source / "setup.py").write_text(setup.replace(old, new))
255
+ run([*command, str(build_source)], env, False)
256
+
257
+
258
+ def install(args: argparse.Namespace) -> None:
259
+ venv = active_conda_environment() if args.conda else Path(args.venv).expanduser().resolve()
260
+ selected_python = args.python or sys.executable
261
+ python = shutil.which(selected_python) or str(Path(selected_python).expanduser().absolute())
262
+ env = clean_environment(venv, conda=args.conda)
263
+ managed = venv / "supra-tex-runtime"
264
+ sources = managed / "sources"
265
+ constraints = managed / "constraints.txt"
266
+ target_python = str(venv / "bin" / "python")
267
+ if args.conda:
268
+ python = target_python
269
+ pip = [target_python, "-I", "-m", "pip", "install", "--no-input"]
270
+ dry = args.dry_run
271
+ print(f"{'DRY RUN: no subprocesses, downloads, or writes.' if dry else 'Installing runtime.'}\nSource: {ROOT}\n{'Conda environment' if args.conda else 'Virtual environment'}: {venv}")
272
+ print("Prerequisites: Linux x86_64; Python 3.10; git; C++ compiler; CUDA toolkit 12.4 nvcc; usable NVIDIA GPU/driver.")
273
+ print("Child processes ignore inherited Python overlays and pip configuration/destinations; caches stay in the target environment. Without FlashAttention, inference uses SDPA; FlashAttention-2/3 is faster.")
274
+ print("Native source pins identify upstream inputs, not verified equivalent binaries. FlexGEMM's install-time cache path is patched in a temporary copy; its original checkout stays unchanged.")
275
+ if not dry:
276
+ for required in (ROOT / "deployment/runtime/requirements-python.txt", ROOT / "deployment/runtime/moge/pyproject.toml", ROOT / "deployment/runtime/utils3d/pyproject.toml", ROOT / "scripts/check_runtime.py"):
277
+ if not required.is_file():
278
+ raise InstallError(f"Incomplete source release: missing {required}")
279
+ env = prerequisites(python, venv, env, conda=args.conda)
280
+ # Check all existing source trees before changing this environment.
281
+ for name, url, revision, _ in SOURCES:
282
+ path = sources / name
283
+ if path.exists():
284
+ validate_checkout(path, url, revision, env)
285
+ if args.conda:
286
+ print("# Install into the active Conda environment.")
287
+ elif not venv.exists():
288
+ run([python, "-I", "-m", "venv", str(venv)], env, dry)
289
+ else:
290
+ print("# Reuse only an isolated Python 3.10 venv; never clear an existing directory.")
291
+ if not dry:
292
+ info = json.loads(capture([target_python, "-I", "-c", PYTHON_PROBE], env))
293
+ validate_target_python(info, venv, conda=args.conda)
294
+ managed.mkdir(parents=True, exist_ok=True)
295
+ sources.mkdir(exist_ok=True)
296
+ constraints.write_text(CONSTRAINTS)
297
+ print(f"# Write {constraints}: torch==2.6.0+cu124; torchvision==0.21.0+cu124")
298
+ run([*pip, *BUILD_TOOLS], env, dry)
299
+ run([*pip, "torch==2.6.0+cu124", "torchvision==0.21.0+cu124", "--index-url", "https://download.pytorch.org/whl/cu124"], env, dry)
300
+ run([*pip, "--constraint", str(constraints), "-r", str(ROOT / "deployment/runtime/requirements-python.txt")], env, dry)
301
+ run([*pip, "--no-build-isolation", "--no-deps", str(ROOT / "deployment/runtime/moge"), str(ROOT / "deployment/runtime/utils3d")], env, dry)
302
+ run([*pip, "--no-build-isolation", "--no-deps", "-e", str(ROOT)], env, dry)
303
+ install_natten(pip, env, dry)
304
+ run([target_python, "-I", "-c", TORCH_PROBE], env, dry)
305
+ for name, url, revision, subdir in SOURCES:
306
+ path = sources / name
307
+ if not path.exists():
308
+ run(["git", "clone", "--no-checkout", url, str(path)], env, dry)
309
+ run(["git", "-C", str(path), "checkout", "--detach", revision], env, dry)
310
+ else:
311
+ print(f"# Verify origin={url}, HEAD={revision}, and clean tracked files: {path}; no reset/clean.")
312
+ run(["git", "-C", str(path), "submodule", "update", "--init", "--recursive"], env, dry)
313
+ flags = None
314
+ if name == "CuMesh":
315
+ print("# CuMesh/cubvh: append --extended-lambda for its CUDA device lambdas without patching source.")
316
+ flags = {"NVCC_APPEND_FLAGS": " ".join(filter(None, (env.get("NVCC_APPEND_FLAGS"), "--extended-lambda")))}
317
+ elif subdir == "o-voxel":
318
+ print("# o-voxel: NVCC_APPEND_FLAGS appends the recorded extended-lambda/half flags without patching source. This build still requires destination validation.")
319
+ flags = {"NVCC_APPEND_FLAGS": " ".join(filter(None, (env.get("NVCC_APPEND_FLAGS"), O_VOXEL_FLAGS)))}
320
+ if name == "FlexGEMM":
321
+ install_flex_gemm(pip, path, managed, env, dry)
322
+ else:
323
+ run([*pip, "--no-build-isolation", "--no-deps", str(path / subdir)], env, dry, flags)
324
+ if args.flash_attn:
325
+ run([*pip, "--no-build-isolation", "--no-deps", "flash-attn==2.8.3"], env, dry)
326
+ run([target_python, "-I", "-c", "import flash_attn; print(flash_attn.__version__)"], env, dry)
327
+ run([target_python, "-I", "-m", "pip", "check"], env, dry)
328
+ run([target_python, "-I", "-c", TORCH_PROBE], env, dry)
329
+ run([target_python, "-I", str(ROOT / "scripts/check_runtime.py"), "--imports-only"], env, dry)
330
+ if dry:
331
+ print("Dry run complete. Prerequisites, builds, and imports have NOT been validated.")
332
+ else:
333
+ print(f"Runtime dependency checks passed in {venv}. Model acquisition and an image-to-GLB run are still required for inference acceptance. FlashAttention-2/3 is faster than SDPA; see docs/installation.md.")
334
+
335
+
336
+ def main(argv: list[str] | None = None) -> int:
337
+ parser = argparse.ArgumentParser(description=__doc__)
338
+ destination = parser.add_mutually_exclusive_group()
339
+ destination.add_argument("--conda", action="store_true", help="Install into the active Conda environment (recommended; activate it first)")
340
+ destination.add_argument("--venv", default=".venv", help="Create/reuse an isolated venv instead (default without --conda: .venv)")
341
+ parser.add_argument("--python", help="Python 3.10 executable used to create a venv (default: invoking Python; unavailable with --conda)")
342
+ parser.add_argument("--dry-run", action="store_true", help="Print the complete plan without subprocesses, network, or filesystem writes")
343
+ parser.add_argument("--flash-attn", action="store_true", help="Also build optional FlashAttention 2.8.3; default SDPA needs neither FA2 nor FA3")
344
+ args = parser.parse_args(argv)
345
+ if args.conda and args.python:
346
+ parser.error("--python cannot be combined with --conda; use the active Conda environment's Python.")
347
+ try:
348
+ install(args)
349
+ except (InstallError, OSError, ValueError) as exc:
350
+ print(f"ERROR: {exc}", file=sys.stderr)
351
+ return 1
352
+ return 0
353
+
354
+
355
+ if __name__ == "__main__":
356
+ raise SystemExit(main())
scripts/prepare_models.py CHANGED
@@ -1,360 +1,552 @@
1
- #!/usr/bin/env python3
2
- """Download or bind Supra-Tex models, then publish a portable models.json.
3
-
4
- Downloads use the inherited Hugging Face configuration and your existing access.
5
- Local overrides are linked without copying large weights and remain dependencies
6
- of the prepared tree. No tensor data is loaded and no checksums are computed.
7
- """
8
- from __future__ import annotations
9
-
10
- import argparse
11
- import importlib.util
12
- import json
13
- import os
14
- from pathlib import Path, PurePosixPath
15
- import re
16
- import shutil
17
- import sys
18
- from urllib.parse import unquote, urlsplit
19
- from urllib.request import urlopen
20
-
21
-
22
- ROOT = Path(__file__).resolve().parents[1]
23
- PROJECT_REPO = "supra3d/supra-tex"
24
- NAF_URL = "https://github.com/valeoai/NAF/releases/download/model/naf_release.pth"
25
- COMPLETION_PATTERNS = ["config.json", "partial_normalization.json", "*.safetensors",
26
- "*.safetensors.index.json", "LICENSE*", "README.md"]
27
- MATERIAL_PATTERNS = ["adapter_config.json", "adapter_model.safetensors", "LICENSE*", "Notice", "README.md"]
28
- GEOMETRY_KEYS = ("sparse_structure_flow_model", "sparse_structure_decoder",
29
- "shape_slat_flow_model_512", "shape_slat_flow_model_1024", "shape_slat_decoder")
30
- VAE_STEMS = ("shape_enc_next_dc_f16c32_fp16", "tex_enc_next_dc_f16c32_fp16",
31
- "shape_dec_next_dc_f16c32_fp16", "tex_dec_next_dc_f16c32_fp16")
32
- MARKER = ".prepare-models.json"
33
-
34
-
35
- def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
36
- parser = argparse.ArgumentParser(description=__doc__)
37
- parser.add_argument("--repo", default=PROJECT_REPO, help="project HF repo ID or URL (default: %(default)s)")
38
- parser.add_argument("--revision", help="project repository revision; defaults to its current main")
39
- parser.add_argument("--completion-subdir", default="completion")
40
- parser.add_argument("--material-subdir", default="material")
41
- parser.add_argument("--completion", help="override with standalone completion directory, HF repo ID, or HF URL")
42
- parser.add_argument("--material", help="override with material adapter directory, HF repo ID, or HF URL")
43
- parser.add_argument("--output-dir", type=Path, default=ROOT / "ckpts/ready")
44
- for name in ("qwen-base", "geometry", "dino", "vae", "moge", "rmbg", "naf-weights"):
45
- parser.add_argument("--" + name, type=Path, help="existing local override (no download)")
46
- parser.add_argument("--with-rmbg", action="store_true", help="also prepare RMBG for RGB inputs; default example uses RGBA")
47
- parser.add_argument("--dry-run", action="store_true", help="print the plan without writes, downloads, or model imports")
48
- return parser.parse_args(argv)
49
-
50
-
51
- def relative_name(value: str) -> str:
52
- if (not isinstance(value, str) or not value or "\\" in value or ":" in value
53
- or PurePosixPath(value).is_absolute() or any(part in ("", ".", "..") for part in value.split("/"))):
54
- raise ValueError(f"expected a relative path without traversal: {value!r}")
55
- return value
56
-
57
-
58
- def model_source(value: str, revision: str | None = None) -> dict:
59
- """Normalize local directories and HF URLs without importing the Hub client."""
60
- local = Path(value).expanduser()
61
- if local.exists():
62
- if not local.is_dir():
63
- raise NotADirectoryError(f"model source must be a directory: {local}")
64
- return {"kind": "local", "path": str(local.resolve())}
65
- if value.startswith(("/", "./", "../", "~")) or value in (".", ".."):
66
- raise FileNotFoundError(f"model directory does not exist: {local}")
67
- if value.startswith("https://"):
68
- url = urlsplit(value)
69
- if url.hostname != "huggingface.co" or url.username or url.password or url.query or url.fragment:
70
- raise ValueError("use a public-form https://huggingface.co/namespace/repo URL without credentials or query")
71
- parts = unquote(url.path).strip("/").split("/")
72
- if len(parts) == 4 and parts[2] == "tree":
73
- if revision is not None and revision != parts[3]:
74
- raise ValueError("HF URL revision conflicts with --revision")
75
- revision = parts[3]
76
- parts = parts[:2]
77
- if len(parts) != 2:
78
- raise ValueError("HF URL must select a repository root or /tree/<revision>")
79
- value = "/".join(parts)
80
- value = value.removeprefix("hf://")
81
- if (not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]*/[A-Za-z0-9_][A-Za-z0-9_.-]*", value)
82
- or ".." in value or "--" in value or value.endswith((".", "-"))):
83
- raise ValueError("model source must be an existing directory or a namespace/repo HF ID or URL")
84
- return {"kind": "hub", "repo_id": value, "revision": revision}
85
-
86
-
87
- def local_override(value: Path, *, file_allowed: bool = False) -> dict:
88
- path = value.expanduser().resolve()
89
- if not path.exists():
90
- raise FileNotFoundError(f"local override does not exist: {path}")
91
- if not path.is_dir() and not (file_allowed and path.is_file()):
92
- raise ValueError(f"local override must be {'a directory or file' if file_allowed else 'a directory'}: {path}")
93
- return {"kind": "local", "path": str(path)}
94
-
95
-
96
- def build_plan(args: argparse.Namespace) -> dict:
97
- output = args.output_dir.expanduser().resolve()
98
- manifest = json.loads((ROOT / "deployment/external_models.json").read_text())
99
- operations, models = [], {}
100
- subdirs = {"completion": relative_name(args.completion_subdir), "material": relative_name(args.material_subdir)}
101
- patterns = {"completion": COMPLETION_PATTERNS, "material": MATERIAL_PATTERNS}
102
- project_needed = [name for name in subdirs if getattr(args, name) is None]
103
- if project_needed:
104
- project = model_source(args.repo, args.revision)
105
- if project["kind"] != "hub":
106
- raise ValueError("--repo must be a HF ID/URL; use --completion and --material for local directories")
107
- operations.append(dict(project, name="project", destination="project",
108
- allow_patterns=[f"{subdirs[name]}/{pattern}" for name in project_needed for pattern in patterns[name]], gated=False))
109
- for name in subdirs:
110
- value = getattr(args, name)
111
- if value is None:
112
- models[name] = {"kind": "prepared", "path": f"project/{subdirs[name]}"}
113
- else:
114
- source = model_source(value, args.revision)
115
- models[name] = source if source["kind"] == "local" else {"kind": "prepared", "path": name}
116
- if source["kind"] == "hub":
117
- operations.append(dict(source, name=name, destination=name, allow_patterns=patterns[name], gated=False))
118
- for key, flag in (("qwen-base", "qwen_base"), ("geometry", "geometry"), ("dino", "dino"),
119
- ("vae", "vae"), ("moge2", "moge"), ("rmbg2", "rmbg")):
120
- override = getattr(args, flag)
121
- if key == "rmbg2" and override is None and not args.with_rmbg:
122
- continue
123
- if override is not None:
124
- models[key] = local_override(override, file_allowed=key == "moge2")
125
- else:
126
- entry = manifest[key]
127
- destination = f"external/{key}"
128
- operations.append({"name": key, "kind": "hub", "repo_id": entry["repo_id"],
129
- "revision": entry["revision"], "allow_patterns": entry["allow_patterns"],
130
- "gated": entry.get("gated", False), "destination": destination})
131
- models[key] = {"kind": "prepared", "path": destination}
132
- if args.naf_weights is not None:
133
- models["naf"] = local_override(args.naf_weights, file_allowed=True)
134
- else:
135
- operations.append({"name": "naf", "kind": "url", "url": NAF_URL, "destination": "external/naf/naf_release.pth"})
136
- models["naf"] = {"kind": "prepared", "path": "external/naf/naf_release.pth"}
137
- return {"schema_version": 1, "output_dir": str(output), "downloads": operations, "models": models,
138
- "bindings": {"geometry": "geometry/pipeline.json + five config/weight pairs + dino",
139
- "pipeline_source": "local geometry pipeline.json or deployment/runtime/geometry/pipeline.json",
140
- "vae": "all four encoder/decoder config/weight pairs under upstream ckpts/",
141
- "naf_source": "torch/hub/valeoai_NAF_main -> deployment/runtime/naf",
142
- "naf_weights": "torch/hub/checkpoints/naf_release.pth",
143
- "config": "models.json (published only after all checks)"},
144
- "access": "Gated repositories require your existing upstream permission and local HF authentication. HF_ENDPOINT is inherited.",
145
- "local_override_note": "Local overrides remain external dependencies; their absolute paths/links are intentionally machine-local."}
146
-
147
-
148
- def require_file(path: Path) -> Path:
149
- if not path.is_file() or path.stat().st_size == 0:
150
- raise FileNotFoundError(f"required model file is missing or empty: {path}")
151
- return path
152
-
153
-
154
- def check_index(path: Path) -> None:
155
- index = json.loads(require_file(path).read_text())
156
- mapping = index.get("weight_map")
157
- if not isinstance(mapping, dict) or not mapping:
158
- raise ValueError(f"checkpoint index requires a nonempty weight_map: {path}")
159
- for filename in set(mapping.values()):
160
- relative_name(filename)
161
- if not filename.endswith(".safetensors"):
162
- raise ValueError(f"index shard must be .safetensors: {filename}")
163
- require_file(path.parent / filename)
164
-
165
-
166
- def check_safe_weights(directory: Path, single: str = "model.safetensors") -> Path:
167
- index = directory / (single + ".index.json")
168
- weights = directory / single
169
- if index.is_file() and weights.is_file():
170
- raise ValueError(f"ambiguous single and sharded weights: {directory}")
171
- if index.is_file():
172
- check_index(index)
173
- return index
174
- return require_file(weights)
175
-
176
-
177
- def validate_inputs(paths: dict[str, Path]) -> tuple[dict, Path]:
178
- # Load the path-only helper directly: package __init__ imports GPU-facing
179
- # modules, and resolve_completion_artifacts performs deeper ABI validation.
180
- spec = importlib.util.spec_from_file_location("_supratex_prepare_weights", ROOT / "supratex/weights.py")
181
- module = importlib.util.module_from_spec(spec)
182
- spec.loader.exec_module(module)
183
- for key in ("completion", "material"):
184
- module.resolve_model_dir(paths[key], local_files_only=True)
185
- completion = json.loads(require_file(paths["completion"] / "config.json").read_text())
186
- if completion.get("format") != "supratex_completion" or completion.get("schema_version") != 1:
187
- raise ValueError("completion requires the standalone Supra-Tex config.json (schema_version 1)")
188
- require_file(paths["completion"] / "partial_normalization.json")
189
- checkpoint = check_safe_weights(paths["completion"])
190
- adapter = json.loads(require_file(paths["material"] / "adapter_config.json").read_text())
191
- if adapter.get("format") != "supratex_qwen_material_adapter" or adapter.get("weight_file") != "adapter_model.safetensors":
192
- raise ValueError("material requires the standalone Supra-Tex adapter_config.json and adapter_model.safetensors")
193
- require_file(paths["material"] / "adapter_model.safetensors")
194
- for name in ("preprocessor_config.json", "tokenizer_config.json", "tokenizer.json", "chat_template.jinja"):
195
- require_file(paths["qwen-base"] / "processor" / name)
196
- for name in ("vae", "text_encoder", "transformer"):
197
- component = paths["qwen-base"] / name
198
- require_file(component / "config.json")
199
- weights = list(component.glob("*.safetensors"))
200
- if not weights:
201
- raise FileNotFoundError(f"Qwen foundation component has no weights: {component}")
202
- for weight in weights:
203
- require_file(weight)
204
- for index in component.glob("*.safetensors.index.json"):
205
- check_index(index)
206
- for name in ("config.json", "preprocessor_config.json"):
207
- require_file(paths["dino"] / name)
208
- check_safe_weights(paths["dino"])
209
- if "rmbg2" in paths:
210
- for name in ("config.json", "model.safetensors", "preprocessor_config.json", "BiRefNet_config.py", "birefnet.py"):
211
- require_file(paths["rmbg2"] / name)
212
- paths["moge2"] = require_file(paths["moge2"] / "model.pt" if paths["moge2"].is_dir() else paths["moge2"])
213
- paths["naf"] = require_file(paths["naf"] / "naf_release.pth" if paths["naf"].is_dir() else paths["naf"])
214
- vae = paths["vae"] / "ckpts" if (paths["vae"] / "ckpts").is_dir() else paths["vae"]
215
- for stem in VAE_STEMS:
216
- for suffix in (".json", ".safetensors"):
217
- require_file(vae / (stem + suffix))
218
- pipeline_file = paths["geometry"] / "pipeline.json"
219
- if not pipeline_file.is_file():
220
- pipeline_file = ROOT / "deployment/runtime/geometry/pipeline.json"
221
- pipeline = json.loads(require_file(pipeline_file).read_text())
222
- for key in GEOMETRY_KEYS:
223
- stem = relative_name(pipeline["args"]["models"][key])
224
- for suffix in (".json", ".safetensors"):
225
- require_file(paths["geometry"] / (stem + suffix))
226
- return pipeline, checkpoint
227
-
228
-
229
- def write_once(path: Path, text: str) -> None:
230
- """Accept identical products on retry, never overwrite an existing file."""
231
- if path.exists() or path.is_symlink():
232
- if path.is_symlink() or not path.is_file() or path.read_text() != text:
233
- raise FileExistsError(f"refusing to overwrite conflicting output: {path}")
234
- return
235
- path.parent.mkdir(parents=True, exist_ok=True)
236
- with path.open("x", encoding="utf-8") as stream:
237
- stream.write(text)
238
-
239
-
240
- def json_text(value: dict) -> str:
241
- return json.dumps(value, indent=2) + "\n"
242
-
243
-
244
- def link_once(destination: Path, source: Path, *, relative: bool) -> None:
245
- target = os.path.relpath(source, destination.parent) if relative else str(source)
246
- if destination.is_symlink() and os.readlink(destination) == target:
247
- return
248
- if destination.exists() or destination.is_symlink():
249
- raise FileExistsError(f"refusing to overwrite conflicting output: {destination}")
250
- destination.parent.mkdir(parents=True, exist_ok=True)
251
- destination.symlink_to(target, target_is_directory=source.is_dir())
252
-
253
-
254
- def download(operation: dict, output: Path) -> None:
255
- destination = output / operation["destination"]
256
- if operation["kind"] == "url":
257
- if destination.exists():
258
- require_file(destination)
259
- return
260
- destination.parent.mkdir(parents=True, exist_ok=True)
261
- temporary = destination.with_suffix(".pth.part")
262
- try:
263
- with urlopen(operation["url"], timeout=60) as response, temporary.open("wb") as target:
264
- expected_size = response.headers.get("Content-Length")
265
- shutil.copyfileobj(response, target)
266
- require_file(temporary)
267
- if expected_size is not None and temporary.stat().st_size != int(expected_size):
268
- raise ValueError("NAF response ended before its declared Content-Length")
269
- # A same-directory hard link publishes without replacing a file.
270
- os.link(temporary, destination)
271
- except Exception as error:
272
- raise RuntimeError("NAF download failed; retry or supply --naf-weights with the official naf_release.pth") from error
273
- finally:
274
- temporary.unlink(missing_ok=True)
275
- return
276
- from huggingface_hub import snapshot_download
277
- try:
278
- snapshot_download(repo_id=operation["repo_id"], revision=operation["revision"],
279
- allow_patterns=operation["allow_patterns"], local_dir=str(destination),
280
- cache_dir=str(output / ".cache/huggingface"))
281
- except Exception as error:
282
- status = getattr(getattr(error, "response", None), "status_code", None)
283
- access = operation.get("gated") or status in (401, 403) or "GatedRepo" in type(error).__name__
284
- detail = ("This upstream repository requires existing access permission and your own local HF authentication. "
285
- "Accept its publisher's terms and authenticate with your normal HF tooling, or provide a local override."
286
- if access else "Check the repository/revision and your network or local HF authentication, then retry.")
287
- # Do not interpolate exception text: upstream URLs can contain secrets.
288
- raise RuntimeError(f"Download failed for {operation['repo_id']}. {detail}") from None
289
-
290
-
291
- def prepare(plan: dict) -> Path:
292
- output = Path(plan["output_dir"])
293
- marker = output / MARKER
294
- identity = {key: value for key, value in plan.items() if key != "output_dir"}
295
- if output.exists() and not output.is_dir():
296
- raise NotADirectoryError(output)
297
- if output.is_dir() and any(output.iterdir()) and not marker.is_file():
298
- raise FileExistsError(f"output directory is not owned by prepare_models.py; choose an empty --output-dir: {output}")
299
- write_once(marker, json_text(identity))
300
- # Check publisher-controlled access before downloading the large public bases.
301
- for operation in sorted(plan["downloads"], key=lambda item: not item.get("gated", False)):
302
- download(operation, output)
303
- paths = {name: Path(source["path"]) if source["kind"] == "local" else output / source["path"]
304
- for name, source in plan["models"].items()}
305
- pipeline, checkpoint = validate_inputs(paths)
306
- geometry = output / "geometry"
307
- for key in GEOMETRY_KEYS:
308
- stem = pipeline["args"]["models"][key]
309
- write_once(geometry / (stem + ".json"), (paths["geometry"] / (stem + ".json")).read_text())
310
- link_once(geometry / (stem + ".safetensors"), paths["geometry"] / (stem + ".safetensors"),
311
- relative=plan["models"]["geometry"]["kind"] != "local")
312
- write_once(geometry / "pipeline.json", json_text(pipeline))
313
- link_once(geometry / "dino", paths["dino"], relative=plan["models"]["dino"]["kind"] != "local")
314
- runtime = ROOT / "deployment/runtime"
315
- require_file(runtime / "naf/hubconf.py")
316
- link_once(output / "torch/hub/valeoai_NAF_main", runtime / "naf", relative=True)
317
- link_once(output / "torch/hub/checkpoints/naf_release.pth", paths["naf"], relative=plan["models"]["naf"]["kind"] != "local")
318
-
319
- def config_path(path: Path, local: bool = False) -> str:
320
- return str(path) if local else os.path.relpath(path, output)
321
-
322
- def model_path(name: str) -> str:
323
- return config_path(paths[name], plan["models"][name]["kind"] == "local")
324
-
325
- local_names = [key for key, value in plan["models"].items() if value["kind"] == "local"]
326
- config = {"moge_model": model_path("moge2"), "rmbg_model": model_path("rmbg2") if "rmbg2" in paths else None,
327
- "geometry_package": config_path(runtime / "geometry"), "geometry_model": "geometry",
328
- "qwen_runtime": config_path(runtime / "qwen"), "qwen_deps": config_path(runtime / "qwen-deps"),
329
- "material_base": model_path("qwen-base"), "material_model": model_path("material"),
330
- "pretrained": model_path("completion"), "torch_home": "torch", "trellis_root": model_path("vae"),
331
- "model_config": config_path(paths["completion"] / "config.json", "completion" in local_names),
332
- "checkpoint": config_path(checkpoint, "completion" in local_names),
333
- "_meta": {"schema_version": 1, "local_overrides": local_names,
334
- "portability": "Relative paths resolve from models.json. Move with the source package; local overrides remain external dependencies.",
335
- "validation": "File presence and shard inventory only; run scripts/check_runtime.py before inference."}}
336
- result = output / "models.json"
337
- write_once(result, json_text(config))
338
- return result
339
-
340
-
341
- def main(argv: list[str] | None = None) -> int:
342
- args = parse_args(argv)
343
- try:
344
- plan = build_plan(args)
345
- if args.dry_run:
346
- print(json_text(plan), end="")
347
- else:
348
- result = prepare(plan)
349
- print(f"Prepared {result}")
350
- if any(value["kind"] == "local" for value in plan["models"].values()):
351
- print("Local overrides remain external dependencies; keep their original paths available.")
352
- print(f"Next: python scripts/check_runtime.py --config {result}")
353
- except (OSError, ValueError, KeyError, RuntimeError) as error:
354
- print(f"error: {error}", file=sys.stderr)
355
- return 2
356
- return 0
357
-
358
-
359
- if __name__ == "__main__":
360
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Download or bind Supra-Tex models, then publish a portable models.json.
3
+
4
+ Downloads use the inherited Hugging Face configuration and your existing access.
5
+ Local overrides are linked without copying large weights and remain dependencies
6
+ of the prepared tree. Cached Hub snapshots are also linked when using an explicit
7
+ cache or preparing offline; keep that cache available. No tensor data is loaded
8
+ and no checksums are computed.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import copy
14
+ import filecmp
15
+ import fnmatch
16
+ import importlib.util
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+ import shutil
21
+ import sys
22
+ import tempfile
23
+ from urllib.request import urlopen
24
+
25
+
26
+ ROOT = Path(__file__).resolve().parents[1]
27
+ PROJECT_REPO = "supra3d/supra-tex"
28
+ MATERIAL_PATTERNS = ["adapter_config.json", "adapter_model.safetensors", "LICENSE*", "Notice", "README.md"]
29
+ GEOMETRY_KEYS = ("sparse_structure_flow_model", "sparse_structure_decoder",
30
+ "shape_slat_flow_model_512", "shape_slat_flow_model_1024", "shape_slat_decoder")
31
+ VAE_STEMS = ("shape_enc_next_dc_f16c32_fp16", "tex_enc_next_dc_f16c32_fp16",
32
+ "shape_dec_next_dc_f16c32_fp16", "tex_dec_next_dc_f16c32_fp16")
33
+ MARKER = ".prepare-models.json"
34
+
35
+ # Keep setup/help usable without importing the GPU-facing supratex package.
36
+ _spec = importlib.util.spec_from_file_location("_supratex_pipeline_config", ROOT / "supratex/pipeline_config.py")
37
+ _pipeline = importlib.util.module_from_spec(_spec)
38
+ _spec.loader.exec_module(_pipeline)
39
+ _weights_spec = importlib.util.spec_from_file_location("_supratex_prepare_weights", ROOT / "supratex/weights.py")
40
+ _weights = importlib.util.module_from_spec(_weights_spec)
41
+ _weights_spec.loader.exec_module(_weights)
42
+ DEFAULT_COMPLETION_CHECKPOINT = _weights.DEFAULT_COMPLETION_CHECKPOINT
43
+ # The release keeps an older sharded model beside the default checkpoint; fetch
44
+ # only the default weights.
45
+ COMPLETION_PATTERNS = ["config.json", "partial_normalization.json", DEFAULT_COMPLETION_CHECKPOINT,
46
+ "LICENSE*", "README.md"]
47
+ relative_name = _pipeline.relative_name
48
+ model_source = _pipeline.model_source
49
+
50
+
51
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
52
+ parser = argparse.ArgumentParser(description=__doc__)
53
+ parser.add_argument("--repo", default=PROJECT_REPO, help="pipeline release directory or HF repo ID/URL (default: %(default)s)")
54
+ parser.add_argument("--revision", help="project repository revision; defaults to its current main")
55
+ parser.add_argument("--pipeline", type=Path, help="explicit local pipeline.json; otherwise read it from --repo")
56
+ parser.add_argument("--completion-subdir", help="override the completion directory declared by pipeline.json")
57
+ parser.add_argument("--material-subdir", help="override the material directory declared by pipeline.json")
58
+ parser.add_argument("--completion", help="override with standalone completion directory, HF repo ID, or HF URL")
59
+ parser.add_argument("--material", help="override with material adapter directory, HF repo ID, or HF URL")
60
+ parser.add_argument("--completion-revision", help="revision for a separate --completion repository")
61
+ parser.add_argument("--material-revision", help="revision for a separate --material repository")
62
+ parser.add_argument("--output-dir", type=Path, default=ROOT / "ckpts/ready")
63
+ parser.add_argument("--cache-dir", type=Path, help="Hugging Face cache for manifests and model snapshots; prepared files link to this cache, so keep it available")
64
+ for name in ("qwen-base", "geometry", "dino", "vae", "moge", "rmbg", "naf-weights"):
65
+ parser.add_argument("--" + name, type=Path, help="existing local override (no download)")
66
+ parser.add_argument("--with-rmbg", action="store_true", help="also prepare RMBG for RGB inputs; default example uses RGBA")
67
+ parser.add_argument("--local-files-only", action="store_true", help="use local files and cached Hub snapshots; never download")
68
+ parser.add_argument("--dry-run", action="store_true", help="offline plan; remote repositories use the bundled pipeline preview")
69
+ return parser.parse_args(argv)
70
+
71
+
72
+ def local_override(value: Path, *, file_allowed: bool = False) -> dict:
73
+ path = value.expanduser().resolve()
74
+ if not path.exists():
75
+ raise FileNotFoundError(f"local override does not exist: {path}")
76
+ if not path.is_dir() and not (file_allowed and path.is_file()):
77
+ raise ValueError(f"local override must be {'a directory or file' if file_allowed else 'a directory'}: {path}")
78
+ return {"kind": "local", "path": str(path)}
79
+
80
+
81
+ def build_plan(args: argparse.Namespace, *, resolve_remote: bool = False) -> dict:
82
+ """Build a plan from one pipeline; offline previews never fetch metadata."""
83
+ output = args.output_dir.expanduser().resolve()
84
+ project = model_source(args.repo, args.revision)
85
+ project_needed = [name for name in ("completion", "material") if getattr(args, name) is None]
86
+ if args.pipeline is not None:
87
+ pipeline = _pipeline.load_pipeline(args.pipeline)
88
+ manifest_source = "explicit local pipeline"
89
+ elif project["kind"] == "local":
90
+ pipeline, project = _pipeline.resolve_pipeline(args.repo)
91
+ manifest_source = "local release"
92
+ elif resolve_remote and (project_needed or args.repo != PROJECT_REPO or args.revision):
93
+ pipeline, project = _pipeline.resolve_pipeline(
94
+ args.repo, revision=args.revision, cache_dir=args.cache_dir, local_files_only=args.local_files_only)
95
+ manifest_source = "Hub pipeline at selected revision"
96
+ else:
97
+ pipeline = _pipeline.load_pipeline(ROOT / "configs/pipeline.json")
98
+ manifest_source = "bundled preview; Hub pipeline is resolved before preparation" if project_needed else "bundled pipeline"
99
+ manifest = pipeline["external_models"]
100
+ operations, models = [], {}
101
+ subdirs = {name: relative_name(getattr(args, name + "_subdir") or pipeline["models"][name])
102
+ for name in ("completion", "material")}
103
+ patterns = {"completion": COMPLETION_PATTERNS, "material": MATERIAL_PATTERNS}
104
+ if project_needed and project["kind"] == "hub":
105
+ operations.append(dict(project, name="project", destination="project",
106
+ allow_patterns=["pipeline.json", "README.md", ".gitattributes"] +
107
+ [f"{subdirs[name]}/{pattern}" for name in project_needed for pattern in patterns[name]]))
108
+ for name in subdirs:
109
+ value = getattr(args, name)
110
+ if value is None:
111
+ if project["kind"] == "local":
112
+ models[name] = local_override(Path(project["path"]) / subdirs[name])
113
+ else:
114
+ models[name] = {"kind": "prepared", "path": f"project/{subdirs[name]}"}
115
+ else:
116
+ source = model_source(value, getattr(args, name + "_revision"))
117
+ models[name] = source if source["kind"] == "local" else {"kind": "prepared", "path": name}
118
+ if source["kind"] == "hub":
119
+ operations.append(dict(source, name=name, destination=name, allow_patterns=patterns[name]))
120
+ for key, flag in (("qwen-base", "qwen_base"), ("geometry", "geometry"), ("dino", "dino"),
121
+ ("vae", "vae"), ("moge2", "moge"), ("rmbg2", "rmbg")):
122
+ override = getattr(args, flag)
123
+ if key == "rmbg2" and override is None and not args.with_rmbg:
124
+ continue
125
+ if override is not None:
126
+ models[key] = local_override(override, file_allowed=key == "moge2")
127
+ else:
128
+ entry = manifest[key]
129
+ destination = f"external/{key}"
130
+ operations.append({"name": key, "kind": "hub", "repo_id": entry["repo_id"],
131
+ "revision": entry["revision"], "allow_patterns": entry["allow_patterns"],
132
+ "gated": entry.get("gated", False), "destination": destination})
133
+ models[key] = {"kind": "prepared", "path": destination}
134
+ if args.naf_weights is not None:
135
+ models["naf"] = local_override(args.naf_weights, file_allowed=True)
136
+ else:
137
+ entry = manifest["naf"]
138
+ destination = "external/naf/" + entry["filename"]
139
+ operations.append({"name": "naf", "kind": "url", "url": entry["url"], "destination": destination})
140
+ models["naf"] = {"kind": "prepared", "path": destination}
141
+ for operation in operations:
142
+ if args.local_files_only:
143
+ operation["local_files_only"] = True
144
+ if args.cache_dir is not None and operation["kind"] == "hub":
145
+ operation["cache_dir"] = str(args.cache_dir.expanduser().resolve())
146
+ return {"schema_version": 1, "output_dir": str(output), "downloads": operations, "models": models,
147
+ "cache_dir": str(args.cache_dir.expanduser().resolve()) if args.cache_dir is not None else None,
148
+ "local_files_only": args.local_files_only,
149
+ "pipeline": pipeline, "pipeline_source": manifest_source,
150
+ "bindings": {"geometry": "geometry/pipeline.json + five config/weight pairs + dino",
151
+ "pipeline_source": "local geometry pipeline.json or deployment/runtime/geometry/pipeline.json",
152
+ "vae": "all four encoder/decoder config/weight pairs under upstream ckpts/",
153
+ "naf_source": "torch/hub/valeoai_NAF_main -> deployment/runtime/naf",
154
+ "naf_weights": "torch/hub/checkpoints/naf_release.pth",
155
+ "config": "models.json (generated paths and selected pipeline defaults)"},
156
+ "access": "Gated repositories require your existing upstream permission and local HF authentication. HF_ENDPOINT is inherited.",
157
+ "local_override_note": "Local overrides remain external dependencies; their absolute paths/links are intentionally machine-local."}
158
+
159
+
160
+ def require_file(path: Path) -> Path:
161
+ if not path.is_file() or path.stat().st_size == 0:
162
+ raise FileNotFoundError(f"required model file is missing or empty: {path}")
163
+ return path
164
+
165
+
166
+ def check_index(path: Path) -> None:
167
+ index = json.loads(require_file(path).read_text())
168
+ mapping = index.get("weight_map")
169
+ if not isinstance(mapping, dict) or not mapping:
170
+ raise ValueError(f"checkpoint index requires a nonempty weight_map: {path}")
171
+ for filename in set(mapping.values()):
172
+ relative_name(filename)
173
+ if not filename.endswith(".safetensors"):
174
+ raise ValueError(f"index shard must be .safetensors: {filename}")
175
+ require_file(path.parent / filename)
176
+
177
+
178
+ def check_safe_weights(directory: Path, single: str = "model.safetensors") -> Path:
179
+ index = directory / (single + ".index.json")
180
+ weights = directory / single
181
+ if index.is_file() and weights.is_file():
182
+ raise ValueError(f"ambiguous single and sharded weights: {directory}")
183
+ if index.is_file():
184
+ check_index(index)
185
+ return index
186
+ return require_file(weights)
187
+
188
+
189
+ def completion_checkpoint(directory: Path) -> Path:
190
+ """Prefer the default completion weights; fall back to model.safetensors or its shards."""
191
+ default = directory / DEFAULT_COMPLETION_CHECKPOINT
192
+ return default if default.is_file() else check_safe_weights(directory)
193
+
194
+
195
+ def validate_inputs(paths: dict[str, Path]) -> tuple[dict, Path]:
196
+ # Load the path-only helper directly: package __init__ imports GPU-facing
197
+ # modules, and resolve_completion_artifacts performs deeper ABI validation.
198
+ for key in ("completion", "material"):
199
+ _weights.resolve_model_dir(paths[key], local_files_only=True)
200
+ completion = json.loads(require_file(paths["completion"] / "config.json").read_text())
201
+ if completion.get("format") != "supratex_completion" or completion.get("schema_version") != 1:
202
+ raise ValueError("completion requires the standalone Supra-Tex config.json (schema_version 1)")
203
+ require_file(paths["completion"] / "partial_normalization.json")
204
+ checkpoint = completion_checkpoint(paths["completion"])
205
+ adapter = json.loads(require_file(paths["material"] / "adapter_config.json").read_text())
206
+ if adapter.get("format") != "supratex_qwen_material_adapter" or adapter.get("weight_file") != "adapter_model.safetensors":
207
+ raise ValueError("material requires the standalone Supra-Tex adapter_config.json and adapter_model.safetensors")
208
+ require_file(paths["material"] / "adapter_model.safetensors")
209
+ for name in ("preprocessor_config.json", "tokenizer_config.json", "tokenizer.json", "chat_template.jinja"):
210
+ require_file(paths["qwen-base"] / "processor" / name)
211
+ for name in ("vae", "text_encoder", "transformer"):
212
+ component = paths["qwen-base"] / name
213
+ require_file(component / "config.json")
214
+ weights = list(component.glob("*.safetensors"))
215
+ if not weights:
216
+ raise FileNotFoundError(f"Qwen foundation component has no weights: {component}")
217
+ for weight in weights:
218
+ require_file(weight)
219
+ for index in component.glob("*.safetensors.index.json"):
220
+ check_index(index)
221
+ # The geometry extractors normalize images directly and load only DINO's
222
+ # model/config; a Transformers image-processor file is not required.
223
+ require_file(paths["dino"] / "config.json")
224
+ check_safe_weights(paths["dino"])
225
+ if "rmbg2" in paths:
226
+ # The foreground adapter normalizes images itself, so RMBG-2.0 and the
227
+ # public BiRefNet release share this layout without an image processor.
228
+ for name in ("config.json", "model.safetensors", "BiRefNet_config.py", "birefnet.py"):
229
+ require_file(paths["rmbg2"] / name)
230
+ paths["moge2"] = require_file(paths["moge2"] / "model.pt" if paths["moge2"].is_dir() else paths["moge2"])
231
+ paths["naf"] = require_file(paths["naf"] / "naf_release.pth" if paths["naf"].is_dir() else paths["naf"])
232
+ vae = paths["vae"] / "ckpts" if (paths["vae"] / "ckpts").is_dir() else paths["vae"]
233
+ for stem in VAE_STEMS:
234
+ for suffix in (".json", ".safetensors"):
235
+ require_file(vae / (stem + suffix))
236
+ pipeline_file = paths["geometry"] / "pipeline.json"
237
+ if not pipeline_file.is_file():
238
+ pipeline_file = _pipeline.runtime_root(ROOT) / "geometry/pipeline.json"
239
+ pipeline = json.loads(require_file(pipeline_file).read_text())
240
+ for key in GEOMETRY_KEYS:
241
+ stem = relative_name(pipeline["args"]["models"][key])
242
+ for suffix in (".json", ".safetensors"):
243
+ require_file(paths["geometry"] / (stem + suffix))
244
+ return pipeline, checkpoint
245
+
246
+
247
+ def write_once(path: Path, text: str) -> None:
248
+ """Accept identical products on retry, never overwrite an existing file."""
249
+ if path.exists() or path.is_symlink():
250
+ if path.is_symlink() or not path.is_file() or path.read_text() != text:
251
+ raise FileExistsError(f"refusing to overwrite conflicting output: {path}")
252
+ return
253
+ path.parent.mkdir(parents=True, exist_ok=True)
254
+ with path.open("x", encoding="utf-8") as stream:
255
+ stream.write(text)
256
+
257
+
258
+ def json_text(value: dict) -> str:
259
+ return json.dumps(value, indent=2) + "\n"
260
+
261
+
262
+ def link_once(destination: Path, source: Path, *, relative: bool) -> None:
263
+ target = os.path.relpath(source, destination.parent) if relative else str(source)
264
+ if destination.is_symlink() and os.readlink(destination) == target:
265
+ return
266
+ if destination.exists() or destination.is_symlink():
267
+ raise FileExistsError(f"refusing to overwrite conflicting output: {destination}")
268
+ destination.parent.mkdir(parents=True, exist_ok=True)
269
+ destination.symlink_to(target, target_is_directory=source.is_dir())
270
+
271
+
272
+ def bind_cached_snapshot(destination: Path, cached: Path, patterns: list[str]) -> None:
273
+ """Fill missing cached files only after checking every existing destination."""
274
+ if not destination.exists() and not destination.is_symlink():
275
+ link_once(destination, cached, relative=False)
276
+ return
277
+ if destination.is_symlink():
278
+ if destination.resolve() == cached.resolve():
279
+ return
280
+ if not destination.is_dir():
281
+ raise FileExistsError(f"refusing to overwrite conflicting output: {destination}")
282
+ missing = []
283
+ for source in sorted(cached.rglob("*")):
284
+ relative = source.relative_to(cached)
285
+ if not source.is_file() or not any(fnmatch.fnmatch(relative.as_posix(), pattern) for pattern in patterns):
286
+ continue
287
+ target = destination / relative
288
+ parent = target.parent
289
+ while parent != destination:
290
+ if parent.is_symlink() or (parent.exists() and not parent.is_dir()):
291
+ raise FileExistsError(f"refusing to modify conflicting output: {parent}")
292
+ parent = parent.parent
293
+ if target.exists() or target.is_symlink():
294
+ if not target.is_file() or (not source.samefile(target) and not filecmp.cmp(source, target, shallow=False)):
295
+ raise FileExistsError(f"refusing to overwrite conflicting output: {target}")
296
+ else:
297
+ if destination.is_symlink():
298
+ raise FileExistsError(f"refusing to modify a conflicting cache binding: {destination}")
299
+ missing.append((target, source))
300
+ for target, source in missing:
301
+ link_once(target, source, relative=False)
302
+
303
+
304
+ def download(operation: dict, output: Path) -> None:
305
+ destination = output / operation["destination"]
306
+ if operation["kind"] == "url":
307
+ if destination.exists():
308
+ require_file(destination)
309
+ return
310
+ if operation.get("local_files_only"):
311
+ raise FileNotFoundError("NAF weights are unavailable offline; supply --naf-weights or prepare them online first")
312
+ destination.parent.mkdir(parents=True, exist_ok=True)
313
+ temporary = destination.with_suffix(".pth.part")
314
+ try:
315
+ with urlopen(operation["url"], timeout=60) as response, temporary.open("wb") as target:
316
+ expected_size = response.headers.get("Content-Length")
317
+ shutil.copyfileobj(response, target)
318
+ require_file(temporary)
319
+ if expected_size is not None and temporary.stat().st_size != int(expected_size):
320
+ raise ValueError("NAF response ended before its declared Content-Length")
321
+ # A same-directory hard link publishes without replacing a file.
322
+ os.link(temporary, destination)
323
+ except Exception as error:
324
+ raise RuntimeError("NAF download failed; retry or supply --naf-weights with the official naf_release.pth") from error
325
+ finally:
326
+ temporary.unlink(missing_ok=True)
327
+ return
328
+ from huggingface_hub import snapshot_download
329
+ try:
330
+ if operation.get("local_files_only"):
331
+ try:
332
+ cached = snapshot_download(repo_id=operation["repo_id"], revision=operation["revision"],
333
+ allow_patterns=operation["allow_patterns"],
334
+ cache_dir=operation.get("cache_dir"), local_files_only=True)
335
+ except Exception:
336
+ if destination.is_dir():
337
+ # Online local_dir downloads may have no shared cache entry.
338
+ # The caller validates all required files before publishing.
339
+ return
340
+ raise
341
+ elif operation.get("cache_dir") is not None:
342
+ # local_dir bypasses the shared cache in huggingface_hub, so an
343
+ # explicit cache must use a snapshot and bind its files afterwards.
344
+ cached = snapshot_download(repo_id=operation["repo_id"], revision=operation["revision"],
345
+ allow_patterns=operation["allow_patterns"],
346
+ cache_dir=operation["cache_dir"])
347
+ else:
348
+ snapshot_download(repo_id=operation["repo_id"], revision=operation["revision"],
349
+ allow_patterns=operation["allow_patterns"], local_dir=str(destination),
350
+ cache_dir=operation.get("cache_dir"))
351
+ except Exception as error:
352
+ status = getattr(getattr(error, "response", None), "status_code", None)
353
+ access = operation.get("gated") or status in (401, 403) or "GatedRepo" in type(error).__name__
354
+ detail = ("This upstream repository requires existing access permission and your own local HF authentication. "
355
+ "Accept its publisher's terms and authenticate with your normal HF tooling, or provide a local override."
356
+ if access else "Check the repository/revision and your network or local HF authentication, then retry.")
357
+ # Do not interpolate exception text: upstream URLs can contain secrets.
358
+ raise RuntimeError(f"Download failed for {operation['repo_id']}. {detail}") from None
359
+ if operation.get("local_files_only") or operation.get("cache_dir") is not None:
360
+ # Keep local conflict errors distinct from remote access failures.
361
+ bind_cached_snapshot(destination, Path(cached), operation["allow_patterns"])
362
+
363
+
364
+ def plan_identity(plan: dict) -> dict:
365
+ """Transport/cache choices are not part of the prepared model identity."""
366
+ identity = {key: copy.deepcopy(value) for key, value in plan.items()
367
+ if key not in {"output_dir", "pipeline_source", "cache_dir", "local_files_only"}}
368
+ identity["downloads"] = [{key: value for key, value in item.items()
369
+ if key not in {"local_files_only", "cache_dir"}}
370
+ for item in plan["downloads"]]
371
+ return identity
372
+
373
+
374
+ def without_rmbg(identity: dict) -> dict:
375
+ identity = copy.deepcopy(identity)
376
+ identity["models"].pop("rmbg2", None)
377
+ identity["downloads"] = [item for item in identity["downloads"] if item["name"] != "rmbg2"]
378
+ return identity
379
+
380
+
381
+ def replace_owned_json(path: Path, value: dict, previous: dict, *, check_only: bool = False) -> None:
382
+ """Atomically extend a generated file, rejecting user edits and symlinks."""
383
+ text = json_text(value)
384
+ if not path.exists() and not path.is_symlink():
385
+ if not check_only:
386
+ write_once(path, text)
387
+ return
388
+ if path.is_symlink() or not path.is_file() or path.read_text() not in {json_text(previous), text}:
389
+ raise FileExistsError(f"refusing to overwrite conflicting output: {path}")
390
+ if check_only or path.read_text() == text:
391
+ return
392
+ with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, prefix="." + path.name + ".", delete=False) as stream:
393
+ temporary = Path(stream.name)
394
+ try:
395
+ stream.write(text)
396
+ stream.close()
397
+ os.replace(temporary, path)
398
+ finally:
399
+ temporary.unlink(missing_ok=True)
400
+
401
+
402
+ def prepare(plan: dict) -> Path:
403
+ plan = copy.deepcopy(plan)
404
+ output = Path(plan["output_dir"])
405
+ marker = output / MARKER
406
+ if output.exists() and not output.is_dir():
407
+ raise NotADirectoryError(output)
408
+ if output.is_dir() and any(output.iterdir()) and not marker.is_file():
409
+ raise FileExistsError(f"output directory is not owned by prepare_models.py; choose an empty --output-dir: {output}")
410
+ previous = None
411
+ identity = plan_identity(plan)
412
+ if marker.exists():
413
+ if marker.is_symlink():
414
+ raise FileExistsError(f"refusing to overwrite conflicting output: {marker}")
415
+ previous = json.loads(marker.read_text())
416
+ if without_rmbg(previous) != without_rmbg(identity):
417
+ raise FileExistsError(f"refusing to overwrite conflicting output: {marker}")
418
+ if "rmbg2" in previous["models"]:
419
+ old_downloads = [item for item in previous["downloads"] if item["name"] == "rmbg2"]
420
+ if "rmbg2" in identity["models"]:
421
+ new_downloads = [item for item in identity["downloads"] if item["name"] == "rmbg2"]
422
+ if identity["models"]["rmbg2"] != previous["models"]["rmbg2"] or old_downloads != new_downloads:
423
+ raise FileExistsError(f"refusing to overwrite conflicting output: {marker}")
424
+ else:
425
+ # Keep an existing optional dependency when switching to RGBA.
426
+ plan["models"] = {**previous["models"], **plan["models"]}
427
+ for operation in old_downloads:
428
+ operation = copy.deepcopy(operation)
429
+ if plan.get("local_files_only") or any(item.get("local_files_only") for item in plan["downloads"]):
430
+ operation["local_files_only"] = True
431
+ if plan.get("cache_dir") is not None:
432
+ operation["cache_dir"] = plan["cache_dir"]
433
+ position = next(index for index, item in enumerate(previous["downloads"]) if item["name"] == "rmbg2")
434
+ plan["downloads"].insert(position, operation)
435
+ identity = plan_identity(plan)
436
+ else:
437
+ write_once(marker, json_text(identity))
438
+ paths = {name: Path(source["path"]) if source["kind"] == "local" else output / source["path"]
439
+ for name, source in plan["models"].items()}
440
+ existing_inputs_complete = False
441
+ if any(operation.get("local_files_only") for operation in plan["downloads"]):
442
+ try:
443
+ validate_inputs(dict(paths))
444
+ existing_inputs_complete = True
445
+ except (OSError, ValueError, KeyError):
446
+ pass
447
+ # Check publisher-controlled access before downloading the large public bases.
448
+ for operation in sorted(plan["downloads"], key=lambda item: not item.get("gated", False)):
449
+ if operation.get("local_files_only") and existing_inputs_complete:
450
+ continue
451
+ download(operation, output)
452
+ pipeline, checkpoint = validate_inputs(paths)
453
+ geometry = output / "geometry"
454
+ for key in GEOMETRY_KEYS:
455
+ stem = pipeline["args"]["models"][key]
456
+ write_once(geometry / (stem + ".json"), (paths["geometry"] / (stem + ".json")).read_text())
457
+ link_once(geometry / (stem + ".safetensors"), paths["geometry"] / (stem + ".safetensors"),
458
+ relative=plan["models"]["geometry"]["kind"] != "local")
459
+ write_once(geometry / "pipeline.json", json_text(pipeline))
460
+ link_once(geometry / "dino", paths["dino"], relative=plan["models"]["dino"]["kind"] != "local")
461
+ runtime = _pipeline.runtime_root(ROOT)
462
+ require_file(runtime / "naf/hubconf.py")
463
+ link_once(output / "torch/hub/valeoai_NAF_main", runtime / "naf", relative=True)
464
+ link_once(output / "torch/hub/checkpoints/naf_release.pth", paths["naf"], relative=plan["models"]["naf"]["kind"] != "local")
465
+
466
+ def config_path(path: Path, local: bool = False) -> str:
467
+ return str(path) if local else os.path.relpath(path, output)
468
+
469
+ def model_path(name: str) -> str:
470
+ return config_path(paths[name], plan["models"][name]["kind"] == "local")
471
+
472
+ local_names = [key for key, value in plan["models"].items() if value["kind"] == "local"]
473
+ config = {"moge_model": model_path("moge2"), "rmbg_model": model_path("rmbg2") if "rmbg2" in paths else None,
474
+ "geometry_package": config_path(runtime / "geometry"), "geometry_model": "geometry",
475
+ "qwen_runtime": config_path(runtime / "qwen"),
476
+ "material_base": model_path("qwen-base"), "material_model": model_path("material"),
477
+ "pretrained": model_path("completion"), "torch_home": "torch", "trellis_root": model_path("vae"),
478
+ "model_config": config_path(paths["completion"] / "config.json", "completion" in local_names),
479
+ "checkpoint": config_path(checkpoint, "completion" in local_names),
480
+ "defaults": plan["pipeline"]["defaults"],
481
+ "_meta": {"schema_version": 1, "local_overrides": local_names,
482
+ "portability": "Relative paths resolve from models.json. Move with the source package; local overrides remain external dependencies.",
483
+ "validation": "File presence and shard inventory only; run scripts/check_runtime.py before inference."}}
484
+ result = output / "models.json"
485
+ if "rmbg2" in identity["models"]:
486
+ old_config = copy.deepcopy(config)
487
+ old_config["rmbg_model"] = None
488
+ old_config["_meta"]["local_overrides"] = [name for name in old_config["_meta"]["local_overrides"] if name != "rmbg2"]
489
+ # Check for user edits before recording the extension. Recording the
490
+ # dependency first lets either input type recover an interrupted publish.
491
+ replace_owned_json(result, config, old_config, check_only=True)
492
+ if previous is not None and previous != identity:
493
+ replace_owned_json(marker, identity, previous)
494
+ replace_owned_json(result, config, old_config)
495
+ else:
496
+ write_once(result, json_text(config))
497
+ return result
498
+
499
+
500
+ def prepare_pipeline(source: str | Path, *, output_dir: str | Path | None = None,
501
+ revision: str | None = None, cache_dir: str | Path | None = None,
502
+ local_files_only: bool = False,
503
+ with_rmbg: bool = False, overrides: dict | None = None) -> Path:
504
+ """Resolve a release and generate the runtime bindings used by inference."""
505
+ source = Path(source).expanduser().absolute() if isinstance(source, Path) else source
506
+ # A manifest filename denotes its release root as well as the selected config.
507
+ local = Path(source).expanduser()
508
+ manifest_file = local.is_file() or (not str(source).startswith(("https://", "hf://")) and local.suffix == ".json")
509
+ argv = ["--repo", str(local.absolute().parent if manifest_file else source)]
510
+ if manifest_file:
511
+ argv.extend(("--pipeline", str(local)))
512
+ if output_dir is not None:
513
+ argv.extend(("--output-dir", str(output_dir)))
514
+ if revision is not None:
515
+ argv.extend(("--revision", revision))
516
+ if cache_dir is not None:
517
+ argv.extend(("--cache-dir", str(cache_dir)))
518
+ if local_files_only:
519
+ argv.append("--local-files-only")
520
+ if with_rmbg:
521
+ argv.append("--with-rmbg")
522
+ flags = {"completion": "completion", "material": "material", "qwen-base": "qwen-base",
523
+ "geometry": "geometry", "dino": "dino", "vae": "vae", "moge2": "moge",
524
+ "rmbg2": "rmbg", "naf": "naf-weights",
525
+ "completion_revision": "completion-revision", "material_revision": "material-revision"}
526
+ for key, value in (overrides or {}).items():
527
+ if key not in flags:
528
+ raise ValueError(f"unknown pipeline model override: {key}")
529
+ argv.extend(("--" + flags[key], str(value)))
530
+ return prepare(build_plan(parse_args(argv), resolve_remote=True))
531
+
532
+
533
+ def main(argv: list[str] | None = None) -> int:
534
+ args = parse_args(argv)
535
+ try:
536
+ plan = build_plan(args, resolve_remote=not args.dry_run)
537
+ if args.dry_run:
538
+ print(json_text(plan), end="")
539
+ else:
540
+ result = prepare(plan)
541
+ print(f"Prepared {result}")
542
+ if any(value["kind"] == "local" for value in plan["models"].values()):
543
+ print("Local overrides remain external dependencies; keep their original paths available.")
544
+ print(f"Next: python scripts/check_runtime.py --config {result}")
545
+ except (OSError, ValueError, KeyError, RuntimeError) as error:
546
+ print(f"error: {error}", file=sys.stderr)
547
+ return 2
548
+ return 0
549
+
550
+
551
+ if __name__ == "__main__":
552
+ raise SystemExit(main())
scripts/run_example.py CHANGED
@@ -1,116 +1,156 @@
1
- #!/usr/bin/env python3
2
- """Run the bundled RGBA example with prepared models and an isolated source path."""
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import json
7
- import os
8
- from pathlib import Path
9
- import shlex
10
- import subprocess
11
- import sys
12
-
13
- ROOT = Path(__file__).resolve().parents[1]
14
- MODEL_KEYS = ("moge_model", "rmbg_model", "geometry_package", "geometry_model", "qwen_runtime",
15
- "qwen_deps", "material_base", "material_model", "pretrained", "model_config", "checkpoint", "trellis_root")
16
- PATH_KEYS = (*MODEL_KEYS, "torch_home")
17
-
18
-
19
- def read_config(path: Path) -> dict:
20
- """Resolve paths relative to models.json, preserving metadata for preflight."""
21
- path = Path(path).expanduser().resolve()
22
- config = json.loads(path.read_text())
23
- if not isinstance(config, dict):
24
- raise ValueError("models.json must contain an object")
25
- for key in PATH_KEYS:
26
- value = config.get(key)
27
- if value is None and key in ("rmbg_model", "model_config", "checkpoint"):
28
- continue
29
- if not isinstance(value, str) or not value:
30
- raise ValueError(f"models.json requires a nonempty {key} path")
31
- resolved = Path(value).expanduser()
32
- config[key] = str(resolved if resolved.is_absolute() else (path.parent / resolved).resolve())
33
- return config
34
-
35
-
36
- def parse_args(argv: list[str] | None = None) -> tuple[argparse.Namespace, list[str]]:
37
- parser = argparse.ArgumentParser(description=__doc__)
38
- parser.add_argument("--config", type=Path, default=ROOT / "ckpts/ready/models.json")
39
- parser.add_argument("--input", type=Path, help="default: bundled assets/pitcher_plant.png")
40
- parser.add_argument("--output", type=Path, required=True, help="destination .glb")
41
- parser.add_argument("--use-input-alpha", action="store_true", help="explicitly require the input foreground alpha")
42
- parser.add_argument("--attention", choices=("auto", "flash_attn_3", "flash_attn", "sdpa", "xformers"), default="auto")
43
- parser.add_argument("--low-vram", action=argparse.BooleanOptionalAction, default=None)
44
- parser.add_argument("--print-command", action="store_true", help="print without creating files or launching inference")
45
- args, extra = parser.parse_known_args(argv)
46
- if extra[:1] == ["--"]:
47
- extra = extra[1:]
48
- protected = {"--" + key.replace("_", "-") for key in MODEL_KEYS}
49
- protected.update(("--material-backend", "--local-files-only", "--cache-dir", "--revision", "--material-revision", "--geometry-revision"))
50
- if any(value.split("=", 1)[0] in protected for value in extra):
51
- parser.error("model paths and offline settings come from --config; additional arguments cannot override them")
52
- return args, extra
53
-
54
-
55
- def has_foreground_alpha(path: Path) -> bool:
56
- from PIL import Image
57
- with Image.open(path) as image:
58
- # The inference foreground contract accepts only an actual RGBA
59
- # input; converting LA or palette transparency here would hide a
60
- # mode that the inference process itself rejects.
61
- if image.mode != "RGBA":
62
- return False
63
- low, high = image.getchannel("A").getextrema()
64
- return low < 255 and high > 0
65
-
66
-
67
- def build_command(args: argparse.Namespace, extra: list[str], config: dict) -> tuple[list[str], dict[str, str]]:
68
- image = (args.input or ROOT / "assets/pitcher_plant.png").expanduser().resolve()
69
- if not image.is_file():
70
- raise FileNotFoundError(f"input image is missing: {image}")
71
- use_alpha = args.use_input_alpha or args.input is None or has_foreground_alpha(image)
72
- if not use_alpha and not config.get("rmbg_model"):
73
- raise ValueError("RGB or opaque input requires RMBG: prepare with --with-rmbg or --rmbg, then use that models.json")
74
- command = [sys.executable, str(ROOT / "infer.py"), "--input", str(image), "--output",
75
- str(args.output.expanduser().resolve()), "--material-backend", "qwen-image", "--local-files-only",
76
- "--attention", args.attention]
77
- if use_alpha:
78
- command.append("--use-input-alpha")
79
- if args.low_vram is not None:
80
- command.append("--low-vram" if args.low_vram else "--no-low-vram")
81
- for key in MODEL_KEYS:
82
- if config.get(key):
83
- command.extend(("--" + key.replace("_", "-"), config[key]))
84
- command.extend(extra)
85
- environment = os.environ.copy()
86
- environment.update(TORCH_HOME=config["torch_home"], HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1",
87
- HF_DATASETS_OFFLINE="1", PYTHONNOUSERSITE="1",
88
- PYTHONPATH=os.pathsep.join(str(path) for path in
89
- (ROOT, ROOT / "deployment/runtime/moge", ROOT / "deployment/runtime/utils3d")))
90
- if args.attention == "auto":
91
- environment.pop("ATTN_BACKEND", None)
92
- environment.pop("SPARSE_ATTN_BACKEND", None)
93
- else:
94
- environment.update(ATTN_BACKEND=args.attention, SPARSE_ATTN_BACKEND=args.attention)
95
- environment.setdefault("OMP_NUM_THREADS", "4")
96
- environment.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
97
- return command, environment
98
-
99
-
100
- def main(argv: list[str] | None = None) -> int:
101
- args, extra = parse_args(argv)
102
- try:
103
- command, environment = build_command(args, extra, read_config(args.config))
104
- if args.print_command:
105
- print(shlex.join(command))
106
- return 0
107
- args.output.expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
108
- subprocess.run(command, cwd=ROOT, env=environment, check=True)
109
- except (OSError, ValueError, subprocess.CalledProcessError) as error:
110
- print(f"error: {error}", file=sys.stderr)
111
- return 2
112
- return 0
113
-
114
-
115
- if __name__ == "__main__":
116
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run the bundled RGBA example with prepared models and an isolated source path."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib.util
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import shlex
11
+ import subprocess
12
+ import sys
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ MODEL_KEYS = ("moge_model", "rmbg_model", "geometry_package", "geometry_model", "qwen_runtime",
16
+ "qwen_deps", "material_base", "material_model", "pretrained", "model_config", "checkpoint", "trellis_root")
17
+ PATH_KEYS = (*MODEL_KEYS, "torch_home")
18
+
19
+
20
+ def _pipeline_config():
21
+ """Load the lightweight shared helpers without importing GPU model modules."""
22
+ spec = importlib.util.spec_from_file_location("supratex_pipeline_config", ROOT / "supratex/pipeline_config.py")
23
+ module = importlib.util.module_from_spec(spec)
24
+ spec.loader.exec_module(module)
25
+ return module
26
+
27
+
28
+ def inference_defaults(config: dict | None = None) -> dict:
29
+ """Read defaults from the pipeline without importing GPU model modules."""
30
+ module = _pipeline_config()
31
+ defaults = module.default_pipeline()["defaults"]
32
+ configured = (config or {}).get("defaults", {})
33
+ if not isinstance(configured, dict):
34
+ raise ValueError("models.json defaults must contain an object")
35
+ defaults.update(configured)
36
+ return module.validate_defaults(defaults)
37
+
38
+
39
+ def read_config(path: Path) -> dict:
40
+ """Resolve paths relative to models.json, preserving metadata for preflight."""
41
+ path = Path(path).expanduser().resolve()
42
+ config = json.loads(path.read_text())
43
+ if not isinstance(config, dict):
44
+ raise ValueError("models.json must contain an object")
45
+ for key in PATH_KEYS:
46
+ value = config.get(key)
47
+ if value is None and key in ("rmbg_model", "qwen_deps", "model_config", "checkpoint"):
48
+ continue
49
+ if not isinstance(value, str) or not value:
50
+ raise ValueError(f"models.json requires a nonempty {key} path")
51
+ resolved = Path(value).expanduser()
52
+ # Normalize without following links: cache-backed files link to
53
+ # opaque blobs, and weight validation needs the logical file name.
54
+ config[key] = os.path.normpath(resolved if resolved.is_absolute() else path.parent / resolved)
55
+ return config
56
+
57
+
58
+ def parse_args(argv: list[str] | None = None) -> tuple[argparse.Namespace, list[str]]:
59
+ parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
60
+ parser.add_argument("--config", type=Path, default=ROOT / "ckpts/ready/models.json")
61
+ parser.add_argument("--input", type=Path, help="default: assets/spiny_whelk_shell.png, or "
62
+ "assets/cinema_signage.png with --dual-view")
63
+ parser.add_argument("--output", type=Path, required=True, help="destination .glb")
64
+ parser.add_argument("--use-input-alpha", action="store_true", help="explicitly require the input foreground alpha")
65
+ parser.add_argument("--attention", choices=("auto", "flash_attn_3", "flash_attn", "sdpa", "xformers"),
66
+ help="override the prepared pipeline's attention default")
67
+ parser.add_argument("--low-vram", action=argparse.BooleanOptionalAction, default=None)
68
+ parser.add_argument("--print-command", action="store_true", help="print without creating files or launching inference")
69
+ args, extra = parser.parse_known_args(argv)
70
+ if extra[:1] == ["--"]:
71
+ extra = extra[1:]
72
+ protected = {"--" + key.replace("_", "-") for key in MODEL_KEYS}
73
+ protected.update(("--material-backend", "--local-files-only", "--cache-dir", "--revision", "--material-revision", "--geometry-revision",
74
+ "--pipeline", "--pipeline-revision", "--models-dir"))
75
+ if any(value.split("=", 1)[0] in protected for value in extra):
76
+ parser.error("model paths and offline settings come from --config; additional arguments cannot override them")
77
+ return args, extra
78
+
79
+
80
+ def has_foreground_alpha(path: Path) -> bool:
81
+ from PIL import Image
82
+ with Image.open(path) as image:
83
+ # The inference foreground contract accepts only an actual RGBA
84
+ # input; converting LA or palette transparency here would hide a
85
+ # mode that the inference process itself rejects.
86
+ if image.mode != "RGBA":
87
+ return False
88
+ low, high = image.getchannel("A").getextrema()
89
+ return low < 255 and high > 0
90
+
91
+
92
+ def default_input(dual_view: bool) -> Path:
93
+ """Bundled example: the whelk shell, or the cinema signage for dual-view."""
94
+ return ROOT / "assets" / ("cinema_signage.png" if dual_view else "spiny_whelk_shell.png")
95
+
96
+
97
+ def build_command(args: argparse.Namespace, extra: list[str], config: dict) -> tuple[list[str], dict[str, str]]:
98
+ defaults = inference_defaults(config)
99
+ attention = args.attention or defaults["attention"]
100
+ image = (args.input or default_input("--dual-view" in extra)).expanduser().resolve()
101
+ if not image.is_file():
102
+ raise FileNotFoundError(f"input image is missing: {image}")
103
+ use_alpha = args.use_input_alpha or has_foreground_alpha(image)
104
+ if not use_alpha and not config.get("rmbg_model"):
105
+ raise ValueError("RGB or opaque input requires RMBG: prepare with --with-rmbg or --rmbg, then use that models.json")
106
+ command = [sys.executable, str(ROOT / "infer.py"), "--input", str(image), "--output",
107
+ str(args.output.expanduser().resolve()), "--material-backend", "qwen-image", "--local-files-only",
108
+ "--attention", attention]
109
+ for key in ("seed", "texture_size", "decimation_target", "texture_format"):
110
+ command.extend(("--" + key.replace("_", "-"), str(defaults[key])))
111
+ if use_alpha:
112
+ command.append("--use-input-alpha")
113
+ if args.low_vram is not None:
114
+ command.append("--low-vram" if args.low_vram else "--no-low-vram")
115
+ for key in MODEL_KEYS:
116
+ if config.get(key):
117
+ command.extend(("--" + key.replace("_", "-"), config[key]))
118
+ command.extend(extra)
119
+ return command, runtime_environment(config, attention)
120
+
121
+
122
+ def runtime_environment(config: dict, attention: str) -> dict[str, str]:
123
+ """Use prepared caches and bundled sources consistently in both entrypoints."""
124
+ runtime = _pipeline_config().runtime_root(ROOT)
125
+ environment = os.environ.copy()
126
+ environment.update(TORCH_HOME=config["torch_home"], HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1",
127
+ HF_DATASETS_OFFLINE="1", PYTHONNOUSERSITE="1",
128
+ PYTHONPATH=os.pathsep.join(str(path) for path in
129
+ (ROOT, runtime / "moge", runtime / "utils3d")))
130
+ if attention == "auto":
131
+ environment.pop("ATTN_BACKEND", None)
132
+ environment.pop("SPARSE_ATTN_BACKEND", None)
133
+ else:
134
+ environment.update(ATTN_BACKEND=attention, SPARSE_ATTN_BACKEND=attention)
135
+ environment.setdefault("OMP_NUM_THREADS", "4")
136
+ environment.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
137
+ return environment
138
+
139
+
140
+ def main(argv: list[str] | None = None) -> int:
141
+ args, extra = parse_args(argv)
142
+ try:
143
+ command, environment = build_command(args, extra, read_config(args.config))
144
+ if args.print_command:
145
+ print(shlex.join(command))
146
+ return 0
147
+ args.output.expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
148
+ subprocess.run(command, cwd=ROOT, env=environment, check=True)
149
+ except (OSError, ValueError, subprocess.CalledProcessError) as error:
150
+ print(f"error: {error}", file=sys.stderr)
151
+ return 2
152
+ return 0
153
+
154
+
155
+ if __name__ == "__main__":
156
+ raise SystemExit(main())
supratex/frontend/camera.py CHANGED
@@ -1,79 +1,76 @@
1
- """MoGe-2 camera adapter for the canonical Supra-Tex camera JSON contract."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import importlib
6
- import json
7
- import math
8
- from pathlib import Path
9
-
10
- import numpy as np
11
- from PIL import Image
12
-
13
-
14
- def camera_from_intrinsics(intrinsics: np.ndarray, width: int, height: int, *, resolution_level: int) -> dict:
15
- """Translate normalized MoGe intrinsics to the texture-stage camera fields."""
16
- intrinsics = np.asarray(intrinsics, dtype=np.float64)
17
- if intrinsics.shape != (3, 3):
18
- raise ValueError("MoGe intrinsics must be 3x3")
19
- fx = float(intrinsics[0, 0]) * width
20
- if not math.isfinite(fx) or fx <= 0.0:
21
- raise ValueError("MoGe returned an invalid focal length")
22
- camera_angle_x = 2.0 * math.atan(width / (2.0 * fx))
23
- focal_pixels = 16.0 / math.tan(camera_angle_x / 2.0) * 512.0 / 32.0
24
- distance = focal_pixels * -0.5 / -256.0
25
- if not math.isfinite(distance) or distance <= 0.0:
26
- raise ValueError("MoGe-derived camera distance is invalid")
27
- return {"camera_angle_x": float(camera_angle_x), "distance": float(distance), "mesh_scale": 1.0,
28
- "image_width": int(width), "image_height": int(height), "camera_estimator": "MoGe-2",
29
- "moge_resolution_level": int(resolution_level)}
30
-
31
-
32
- def estimate(image: Path, model_path: Path, module_name: str = "moge.model.v2", resolution_level: int = 9,
33
- device: str = "cuda", *, model=None) -> dict:
34
- if not 0 <= resolution_level <= 9:
35
- raise ValueError("resolution_level must be in [0, 9]")
36
- module = importlib.import_module(module_name)
37
- model_cls = getattr(module, "MoGeModel", None)
38
- if model_cls is None:
39
- raise RuntimeError(f"{module_name} does not expose MoGeModel")
40
- import torch
41
- checkpoint = model_path / "model.pt" if model_path.is_dir() else model_path
42
- if not checkpoint.is_file():
43
- raise FileNotFoundError(checkpoint)
44
- resident = model is not None
45
- if model is None:
46
- model = model_cls.from_pretrained(str(checkpoint)).to(device).eval()
47
- tensor = result = None
48
- try:
49
- rgb = Image.open(image).convert("RGB")
50
- width, height = rgb.size
51
- tensor = torch.from_numpy(np.asarray(rgb, dtype=np.float32) / 255.0).permute(2, 0, 1).to(device)
52
- with torch.inference_mode():
53
- result = model.infer(tensor, resolution_level=resolution_level, force_projection=True, apply_mask=True)
54
- return camera_from_intrinsics(result["intrinsics"].squeeze().float().cpu().numpy(), width, height,
55
- resolution_level=resolution_level)
56
- finally:
57
- if not resident:
58
- model.cpu()
59
- del tensor, result, model
60
- if str(device).startswith("cuda"):
61
- torch.cuda.empty_cache()
62
-
63
-
64
- def main() -> None:
65
- parser = argparse.ArgumentParser(description="Estimate canonical Supra-Tex camera parameters with MoGe-2.")
66
- parser.add_argument("--image", type=Path, required=True)
67
- parser.add_argument("--model", type=Path, required=True)
68
- parser.add_argument("--module", default="moge.model.v2")
69
- parser.add_argument("--resolution-level", type=int, default=9,
70
- help="MoGe-2 detail level from 0 (fastest) to 9 (highest).")
71
- parser.add_argument("--device", default="cuda")
72
- parser.add_argument("--output", type=Path, required=True)
73
- args = parser.parse_args()
74
- args.output.parent.mkdir(parents=True, exist_ok=True)
75
- args.output.write_text(json.dumps(estimate(args.image, args.model, args.module, args.resolution_level, args.device), indent=2) + "\n")
76
-
77
-
78
- if __name__ == "__main__":
79
- main()
 
1
+ """MoGe-2 camera adapter for the canonical Supra-Tex camera JSON contract."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import importlib
6
+ import json
7
+ import math
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ from PIL import Image
12
+
13
+
14
+ def camera_from_intrinsics(intrinsics: np.ndarray, width: int, height: int, *, resolution_level: int) -> dict:
15
+ """Translate normalized MoGe intrinsics to the texture-stage camera fields."""
16
+ intrinsics = np.asarray(intrinsics, dtype=np.float64)
17
+ if intrinsics.shape != (3, 3):
18
+ raise ValueError("MoGe intrinsics must be 3x3")
19
+ fx = float(intrinsics[0, 0]) * width
20
+ if not math.isfinite(fx) or fx <= 0.0:
21
+ raise ValueError("MoGe returned an invalid focal length")
22
+ camera_angle_x = 2.0 * math.atan(width / (2.0 * fx))
23
+ focal_pixels = 16.0 / math.tan(camera_angle_x / 2.0) * 512.0 / 32.0
24
+ distance = focal_pixels * -0.5 / -256.0
25
+ if not math.isfinite(distance) or distance <= 0.0:
26
+ raise ValueError("MoGe-derived camera distance is invalid")
27
+ return {"camera_angle_x": float(camera_angle_x), "distance": float(distance), "mesh_scale": 1.0,
28
+ "image_width": int(width), "image_height": int(height), "camera_estimator": "MoGe-2",
29
+ "moge_resolution_level": int(resolution_level)}
30
+
31
+
32
+ def estimate(image: Path, model_path: Path, module_name: str = "moge.model.v2", resolution_level: int = 9,
33
+ device: str = "cuda") -> dict:
34
+ if not 0 <= resolution_level <= 9:
35
+ raise ValueError("resolution_level must be in [0, 9]")
36
+ module = importlib.import_module(module_name)
37
+ model_cls = getattr(module, "MoGeModel", None)
38
+ if model_cls is None:
39
+ raise RuntimeError(f"{module_name} does not expose MoGeModel")
40
+ import torch
41
+ checkpoint = model_path / "model.pt" if model_path.is_dir() else model_path
42
+ if not checkpoint.is_file():
43
+ raise FileNotFoundError(checkpoint)
44
+ model = model_cls.from_pretrained(str(checkpoint)).to(device).eval()
45
+ tensor = result = None
46
+ try:
47
+ rgb = Image.open(image).convert("RGB")
48
+ width, height = rgb.size
49
+ tensor = torch.from_numpy(np.asarray(rgb, dtype=np.float32) / 255.0).permute(2, 0, 1).to(device)
50
+ with torch.inference_mode():
51
+ result = model.infer(tensor, resolution_level=resolution_level, force_projection=True, apply_mask=True)
52
+ return camera_from_intrinsics(result["intrinsics"].squeeze().float().cpu().numpy(), width, height,
53
+ resolution_level=resolution_level)
54
+ finally:
55
+ model.cpu()
56
+ del tensor, result, model
57
+ if str(device).startswith("cuda"):
58
+ torch.cuda.empty_cache()
59
+
60
+
61
+ def main() -> None:
62
+ parser = argparse.ArgumentParser(description="Estimate canonical Supra-Tex camera parameters with MoGe-2.")
63
+ parser.add_argument("--image", type=Path, required=True)
64
+ parser.add_argument("--model", type=Path, required=True)
65
+ parser.add_argument("--module", default="moge.model.v2")
66
+ parser.add_argument("--resolution-level", type=int, default=9,
67
+ help="MoGe-2 detail level from 0 (fastest) to 9 (highest).")
68
+ parser.add_argument("--device", default="cuda")
69
+ parser.add_argument("--output", type=Path, required=True)
70
+ args = parser.parse_args()
71
+ args.output.parent.mkdir(parents=True, exist_ok=True)
72
+ args.output.write_text(json.dumps(estimate(args.image, args.model, args.module, args.resolution_level, args.device), indent=2) + "\n")
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
 
 
 
supratex/frontend/firered.py CHANGED
@@ -1,126 +1,135 @@
1
- """FireRed Image-to-Material Translation adapter.
2
-
3
- The supplied package remains the sole FireRed implementation: this adapter
4
- creates its documented JSONL item and runs only its public ``infer.py``.
5
- """
6
- from __future__ import annotations
7
-
8
- import argparse
9
- import json
10
- import shutil
11
- import subprocess
12
- import sys
13
- from pathlib import Path
14
-
15
-
16
- def item_for(condition: Path, key: str) -> dict[str, object]:
17
- """Build the public package record without inventing material supervision."""
18
- condition = condition.resolve()
19
- return {
20
- "key": key, "dataset": "user", "sha": key, "asset_id": key, "view": 0, "cond": str(condition),
21
- # The public package validates these schema slots but prediction only
22
- # consumes ``cond``. Point them at the supplied condition so there is
23
- # no fabricated or private GT material file in an inference request.
24
- "albedo_gt": str(condition), "mra_gt": str(condition), "normal_gt": str(condition), "normal_valid": str(condition),
25
- "instr_albedo": "Convert this rendered image into its albedo (base color) map: remove all lighting, shading, shadows and highlights, keep only the intrinsic surface colors on a black background.",
26
- "instr_mra": "Convert this rendered image into its material property map: encode metallic in the red channel, roughness in the green channel, and material alpha in the blue channel, on a black background.",
27
- "instr_normal": "Convert this rendered image into its camera-space surface normal map: encode the normal direction at every pixel as RGB with x pointing right, y pointing up and z toward the camera, on a flat gray background.", "split": "eval",
28
- }
29
-
30
-
31
- def write_manifest(condition: Path, path: Path, *, key: str = "input") -> Path:
32
- if not condition.is_file():
33
- raise FileNotFoundError(condition)
34
- path.parent.mkdir(parents=True, exist_ok=True)
35
- path.write_text(json.dumps(item_for(condition, key), sort_keys=True) + "\n", encoding="utf-8")
36
- return path
37
-
38
-
39
- def run(package: Path, base_model: str | Path, condition: Path, output: Path, manifest: Path | None = None,
40
- *, key: str = "input", weights: Path | None = None, spec: Path | None = None,
41
- checkpoint_manifest: Path | None = None, material_model: str | Path | None = None,
42
- revision: str | None = None, cache_dir: Path | None = None,
43
- local_files_only: bool = False) -> tuple[Path, Path]:
44
- if not key or Path(key).name != key or key in {".", ".."} or "\\" in key:
45
- raise ValueError("key must be a single safe filename component")
46
- from supratex.weights import resolve_model_dir
47
- if material_model is not None:
48
- if any(value is not None for value in (weights, spec, checkpoint_manifest)):
49
- raise ValueError("--material-model cannot be combined with explicit material artifact paths")
50
- root = resolve_model_dir(material_model, revision=revision, cache_dir=cache_dir,
51
- local_files_only=local_files_only,
52
- allow_patterns=["full_transformer.safetensors", "joint_contract.json", "checkpoint_complete.json"])
53
- weights, spec, checkpoint_manifest = (root / name for name in
54
- ("full_transformer.safetensors", "joint_contract.json", "checkpoint_complete.json"))
55
- for path in (weights, spec, checkpoint_manifest):
56
- if not path.is_file():
57
- raise FileNotFoundError(path)
58
- base_model = resolve_model_dir(base_model, cache_dir=cache_dir, local_files_only=local_files_only)
59
- package, condition, output = (p.resolve() for p in (package, condition, output))
60
- if manifest is not None:
61
- manifest = manifest.resolve()
62
- entry = package / "infer.py"
63
- if not entry.is_file():
64
- raise FileNotFoundError(f"FireRed public entrypoint is missing: {entry}")
65
- if not base_model.exists():
66
- raise FileNotFoundError(base_model)
67
- output.mkdir(parents=True, exist_ok=True)
68
- targets = [output / f"{key}_{channel}.png" for channel in ("albedo", "mra", "normal")]
69
- targets += [output / name for name in ("albedo.png", "mra.png", "firered_albedo.png", "firered_mra.png")]
70
- if any(path.exists() for path in targets):
71
- raise FileExistsError("material outputs already exist; select a fresh output directory")
72
- manifest = manifest or output / "firered_input.jsonl"
73
- if manifest == output / "firered_input.jsonl":
74
- write_manifest(condition, manifest, key=key)
75
- elif not manifest.is_file():
76
- raise FileNotFoundError(manifest)
77
- command = [sys.executable, str(entry), "--base-model", str(base_model), "--items-jsonl", str(manifest), "--out-dir", str(output)]
78
- bundled = package.resolve() == (Path(__file__).resolve().parent / "material_model").resolve()
79
- if bundled:
80
- if not all((weights, spec, checkpoint_manifest)):
81
- raise ValueError("bundled material model requires --weights, --spec, and --checkpoint-manifest")
82
- command += ["--weights", str(weights), "--spec", str(spec), "--checkpoint-manifest", str(checkpoint_manifest)]
83
- subprocess.run(command, check=True)
84
- albedo, mra = output / f"{key}_albedo.png", output / f"{key}_mra.png"
85
- if not albedo.is_file() or not mra.is_file():
86
- raise FileNotFoundError("FireRed did not emit the expected albedo and MRA maps")
87
- from PIL import Image
88
- for generated in (albedo, mra):
89
- with Image.open(generated) as image:
90
- if image.mode != "RGB" or image.size != (1024, 1024):
91
- raise ValueError(f"material prediction must be RGB 1024x1024: {generated}")
92
- canonical_albedo, canonical_mra = output / "firered_albedo.png", output / "firered_mra.png"
93
- shutil.copyfile(albedo, canonical_albedo)
94
- shutil.copyfile(mra, canonical_mra)
95
- shutil.copyfile(albedo, output / "albedo.png")
96
- shutil.copyfile(mra, output / "mra.png")
97
- return canonical_albedo, canonical_mra
98
-
99
-
100
- def main() -> None:
101
- parser = argparse.ArgumentParser(description="Run FireRed Image-to-Material Translation through its packaged public entrypoint.")
102
- parser.add_argument("--package", type=Path, default=Path(__file__).resolve().parent / "material_model",
103
- help="Bundled material-model package or a compatible package containing infer.py")
104
- parser.add_argument("--base-model", required=True, help="local FireRed base directory or Hugging Face namespace/repo")
105
- parser.add_argument("--material-model", help="local material-model directory or Hugging Face namespace/repo")
106
- parser.add_argument("--revision", help="material-model Hugging Face revision")
107
- parser.add_argument("--cache-dir", type=Path)
108
- parser.add_argument("--local-files-only", action="store_true")
109
- parser.add_argument("--condition", type=Path, required=True)
110
- parser.add_argument("--manifest", type=Path,
111
- help="Optional package-contract JSONL; omitted builds a validation-only local record.")
112
- parser.add_argument("--key", default="input")
113
- parser.add_argument("--weights", type=Path, help="External factorized material-model weights for the bundled package.")
114
- parser.add_argument("--spec", type=Path, help="External factorized material-model checkpoint contract.")
115
- parser.add_argument("--checkpoint-manifest", type=Path, help="External material-model completion manifest.")
116
- parser.add_argument("--output", type=Path, required=True)
117
- args = parser.parse_args()
118
- albedo, mra = run(args.package, args.base_model, args.condition, args.output, args.manifest, key=args.key,
119
- weights=args.weights, spec=args.spec, checkpoint_manifest=args.checkpoint_manifest,
120
- material_model=args.material_model, revision=args.revision, cache_dir=args.cache_dir,
121
- local_files_only=args.local_files_only)
122
- print(f"albedo={albedo}\nmra={mra}")
123
-
124
-
125
- if __name__ == "__main__":
126
- main()
 
 
 
 
 
 
 
 
 
 
1
+ """FireRed Image-to-Material Translation adapter.
2
+
3
+ The supplied package remains the sole FireRed implementation: this adapter
4
+ creates its documented JSONL item, runs its public ``infer.py``, and fixes
5
+ the decoded MR blue channel to opaque for the 3D material encoder.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ def item_for(condition: Path, key: str) -> dict[str, object]:
18
+ """Build the public package record without inventing material supervision."""
19
+ condition = condition.resolve()
20
+ return {
21
+ "key": key, "dataset": "user", "sha": key, "asset_id": key, "view": 0, "cond": str(condition),
22
+ # The public package validates these schema slots but prediction only
23
+ # consumes ``cond``. Point them at the supplied condition so there is
24
+ # no fabricated or private GT material file in an inference request.
25
+ "albedo_gt": str(condition), "mra_gt": str(condition), "normal_gt": str(condition), "normal_valid": str(condition),
26
+ "instr_albedo": "Convert this rendered image into its albedo (base color) map: remove all lighting, shading, shadows and highlights, keep only the intrinsic surface colors on a black background.",
27
+ # Retain the checkpoint's native prompt; run() replaces its predicted
28
+ # material alpha with the constant opaque channel used by completion.
29
+ "instr_mra": "Convert this rendered image into its material property map: encode metallic in the red channel, roughness in the green channel, and material alpha in the blue channel, on a black background.",
30
+ "instr_normal": "Convert this rendered image into its camera-space surface normal map: encode the normal direction at every pixel as RGB with x pointing right, y pointing up and z toward the camera, on a flat gray background.", "split": "eval",
31
+ }
32
+
33
+
34
+ def write_manifest(condition: Path, path: Path, *, key: str = "input") -> Path:
35
+ if not condition.is_file():
36
+ raise FileNotFoundError(condition)
37
+ path.parent.mkdir(parents=True, exist_ok=True)
38
+ path.write_text(json.dumps(item_for(condition, key), sort_keys=True) + "\n", encoding="utf-8")
39
+ return path
40
+
41
+
42
+ def run(package: Path, base_model: str | Path, condition: Path, output: Path, manifest: Path | None = None,
43
+ *, key: str = "input", weights: Path | None = None, spec: Path | None = None,
44
+ checkpoint_manifest: Path | None = None, material_model: str | Path | None = None,
45
+ revision: str | None = None, cache_dir: Path | None = None,
46
+ local_files_only: bool = False) -> tuple[Path, Path]:
47
+ if not key or Path(key).name != key or key in {".", ".."} or "\\" in key:
48
+ raise ValueError("key must be a single safe filename component")
49
+ from supratex.weights import resolve_model_dir
50
+ if material_model is not None:
51
+ if any(value is not None for value in (weights, spec, checkpoint_manifest)):
52
+ raise ValueError("--material-model cannot be combined with explicit material artifact paths")
53
+ root = resolve_model_dir(material_model, revision=revision, cache_dir=cache_dir,
54
+ local_files_only=local_files_only,
55
+ allow_patterns=["full_transformer.safetensors", "joint_contract.json", "checkpoint_complete.json"])
56
+ weights, spec, checkpoint_manifest = (root / name for name in
57
+ ("full_transformer.safetensors", "joint_contract.json", "checkpoint_complete.json"))
58
+ for path in (weights, spec, checkpoint_manifest):
59
+ if not path.is_file():
60
+ raise FileNotFoundError(path)
61
+ base_model = resolve_model_dir(base_model, cache_dir=cache_dir, local_files_only=local_files_only)
62
+ package, condition, output = (p.resolve() for p in (package, condition, output))
63
+ if manifest is not None:
64
+ manifest = manifest.resolve()
65
+ entry = package / "infer.py"
66
+ if not entry.is_file():
67
+ raise FileNotFoundError(f"FireRed public entrypoint is missing: {entry}")
68
+ if not base_model.exists():
69
+ raise FileNotFoundError(base_model)
70
+ output.mkdir(parents=True, exist_ok=True)
71
+ targets = [output / f"{key}_{channel}.png" for channel in ("albedo", "mra", "normal")]
72
+ targets += [output / name for name in ("albedo.png", "mra.png", "firered_albedo.png", "firered_mra.png")]
73
+ if any(path.exists() for path in targets):
74
+ raise FileExistsError("material outputs already exist; select a fresh output directory")
75
+ manifest = manifest or output / "firered_input.jsonl"
76
+ if manifest == output / "firered_input.jsonl":
77
+ write_manifest(condition, manifest, key=key)
78
+ elif not manifest.is_file():
79
+ raise FileNotFoundError(manifest)
80
+ command = [sys.executable, str(entry), "--base-model", str(base_model), "--items-jsonl", str(manifest), "--out-dir", str(output)]
81
+ bundled = package.resolve() == (Path(__file__).resolve().parent / "material_model").resolve()
82
+ if bundled:
83
+ if not all((weights, spec, checkpoint_manifest)):
84
+ raise ValueError("bundled material model requires --weights, --spec, and --checkpoint-manifest")
85
+ command += ["--weights", str(weights), "--spec", str(spec), "--checkpoint-manifest", str(checkpoint_manifest)]
86
+ subprocess.run(command, check=True)
87
+ albedo, mra = output / f"{key}_albedo.png", output / f"{key}_mra.png"
88
+ if not albedo.is_file() or not mra.is_file():
89
+ raise FileNotFoundError("FireRed did not emit the expected albedo and MRA maps")
90
+ from PIL import Image
91
+ for generated in (albedo, mra):
92
+ with Image.open(generated) as image:
93
+ if image.mode != "RGB" or image.size != (1024, 1024):
94
+ raise ValueError(f"material prediction must be RGB 1024x1024: {generated}")
95
+ # Completion consumes (albedo RGB, metallic, roughness, opacity). Its MR
96
+ # placeholder is constant 1; foreground coverage comes from a separate mask.
97
+ with Image.open(mra) as image:
98
+ fixed_mra = Image.merge("RGB", (image.getchannel("R"), image.getchannel("G"),
99
+ Image.new("L", image.size, 255)))
100
+ fixed_mra.save(mra)
101
+ canonical_albedo, canonical_mra = output / "firered_albedo.png", output / "firered_mra.png"
102
+ shutil.copyfile(albedo, canonical_albedo)
103
+ shutil.copyfile(mra, canonical_mra)
104
+ shutil.copyfile(albedo, output / "albedo.png")
105
+ shutil.copyfile(mra, output / "mra.png")
106
+ return canonical_albedo, canonical_mra
107
+
108
+
109
+ def main() -> None:
110
+ parser = argparse.ArgumentParser(description="Run FireRed Image-to-Material Translation through its packaged public entrypoint.")
111
+ parser.add_argument("--package", type=Path, default=Path(__file__).resolve().parent / "material_model",
112
+ help="Bundled material-model package or a compatible package containing infer.py")
113
+ parser.add_argument("--base-model", required=True, help="local FireRed base directory or Hugging Face namespace/repo")
114
+ parser.add_argument("--material-model", help="local material-model directory or Hugging Face namespace/repo")
115
+ parser.add_argument("--revision", help="material-model Hugging Face revision")
116
+ parser.add_argument("--cache-dir", type=Path)
117
+ parser.add_argument("--local-files-only", action="store_true")
118
+ parser.add_argument("--condition", type=Path, required=True)
119
+ parser.add_argument("--manifest", type=Path,
120
+ help="Optional package-contract JSONL; omitted builds a validation-only local record.")
121
+ parser.add_argument("--key", default="input")
122
+ parser.add_argument("--weights", type=Path, help="External factorized material-model weights for the bundled package.")
123
+ parser.add_argument("--spec", type=Path, help="External factorized material-model checkpoint contract.")
124
+ parser.add_argument("--checkpoint-manifest", type=Path, help="External material-model completion manifest.")
125
+ parser.add_argument("--output", type=Path, required=True)
126
+ args = parser.parse_args()
127
+ albedo, mra = run(args.package, args.base_model, args.condition, args.output, args.manifest, key=args.key,
128
+ weights=args.weights, spec=args.spec, checkpoint_manifest=args.checkpoint_manifest,
129
+ material_model=args.material_model, revision=args.revision, cache_dir=args.cache_dir,
130
+ local_files_only=args.local_files_only)
131
+ print(f"albedo={albedo}\nmra={mra}")
132
+
133
+
134
+ if __name__ == "__main__":
135
+ main()
supratex/frontend/geometry.py CHANGED
@@ -1,169 +1,174 @@
1
- """Run native or paper Pixal3D geometry with bounded mesh-scale calibration."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import json
6
- import math
7
- import os
8
- import shutil
9
- import subprocess
10
- import sys
11
- from pathlib import Path
12
-
13
- from supratex.weights import resolve_model_dir
14
-
15
-
16
- def validate_source(package: Path) -> None:
17
- """Check the source checkout before loading any image-stage models."""
18
- if (package / "pixal3d/pipelines/pixal3d_image_to_3d.py").is_file():
19
- return
20
- for name in ("pixal3dpipeline.py", "pixal3dpipeline2stage.py"):
21
- if not (package / name).is_file():
22
- raise FileNotFoundError(f"Pixal3D two-stage source is missing: {package / name}")
23
-
24
-
25
- NATIVE_GEOMETRY_MODELS = ("sparse_structure_flow_model", "sparse_structure_decoder",
26
- "shape_slat_flow_model_512", "shape_slat_flow_model_1024", "shape_slat_decoder")
27
-
28
-
29
- def validate_model(package: Path, model_root: Path) -> str:
30
- """Require complete local artifacts; native model stems must stay in the root."""
31
- native = (package / "pixal3d/pipelines/pixal3d_image_to_3d.py").is_file()
32
- if native:
33
- config = model_root / "pipeline.json"
34
- if not config.is_file():
35
- raise FileNotFoundError(f"geometry model artifact is missing: {config}")
36
- models = json.loads(config.read_text())["args"]["models"]
37
- required = []
38
- for name in NATIVE_GEOMETRY_MODELS:
39
- stem = (model_root / models[name]).resolve()
40
- if not stem.is_relative_to(model_root.resolve()):
41
- raise ValueError(f"geometry model must be inside model root: {name}")
42
- required.extend(Path(str(stem) + suffix) for suffix in (".json", ".safetensors"))
43
- dino = model_root / "dino"
44
- required.append(dino / "config.json")
45
- index = dino / "model.safetensors.index.json"
46
- if index.is_file():
47
- for shard in set(json.loads(index.read_text())["weight_map"].values()):
48
- path = (dino / shard).resolve()
49
- if not path.is_relative_to(dino.resolve()):
50
- raise ValueError("DINO weight shard must be inside its model directory")
51
- required.append(path)
52
- else:
53
- required.append(dino / "model.safetensors")
54
- else:
55
- required = []
56
- for stage in ("dense", "sparse512", "sparse1024"):
57
- for component in ("dit", "vae", "conditioner", "scheduler"):
58
- required.append(model_root / stage / component / "config.json")
59
- if component in ("dit", "vae"):
60
- required.append(model_root / stage / component / "model.safetensors")
61
- for path in required:
62
- if not path.is_file():
63
- raise FileNotFoundError(f"geometry model artifact is missing: {path}")
64
- return "native" if native else "paper"
65
-
66
-
67
- def _validate_inputs(package: Path, input_dir: Path) -> None:
68
- validate_source(package)
69
- for name in ("condition_rgb.png", "condition_rgba.png", "camera.json"):
70
- if not (input_dir / name).is_file():
71
- raise FileNotFoundError(f"geometry input is missing: {input_dir / name}")
72
- from PIL import Image
73
- with Image.open(input_dir / "condition_rgb.png") as rgb, Image.open(input_dir / "condition_rgba.png") as rgba:
74
- if rgb.mode != "RGB" or rgba.mode != "RGBA" or rgb.size != rgba.size or rgb.width != rgb.height:
75
- raise ValueError("geometry conditions must be matching square RGB and RGBA images")
76
- if rgba.getchannel("A").getbbox() is None:
77
- raise ValueError("geometry condition alpha must contain foreground")
78
- rgb.load()
79
- rgba.load()
80
- try:
81
- camera = json.loads((input_dir / "camera.json").read_text(encoding="utf-8"))
82
- if not isinstance(camera, dict):
83
- raise ValueError("camera must be an object")
84
- if any(key in camera for key in ("extrinsic", "intrinsic", "projection_intrinsic")):
85
- raise ValueError("geometry requires the canonical camera, without explicit matrices")
86
- distance, fov = float(camera["distance"]), float(camera["camera_angle_x"])
87
- if not math.isfinite(distance) or distance <= 0 or not math.isfinite(fov) or not 0 < fov < math.pi:
88
- raise ValueError("invalid distance or field of view")
89
- except (KeyError, TypeError, ValueError) as error:
90
- raise ValueError("camera.json must provide finite positive distance and camera_angle_x in (0, pi), without explicit matrices") from error
91
-
92
-
93
- def run(package: Path, model_source: str | Path, input_dir: Path, output_dir: Path, *, seed: int = 42,
94
- initial_mesh_scale: float = 1.0, target_padding: int = 3, max_iterations: int = 2,
95
- cache_dir: Path | None = None, revision: str | None = None,
96
- local_files_only: bool = False, resident=None) -> dict[str, str]:
97
- """Generate a final mesh using the adapter's bounded scale calibration.
98
-
99
- The external implementation runs in a separate interpreter so its package
100
- imports and GPU resources do not share the material frontend's process.
101
- """
102
- package, input_dir, output_dir = (Path(path).expanduser().resolve() for path in (package, input_dir, output_dir))
103
- if output_dir.exists() and (not output_dir.is_dir() or any(output_dir.iterdir())):
104
- raise FileExistsError("geometry output must be a fresh empty directory")
105
- _validate_inputs(package, input_dir)
106
- if not math.isfinite(initial_mesh_scale) or initial_mesh_scale <= 0:
107
- raise ValueError("initial_mesh_scale must be finite and positive")
108
- if isinstance(target_padding, bool) or not isinstance(target_padding, int) or not 1 <= target_padding <= 30:
109
- raise ValueError("target_padding must be an integer between 1 and 30 for the 64-cell grid")
110
- if isinstance(max_iterations, bool) or not isinstance(max_iterations, int) or max_iterations < 1:
111
- raise ValueError("max_iterations must be a positive integer")
112
- if isinstance(seed, bool) or not isinstance(seed, int) or seed < 0:
113
- raise ValueError("seed must be a nonnegative integer")
114
- model_root = resolve_model_dir(model_source, revision=revision, cache_dir=cache_dir,
115
- local_files_only=local_files_only)
116
- # The upstream local loader permits missing weight files. Require them here
117
- # so an incomplete checkpoint can never silently run random initial weights.
118
- validate_model(package, model_root)
119
- output_dir.mkdir(parents=True, exist_ok=True)
120
- runner = Path(__file__).resolve().with_name("geometry_runner.py")
121
- command = [sys.executable, str(runner), "--package", str(package), "--model-root", str(model_root),
122
- "--input-dir", str(input_dir), "--output-dir", str(output_dir), "--seed", str(seed),
123
- "--initial-mesh-scale", str(initial_mesh_scale), "--target-padding", str(target_padding),
124
- "--max-iterations", str(max_iterations)]
125
- environment = os.environ.copy()
126
- source_root = Path(__file__).resolve().parents[2]
127
- environment["PYTHONPATH"] = os.pathsep.join((str(package), str(source_root))) + (os.pathsep + environment["PYTHONPATH"] if environment.get("PYTHONPATH") else "")
128
- if local_files_only:
129
- environment["HF_HUB_OFFLINE"] = "1"
130
- environment["TRANSFORMERS_OFFLINE"] = "1"
131
- if resident is not None:
132
- resident.run_geometry(package=package, model_root=model_root, input_dir=input_dir,
133
- output_dir=output_dir, seed=seed, initial_mesh_scale=initial_mesh_scale,
134
- target_padding=target_padding, max_iterations=max_iterations)
135
- else:
136
- with (output_dir / "geometry.log").open("x", encoding="utf-8") as log:
137
- subprocess.run(command, check=True, cwd=package, env=environment, stdout=log, stderr=subprocess.STDOUT)
138
- results = {"geometry": output_dir / "geometry.pt", "camera": output_dir / "camera.json",
139
- "receipt": output_dir / "geometry_receipt.json"}
140
- for path in results.values():
141
- if not path.is_file():
142
- raise FileNotFoundError(f"geometry runner did not emit expected output: {path}")
143
- for name in ("condition_rgb.png", "condition_rgba.png"):
144
- shutil.copyfile(input_dir / name, output_dir / name)
145
- return {key: str(path) for key, path in results.items()}
146
-
147
-
148
- def main() -> None:
149
- parser = argparse.ArgumentParser(description="Generate the final mesh with native or paper Pixal3D geometry and bounded scale calibration.")
150
- parser.add_argument("--package", type=Path, required=True, help="Native source checkout (pixal3d/pipelines/) or paper source (pixal3dpipeline2stage.py)")
151
- parser.add_argument("--model", required=True, help="Local geometry-model directory or Hugging Face namespace/repo")
152
- parser.add_argument("--input-dir", type=Path, required=True, help="Condition RGB/RGBA and MoGe camera directory")
153
- parser.add_argument("--output-dir", type=Path, required=True, help="Fresh empty geometry output directory")
154
- parser.add_argument("--seed", type=int, default=42)
155
- parser.add_argument("--initial-mesh-scale", type=float, default=1.0)
156
- parser.add_argument("--target-padding", type=int, default=3)
157
- parser.add_argument("--max-iterations", type=int, default=2)
158
- parser.add_argument("--cache-dir", type=Path)
159
- parser.add_argument("--revision")
160
- parser.add_argument("--local-files-only", action="store_true")
161
- args = parser.parse_args()
162
- print(json.dumps(run(args.package, args.model, args.input_dir, args.output_dir, seed=args.seed,
163
- initial_mesh_scale=args.initial_mesh_scale, target_padding=args.target_padding,
164
- max_iterations=args.max_iterations, cache_dir=args.cache_dir, revision=args.revision,
165
- local_files_only=args.local_files_only), indent=2))
166
-
167
-
168
- if __name__ == "__main__":
169
- main()
 
 
 
 
 
 
1
+ """Run native or paper Pixal3D geometry with bounded mesh-scale calibration."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import math
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from supratex.weights import resolve_model_dir
14
+
15
+
16
+ def validate_source(package: Path) -> None:
17
+ """Check the source checkout before loading any image-stage models."""
18
+ if (package / "pixal3d/pipelines/pixal3d_image_to_3d.py").is_file():
19
+ return
20
+ for name in ("pixal3dpipeline.py", "pixal3dpipeline2stage.py"):
21
+ if not (package / name).is_file():
22
+ raise FileNotFoundError(f"Pixal3D two-stage source is missing: {package / name}")
23
+
24
+
25
+ NATIVE_GEOMETRY_MODELS = ("sparse_structure_flow_model", "sparse_structure_decoder",
26
+ "shape_slat_flow_model_512", "shape_slat_flow_model_1024", "shape_slat_decoder")
27
+
28
+
29
+ def validate_model(package: Path, model_root: Path) -> str:
30
+ """Require complete local artifacts; native model stems must stay in the root."""
31
+ native = (package / "pixal3d/pipelines/pixal3d_image_to_3d.py").is_file()
32
+ if native:
33
+ config = model_root / "pipeline.json"
34
+ if not config.is_file():
35
+ raise FileNotFoundError(f"geometry model artifact is missing: {config}")
36
+ models = json.loads(config.read_text())["args"]["models"]
37
+ required = []
38
+ for name in NATIVE_GEOMETRY_MODELS:
39
+ stem = (model_root / models[name]).resolve()
40
+ if not stem.is_relative_to(model_root.resolve()):
41
+ raise ValueError(f"geometry model must be inside model root: {name}")
42
+ required.extend(Path(str(stem) + suffix) for suffix in (".json", ".safetensors"))
43
+ dino = model_root / "dino"
44
+ required.append(dino / "config.json")
45
+ index = dino / "model.safetensors.index.json"
46
+ if index.is_file():
47
+ weight_map = json.loads(index.read_text())["weight_map"]
48
+ if not isinstance(weight_map, dict) or not weight_map:
49
+ raise ValueError("DINO checkpoint index requires a nonempty weight_map")
50
+ for shard in weight_map.values():
51
+ if (not isinstance(shard, str) or not shard or "\\" in shard or ":" in shard
52
+ or Path(shard).is_absolute() or ".." in Path(shard).parts):
53
+ raise ValueError("DINO weight shard must be inside its model directory")
54
+ # HF snapshot files point to shared cache blobs outside the
55
+ # snapshot. Validate the indexed name, then follow its link
56
+ # only when checking that the required file exists.
57
+ required.append(dino / shard)
58
+ else:
59
+ required.append(dino / "model.safetensors")
60
+ else:
61
+ required = []
62
+ for stage in ("dense", "sparse512", "sparse1024"):
63
+ for component in ("dit", "vae", "conditioner", "scheduler"):
64
+ required.append(model_root / stage / component / "config.json")
65
+ if component in ("dit", "vae"):
66
+ required.append(model_root / stage / component / "model.safetensors")
67
+ for path in required:
68
+ if not path.is_file():
69
+ raise FileNotFoundError(f"geometry model artifact is missing: {path}")
70
+ return "native" if native else "paper"
71
+
72
+
73
+ def _validate_inputs(package: Path, input_dir: Path) -> None:
74
+ validate_source(package)
75
+ for name in ("condition_rgb.png", "condition_rgba.png", "camera.json"):
76
+ if not (input_dir / name).is_file():
77
+ raise FileNotFoundError(f"geometry input is missing: {input_dir / name}")
78
+ from PIL import Image
79
+ with Image.open(input_dir / "condition_rgb.png") as rgb, Image.open(input_dir / "condition_rgba.png") as rgba:
80
+ if rgb.mode != "RGB" or rgba.mode != "RGBA" or rgb.size != rgba.size or rgb.width != rgb.height:
81
+ raise ValueError("geometry conditions must be matching square RGB and RGBA images")
82
+ if rgba.getchannel("A").getbbox() is None:
83
+ raise ValueError("geometry condition alpha must contain foreground")
84
+ rgb.load()
85
+ rgba.load()
86
+ try:
87
+ camera = json.loads((input_dir / "camera.json").read_text(encoding="utf-8"))
88
+ if not isinstance(camera, dict):
89
+ raise ValueError("camera must be an object")
90
+ if any(key in camera for key in ("extrinsic", "intrinsic", "projection_intrinsic")):
91
+ raise ValueError("geometry requires the canonical camera, without explicit matrices")
92
+ distance, fov = float(camera["distance"]), float(camera["camera_angle_x"])
93
+ if not math.isfinite(distance) or distance <= 0 or not math.isfinite(fov) or not 0 < fov < math.pi:
94
+ raise ValueError("invalid distance or field of view")
95
+ except (KeyError, TypeError, ValueError) as error:
96
+ raise ValueError("camera.json must provide finite positive distance and camera_angle_x in (0, pi), without explicit matrices") from error
97
+
98
+
99
+ def run(package: Path, model_source: str | Path, input_dir: Path, output_dir: Path, *, seed: int = 42,
100
+ initial_mesh_scale: float = 1.0, scale_search: bool = False, target_padding: int = 3, max_iterations: int = 2,
101
+ cuda_visible_devices: str | None = None,
102
+ cache_dir: Path | None = None, revision: str | None = None,
103
+ local_files_only: bool = False) -> dict[str, str]:
104
+ """Generate a final mesh using the adapter's bounded scale calibration.
105
+
106
+ The external implementation runs in a separate interpreter so its package
107
+ imports and GPU resources do not share the material frontend's process.
108
+ """
109
+ package, input_dir, output_dir = (Path(path).expanduser().resolve() for path in (package, input_dir, output_dir))
110
+ if output_dir.exists() and (not output_dir.is_dir() or any(output_dir.iterdir())):
111
+ raise FileExistsError("geometry output must be a fresh empty directory")
112
+ _validate_inputs(package, input_dir)
113
+ if not math.isfinite(initial_mesh_scale) or initial_mesh_scale <= 0:
114
+ raise ValueError("initial_mesh_scale must be finite and positive")
115
+ if not 1 <= target_padding <= 30:
116
+ raise ValueError("target_padding must be between 1 and 30 for the 64-cell grid")
117
+ if max_iterations < 1:
118
+ raise ValueError("max_iterations must be positive")
119
+ if seed < 0:
120
+ raise ValueError("seed must be nonnegative")
121
+ model_root = resolve_model_dir(model_source, revision=revision, cache_dir=cache_dir,
122
+ local_files_only=local_files_only)
123
+ # The upstream local loader permits missing weight files. Require them here
124
+ # so an incomplete checkpoint can never silently run random initial weights.
125
+ validate_model(package, model_root)
126
+ output_dir.mkdir(parents=True, exist_ok=True)
127
+ runner = Path(__file__).resolve().with_name("geometry_runner.py")
128
+ command = [sys.executable, str(runner), "--package", str(package), "--model-root", str(model_root),
129
+ "--input-dir", str(input_dir), "--output-dir", str(output_dir), "--seed", str(seed),
130
+ "--initial-mesh-scale", str(initial_mesh_scale), "--target-padding", str(target_padding),
131
+ "--max-iterations", str(max_iterations), *(["--scale-search"] if scale_search else [])]
132
+ environment = os.environ.copy()
133
+ source_root = Path(__file__).resolve().parents[2]
134
+ environment["PYTHONPATH"] = os.pathsep.join((str(package), str(source_root))) + (os.pathsep + environment["PYTHONPATH"] if environment.get("PYTHONPATH") else "")
135
+ if cuda_visible_devices is not None:
136
+ environment["CUDA_VISIBLE_DEVICES"] = cuda_visible_devices
137
+ if local_files_only:
138
+ environment["HF_HUB_OFFLINE"] = "1"
139
+ environment["TRANSFORMERS_OFFLINE"] = "1"
140
+ with (output_dir / "geometry.log").open("x", encoding="utf-8") as log:
141
+ subprocess.run(command, check=True, cwd=package, env=environment, stdout=log, stderr=subprocess.STDOUT)
142
+ results = {"geometry": output_dir / "geometry.pt", "camera": output_dir / "camera.json",
143
+ "receipt": output_dir / "geometry_receipt.json"}
144
+ for path in results.values():
145
+ if not path.is_file():
146
+ raise FileNotFoundError(f"geometry runner did not emit expected output: {path}")
147
+ for name in ("condition_rgb.png", "condition_rgba.png"):
148
+ shutil.copyfile(input_dir / name, output_dir / name)
149
+ return {key: str(path) for key, path in results.items()}
150
+
151
+
152
+ def main() -> None:
153
+ parser = argparse.ArgumentParser(description="Generate the final mesh with native or paper Pixal3D geometry and bounded scale calibration.")
154
+ parser.add_argument("--package", type=Path, required=True, help="Native source checkout (pixal3d/pipelines/) or paper source (pixal3dpipeline2stage.py)")
155
+ parser.add_argument("--model", required=True, help="Local geometry-model directory or Hugging Face namespace/repo")
156
+ parser.add_argument("--input-dir", type=Path, required=True, help="Condition RGB/RGBA and MoGe camera directory")
157
+ parser.add_argument("--output-dir", type=Path, required=True, help="Fresh empty geometry output directory")
158
+ parser.add_argument("--seed", type=int, default=42)
159
+ parser.add_argument("--initial-mesh-scale", type=float, default=1.0)
160
+ parser.add_argument("--scale-search", action="store_true", help="enable bounded mesh-scale calibration (default: Pixal3D fixed scale)")
161
+ parser.add_argument("--target-padding", type=int, default=3)
162
+ parser.add_argument("--max-iterations", type=int, default=2)
163
+ parser.add_argument("--cache-dir", type=Path)
164
+ parser.add_argument("--revision")
165
+ parser.add_argument("--local-files-only", action="store_true")
166
+ args = parser.parse_args()
167
+ print(json.dumps(run(args.package, args.model, args.input_dir, args.output_dir, seed=args.seed,
168
+ initial_mesh_scale=args.initial_mesh_scale, scale_search=args.scale_search, target_padding=args.target_padding,
169
+ max_iterations=args.max_iterations, cache_dir=args.cache_dir, revision=args.revision,
170
+ local_files_only=args.local_files_only), indent=2))
171
+
172
+
173
+ if __name__ == "__main__":
174
+ main()
supratex/frontend/geometry_loading.py CHANGED
@@ -1,69 +1,128 @@
1
- """Strict local checkpoint loading without initializing discarded geometry weights."""
2
-
3
- import json
4
- from pathlib import Path
5
-
6
- import torch
7
-
8
-
9
- _MODEL_NAMES = {
10
- "SparseStructureFlowModel", "SparseStructureDecoder",
11
- "ElasticSLatFlowModel", "FlexiDualGridVaeDecoder",
12
- }
13
-
14
-
15
- def _restore_position_frequencies(model):
16
- """Recreate small native tensors omitted from state_dict, on the CPU."""
17
- for module in model.modules():
18
- frequencies = vars(module).get("freqs")
19
- if not isinstance(frequencies, torch.Tensor) or not frequencies.is_meta:
20
- continue
21
- cls = type(module)
22
- with torch.device("cpu"):
23
- if (cls.__name__ in {"RotaryPositionEmbedder", "SparseRotaryPositionEmbedder"}
24
- and cls.__module__.startswith("pixal3d.modules.")
25
- and cls.__module__.endswith(".attention.rope")):
26
- original = cls(module.head_dim, dim=module.dim, rope_freq=module.rope_freq)
27
- elif (cls.__name__ == "AbsolutePositionEmbedder"
28
- and cls.__module__ == "pixal3d.modules.transformer.blocks"):
29
- original = cls(module.channels, in_channels=module.in_channels)
30
- else:
31
- raise ValueError(f"Unsupported geometry meta frequency tensor: {cls.__module__}.{cls.__name__}")
32
- module.freqs = original.freqs
33
-
34
-
35
- def load_native_geometry_model(path: Path):
36
- """Load a reviewed local Pixal3D network with its constructor's dtype ABI.
37
-
38
- Native flow checkpoints store float32 even for bfloat16 transformer blocks;
39
- decoder checkpoints can instead store float16 for float32 boundary layers.
40
- Cast to each meta-shell tensor's dtype before assignment, exactly as native
41
- copy-based load_state_dict does. Unknown/missing weights fail before return.
42
- """
43
- from pixal3d import models
44
- from safetensors.torch import load_file
45
-
46
- path = Path(path)
47
- config = json.loads(Path(str(path) + ".json").read_text(encoding="utf-8"))
48
- if config["name"] not in _MODEL_NAMES:
49
- raise ValueError(f"Unsupported native geometry model: {config['name']}")
50
- with torch.device("meta"):
51
- model = getattr(models, config["name"])(**config["args"])
52
- expected = model.state_dict()
53
- state = load_file(str(path) + ".safetensors", device="cpu")
54
- missing, unexpected = expected.keys() - state.keys(), state.keys() - expected.keys()
55
- if missing or unexpected:
56
- raise ValueError(f"Geometry checkpoint key mismatch: missing={sorted(missing)}, unexpected={sorted(unexpected)}")
57
- for name, target in expected.items():
58
- value = state[name]
59
- if value.shape != target.shape:
60
- raise ValueError(f"Geometry checkpoint shape mismatch for {name}: {tuple(value.shape)} != {tuple(target.shape)}")
61
- state[name] = value.to(dtype=target.dtype)
62
- model.load_state_dict(state, strict=True, assign=True)
63
- _restore_position_frequencies(model)
64
- for name, module in model.named_modules():
65
- tensors = list(module._parameters.items()) + list(module._buffers.items()) + list(vars(module).items())
66
- for key, value in tensors:
67
- if isinstance(value, torch.Tensor) and value.is_meta:
68
- raise ValueError(f"Unmaterialized geometry tensor: {name}.{key}")
69
- return model.eval()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Strict local checkpoint loading without initializing discarded geometry weights."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import torch
7
+
8
+
9
+ _MODEL_NAMES = {
10
+ "SparseStructureFlowModel", "SparseStructureDecoder",
11
+ "ElasticSLatFlowModel", "FlexiDualGridVaeDecoder",
12
+ }
13
+
14
+
15
+ def _restore_position_frequencies(model):
16
+ """Recreate small native tensors omitted from state_dict, on the CPU."""
17
+ for module in model.modules():
18
+ frequencies = vars(module).get("freqs")
19
+ if not isinstance(frequencies, torch.Tensor) or not frequencies.is_meta:
20
+ continue
21
+ cls = type(module)
22
+ with torch.device("cpu"):
23
+ if (cls.__name__ in {"RotaryPositionEmbedder", "SparseRotaryPositionEmbedder"}
24
+ and cls.__module__.startswith("pixal3d.modules.")
25
+ and cls.__module__.endswith(".attention.rope")):
26
+ original = cls(module.head_dim, dim=module.dim, rope_freq=module.rope_freq)
27
+ elif (cls.__name__ == "AbsolutePositionEmbedder"
28
+ and cls.__module__ == "pixal3d.modules.transformer.blocks"):
29
+ original = cls(module.channels, in_channels=module.in_channels)
30
+ else:
31
+ raise ValueError(f"Unsupported geometry meta frequency tensor: {cls.__module__}.{cls.__name__}")
32
+ module.freqs = original.freqs
33
+
34
+
35
+ RUNTIME_SUFFIX = ".runtime.safetensors"
36
+
37
+
38
+ def _meta_model(path: Path):
39
+ from pixal3d import models
40
+
41
+ config = json.loads(Path(str(path) + ".json").read_text(encoding="utf-8"))
42
+ if config["name"] not in _MODEL_NAMES:
43
+ raise ValueError(f"Unsupported native geometry model: {config['name']}")
44
+ with torch.device("meta"):
45
+ return getattr(models, config["name"])(**config["args"])
46
+
47
+
48
+ def _source_identity(path: Path) -> dict[str, str]:
49
+ stat = Path(str(path) + ".safetensors").resolve().stat()
50
+ return {"source_size": str(stat.st_size), "source_mtime_ns": str(stat.st_mtime_ns)}
51
+
52
+
53
+ def _runtime_state(path: Path, expected: dict) -> dict:
54
+ """Read checkpoint tensors and cast them to the meta shell's dtypes."""
55
+ from safetensors.torch import load_file
56
+
57
+ state = load_file(str(path) + ".safetensors", device="cpu")
58
+ missing, unexpected = expected.keys() - state.keys(), state.keys() - expected.keys()
59
+ if missing or unexpected:
60
+ raise ValueError(f"Geometry checkpoint key mismatch: missing={sorted(missing)}, unexpected={sorted(unexpected)}")
61
+ for name, target in expected.items():
62
+ value = state[name]
63
+ if value.shape != target.shape:
64
+ raise ValueError(f"Geometry checkpoint shape mismatch for {name}: {tuple(value.shape)} != {tuple(target.shape)}")
65
+ state[name] = value.to(dtype=target.dtype)
66
+ return state
67
+
68
+
69
+ def write_runtime_weights(path: Path) -> Path | None:
70
+ """Save the cast state beside the checkpoint when that copy is smaller.
71
+
72
+ Native flow checkpoints store float32 for bfloat16 blocks, so the cast copy
73
+ halves the bytes read per load and loads bit-identical tensors. Decoders
74
+ that store float16 for float32 layers would grow, so they are skipped (None).
75
+ """
76
+ from safetensors.torch import save_file
77
+
78
+ path = Path(path)
79
+ expected = _meta_model(path).state_dict()
80
+ runtime_bytes = sum(target.numel() * target.element_size() for target in expected.values())
81
+ # Only worth a second file when it is clearly smaller than the original.
82
+ if runtime_bytes > .9 * Path(str(path) + ".safetensors").resolve().stat().st_size:
83
+ return None
84
+ target = Path(str(path) + RUNTIME_SUFFIX)
85
+ temporary = target.with_name(target.name + ".tmp")
86
+ save_file({name: tensor.contiguous() for name, tensor in _runtime_state(path, expected).items()},
87
+ str(temporary), metadata=_source_identity(path))
88
+ temporary.replace(target)
89
+ return target
90
+
91
+
92
+ def _runtime_copy(path: Path) -> Path | None:
93
+ """Use a runtime-dtype copy only if it was made from the current checkpoint."""
94
+ from safetensors import safe_open
95
+
96
+ runtime = Path(str(path) + RUNTIME_SUFFIX)
97
+ if not runtime.is_file():
98
+ return None
99
+ with safe_open(str(runtime), framework="pt", device="cpu") as handle:
100
+ if handle.metadata() != _source_identity(path):
101
+ print(f"Ignoring stale runtime geometry weights: {runtime}", flush=True)
102
+ return None
103
+ return runtime
104
+
105
+
106
+ def load_native_geometry_model(path: Path):
107
+ """Load a reviewed local Pixal3D network with its constructor's dtype ABI.
108
+
109
+ Native flow checkpoints store float32 even for bfloat16 transformer blocks;
110
+ decoder checkpoints can instead store float16 for float32 boundary layers.
111
+ Cast to each meta-shell tensor's dtype before assignment, exactly as native
112
+ copy-based load_state_dict does. A matching runtime-dtype copy written by
113
+ scripts/convert_geometry_weights.py is read instead when present.
114
+ Unknown/missing weights fail before return.
115
+ """
116
+ path = Path(path)
117
+ model = _meta_model(path)
118
+ expected = model.state_dict()
119
+ runtime = _runtime_copy(path)
120
+ state = _runtime_state(Path(str(runtime).removesuffix(".safetensors")) if runtime else path, expected)
121
+ model.load_state_dict(state, strict=True, assign=True)
122
+ _restore_position_frequencies(model)
123
+ for name, module in model.named_modules():
124
+ tensors = list(module._parameters.items()) + list(module._buffers.items()) + list(vars(module).items())
125
+ for key, value in tensors:
126
+ if isinstance(value, torch.Tensor) and value.is_meta:
127
+ raise ValueError(f"Unmaterialized geometry tensor: {name}.{key}")
128
+ return model.eval()
supratex/frontend/geometry_runner.py CHANGED
@@ -1,450 +1,434 @@
1
- """Isolated adapter for native and paper Pixal3D geometry generators.
2
-
3
- The prepared demo camera owns FOV and distance. This adapter calibrates scale
4
- using the main coarse geometry model, then publishes the final mesh/camera in
5
- the SUPRA-Tex canonical frame.
6
- """
7
- from __future__ import annotations
8
-
9
- import argparse
10
- import copy
11
- import importlib
12
- import json
13
- import math
14
- from pathlib import Path
15
- import sys
16
-
17
- from hf_space.timing import span
18
-
19
-
20
- def calibrate_scale(probe, initial_scale: float = 1.0, *, target_padding: int = 3,
21
- max_iterations: int = 2) -> tuple[float, dict]:
22
- """Keep the closest safe occupancy, with a bounded number of scale updates.
23
-
24
- ``probe(scale)`` returns single-sample [N,4] decoded grid indices. Invalid
25
- occupancy ends the search; inference/runtime errors propagate to the caller.
26
- """
27
- import torch
28
-
29
- if not math.isfinite(initial_scale) or initial_scale <= 0:
30
- raise ValueError("initial_scale must be finite and positive")
31
- if isinstance(target_padding, bool) or not isinstance(target_padding, int) or not 1 <= target_padding <= 30:
32
- raise ValueError("target_padding must be an integer in [1, 30]")
33
- if isinstance(max_iterations, bool) or not isinstance(max_iterations, int) or max_iterations < 1:
34
- raise ValueError("max_iterations must be a positive integer")
35
- max_iterations = min(max_iterations, 2)
36
- lower, upper = max(1, target_padding - 1), target_padding + 1
37
- scale = best = float(initial_scale)
38
- best_padding, best_error = None, math.inf
39
- report = {"algorithm": "bounded_main_dense_padding", "initial_mesh_scale": scale,
40
- "target_padding": target_padding, "padding_tolerance": [lower, upper],
41
- "scale_bounds": [scale / 2, scale * 2], "max_iterations": max_iterations, "probes": []}
42
- reason = "max_iterations"
43
- for iteration in range(max_iterations + 1):
44
- coords = probe(scale)
45
- valid = (isinstance(coords, torch.Tensor) and coords.ndim == 2 and coords.shape[1] == 4
46
- and coords.shape[0] > 0 and not coords.is_complex())
47
- if valid:
48
- valid = bool(torch.isfinite(coords).all() and (coords[:, 0] == 0).all()
49
- and (coords[:, 1:] >= 0).all() and (coords[:, 1:] <= 63).all()
50
- and (coords == coords.float().round()).all())
51
- state = {"mesh_scale": scale, "valid": valid}
52
- report["probes"].append(state)
53
- if not valid:
54
- reason = "invalid_occupancy"
55
- break
56
- xyz = coords[:, 1:].float()
57
- padding = min(float(xyz.min()), 63 - float(xyz.max()))
58
- offset = float((xyz - 31.5).abs().max())
59
- safe, error = padding >= lower, abs(padding - target_padding)
60
- state.update(padding=padding, max_offset=offset, occupied_voxels=len(coords), safe=safe)
61
- if safe and error < best_error:
62
- best, best_padding, best_error = scale, padding, error
63
- if lower <= padding <= upper:
64
- reason = "target_reached"
65
- break
66
- if iteration == max_iterations:
67
- break
68
- proposed = scale * (31.5 - target_padding) / offset
69
- if not math.isfinite(proposed) or not report["scale_bounds"][0] <= proposed <= report["scale_bounds"][1]:
70
- reason = "scale_out_of_bounds"
71
- break
72
- if math.isclose(proposed, scale, rel_tol=1e-6):
73
- reason = "scale_stalled"
74
- break
75
- scale = proposed
76
- report.update(selected_mesh_scale=best, selected_padding=best_padding,
77
- converged=best_padding is not None and lower <= best_padding <= upper,
78
- used_baseline=best == initial_scale, stop_reason=reason)
79
- return best, report
80
-
81
-
82
- def traced_pipeline(base_class, camera: dict, receipt: dict):
83
- """Customize scale calibration while keeping the upstream generation path."""
84
- fov = float(camera["camera_angle_x"])
85
- if not math.isfinite(fov) or not 0 < fov < math.pi:
86
- raise ValueError("camera_angle_x must be finite and between zero and pi")
87
- distance = float(camera["distance"])
88
- if not math.isfinite(distance) or distance <= 0:
89
- raise ValueError("camera distance must be finite and positive")
90
-
91
- class GeometryPipeline(base_class):
92
- _optimizing_scale = False
93
-
94
- def estimate_fov(self, image_path):
95
- return fov
96
-
97
- def _optimize_mesh_scale(self, image_tensor, camera_angle_x_tensor, distance_tensor,
98
- initial_mesh_scale, dense_steps, dense_guidance_scale, dense_seed,
99
- dense_threshold, target_padding=3, max_iterations=2):
100
- import torch
101
-
102
- device = image_tensor.device
103
- cuda_devices = [device.index if device.index is not None else torch.cuda.current_device()] if device.type == "cuda" else []
104
- receipt["upstream_initial_generation_camera"] = {
105
- "camera_angle_x": float(camera_angle_x_tensor.item()),
106
- "distance": float(distance_tensor.item()), "mesh_scale": float(initial_mesh_scale)}
107
- # The upstream infer() re-derives distance using its 518px/padding
108
- # convention. Mutate its shared camera tensor before any probe so
109
- # final dense and both sparse stages retain the prepared demo camera.
110
- distance_tensor.fill_(distance)
111
- receipt["camera_distance_policy"] = "preserve_prepared_demo_camera"
112
- receipt["initial_generation_camera"] = {
113
- "camera_angle_x": float(camera_angle_x_tensor.item()),
114
- "distance": float(distance_tensor.item()), "mesh_scale": float(initial_mesh_scale)}
115
-
116
- def probe(scale):
117
- receipt["dense_probe_calls"] = receipt.get("dense_probe_calls", 0) + 1
118
- original_scheduler = self.dense_scheduler
119
- try:
120
- # Probe state must not leak into another probe or final generation.
121
- self.dense_scheduler = copy.deepcopy(original_scheduler)
122
- with torch.random.fork_rng(devices=cuda_devices):
123
- latents = self.infer_dense(
124
- image_tensor, camera_angle_x_tensor, distance_tensor,
125
- torch.tensor([scale], device=device, dtype=torch.float32),
126
- dense_steps, dense_guidance_scale, dense_seed)
127
- with torch.autocast(device.type, dtype=torch.float16, enabled=device.type == "cuda"):
128
- return self.dense_vae.decode_mesh(latents, mc_threshold=dense_threshold, return_index=True)[0]
129
- finally:
130
- self.dense_scheduler = original_scheduler
131
-
132
- self._optimizing_scale = True
133
- try:
134
- with span("geometry.scale_search"):
135
- result, report = calibrate_scale(probe, initial_mesh_scale, target_padding=target_padding,
136
- max_iterations=max_iterations)
137
- finally:
138
- self._optimizing_scale = False
139
- receipt["scale_search"] = report
140
- receipt["optimized_mesh_scale"] = float(result)
141
- print(f"[mesh_scale] selected={result:.6f}, stop={report['stop_reason']}, probes={len(report['probes'])}", flush=True)
142
- return result
143
-
144
- def infer_dense(self, image, camera_angle_x, distance, mesh_scale, *args, **kwargs):
145
- if not math.isclose(float(distance.item()), float(camera["distance"]), rel_tol=1e-5):
146
- raise RuntimeError("geometry generation did not retain the prepared demo camera distance")
147
- if not self._optimizing_scale:
148
- receipt["final_generation_camera"] = {
149
- "camera_angle_x": float(camera_angle_x.item()),
150
- "distance": float(distance.item()),
151
- "mesh_scale": float(mesh_scale.item()),
152
- }
153
- with span("geometry.dense_sampling"):
154
- return super().infer_dense(image, camera_angle_x, distance, mesh_scale, *args, **kwargs)
155
-
156
- return GeometryPipeline
157
-
158
-
159
- def canonical_handoff(mesh, camera: dict, receipt: dict):
160
- """Undo upstream export rotation and scale while preserving projection."""
161
- import numpy as np
162
- import torch
163
-
164
- try:
165
- selected = receipt["final_generation_camera"]
166
- scale = float(selected["mesh_scale"])
167
- optimized = float(receipt["optimized_mesh_scale"])
168
- except (KeyError, TypeError, ValueError) as error:
169
- raise RuntimeError("geometry source did not expose the two-stage scale/camera hooks") from error
170
- if not math.isfinite(scale) or scale <= 0 or not math.isclose(scale, optimized, rel_tol=1e-5):
171
- raise RuntimeError("final geometry camera does not match optimized mesh_scale")
172
- if not math.isfinite(selected["distance"]) or selected["distance"] <= 0:
173
- raise RuntimeError("final geometry distance must be finite and positive")
174
- if not math.isclose(selected["distance"], float(camera["distance"]), rel_tol=1e-5):
175
- raise RuntimeError("final geometry did not retain the prepared demo camera distance")
176
- if not math.isclose(selected["camera_angle_x"], float(camera["camera_angle_x"]), rel_tol=1e-5):
177
- raise RuntimeError("geometry generation did not retain the supplied MoGe-2 FOV")
178
- # Upstream exports R @ raw_xyz * (0.5 / scale), with R mapping
179
- # (x,y,z) -> (x,-z,y). Canonical points are raw_xyz * 0.5.
180
- rotation = np.array([[1., 0., 0.], [0., 0., -1.], [0., 1., 0.]])
181
- vertices = np.asarray(mesh.vertices, dtype=np.float32) @ rotation * scale
182
- faces = np.asarray(mesh.faces)
183
- if vertices.ndim != 2 or vertices.shape[1:] != (3,) or not len(vertices) or not np.isfinite(vertices).all():
184
- raise ValueError("geometry generator returned invalid vertices")
185
- if faces.ndim != 2 or faces.shape[1:] != (3,) or not len(faces) or not np.issubdtype(faces.dtype, np.integer):
186
- raise ValueError("geometry generator returned invalid faces")
187
- if faces.min() < 0 or faces.max() >= len(vertices):
188
- raise ValueError("geometry generator returned out-of-range face indices")
189
- if float(np.abs(vertices).max()) > 0.5001:
190
- raise ValueError("converted geometry exceeds the canonical [-0.5, 0.5] volume")
191
- effective_distance = selected["distance"] * scale
192
- if effective_distance <= float(vertices[:, 2].max()):
193
- raise ValueError("optimized camera lies inside or behind the geometry")
194
- final_camera = dict(camera)
195
- final_camera.update(selected)
196
- final_camera["moge_initial_camera"] = {key: camera[key] for key in
197
- ("camera_angle_x", "distance", "mesh_scale") if key in camera}
198
- final_camera["geometry_scale_optimization"] = {
199
- "initial_mesh_scale": receipt["initial_mesh_scale"],
200
- "optimized_mesh_scale": scale,
201
- "effective_camera_distance": effective_distance,
202
- "objective": "dense_grid_boundary_padding",
203
- }
204
- geometry = {"verts": torch.from_numpy(vertices.copy()).float(),
205
- "faces": torch.from_numpy(faces.astype(np.int64, copy=True)).long()}
206
- return geometry, final_camera
207
-
208
-
209
- def build_native_pipeline(model_root: Path):
210
- """Load only native geometry networks and their projection conditioners."""
211
- import torch
212
- local_naf = Path(torch.hub.get_dir()) / "valeoai_NAF_main"
213
- for artifact in (local_naf / "hubconf.py", local_naf.parent / "checkpoints/naf_release.pth"):
214
- if not artifact.is_file():
215
- raise FileNotFoundError(f"native geometry requires the local NAF cache: {artifact}")
216
- from pixal3d.pipelines import Pixal3DImageTo3DPipeline, samplers
217
- from pixal3d.trainers.flow_matching.mixins.image_conditioned_proj import DinoV3ProjFeatureExtractor
218
- from supratex.frontend.geometry_loading import load_native_geometry_model
219
-
220
- class GeometryOnlyPipeline(Pixal3DImageTo3DPipeline):
221
- model_names_to_load = ["sparse_structure_flow_model", "sparse_structure_decoder",
222
- "shape_slat_flow_model_512", "shape_slat_flow_model_1024", "shape_slat_decoder"]
223
-
224
- # Conditions already include foreground removal. Base loading avoids creating
225
- # another background-removal model or any texture networks/samplers.
226
- args = json.loads((model_root / "pipeline.json").read_text(encoding="utf-8"))["args"]
227
- networks = {name: load_native_geometry_model(model_root / checkpoint)
228
- for name, checkpoint in args["models"].items()
229
- if name in GeometryOnlyPipeline.model_names_to_load}
230
- missing = set(GeometryOnlyPipeline.model_names_to_load) - networks.keys()
231
- if missing:
232
- raise ValueError(f"Native geometry config is missing models: {sorted(missing)}")
233
- pipe = GeometryOnlyPipeline(networks)
234
- pipe._pretrained_args = args
235
- for name in ("sparse_structure", "shape_slat"):
236
- spec = args[f"{name}_sampler"]
237
- setattr(pipe, f"{name}_sampler", getattr(samplers, spec["name"])(**spec["args"]))
238
- setattr(pipe, f"{name}_sampler_params", spec["params"])
239
- pipe.shape_slat_normalization = args["shape_slat_normalization"]
240
- original_load = torch.hub.load
241
-
242
- def local_hub_load(repo_or_dir, model, *args, **kwargs):
243
- if repo_or_dir == "valeoai/NAF":
244
- kwargs.pop("trust_repo", None)
245
- kwargs["source"] = "local"
246
- repo_or_dir = str(local_naf)
247
- return original_load(repo_or_dir, model, *args, **kwargs)
248
-
249
- try:
250
- torch.hub.load = local_hub_load
251
- for name, size, grid, use_naf in (("ss", 512, 16, False),
252
- ("shape_512", 512, 32, True),
253
- ("shape_1024", 1024, 64, True)):
254
- conditioner = DinoV3ProjFeatureExtractor(
255
- model_name=str(model_root / "dino"), image_size=size, grid_resolution=grid,
256
- use_naf_upsample=use_naf, naf_target_size=512).eval()
257
- if use_naf:
258
- conditioner._load_naf()
259
- setattr(pipe, f"image_cond_model_{name}", conditioner)
260
- finally:
261
- torch.hub.load = original_load
262
- pipe._device = torch.device("cuda")
263
- pipe.low_vram = True
264
- return pipe
265
-
266
-
267
- def infer_native(pipe, image, camera: dict, receipt: dict, *, seed: int,
268
- initial_mesh_scale: float, target_padding: int, max_iterations: int):
269
- """Calibrate native 64-cell occupancy, then run the demo's shape cascade."""
270
- import torch
271
-
272
- device = torch.device(pipe.device)
273
- cuda_devices = [device.index if device.index is not None else torch.cuda.current_device()] if device.type == "cuda" else []
274
- sampler_ss = {"steps": 12, "guidance_strength": 7.5, "guidance_rescale": .7, "rescale_t": 5.}
275
- sampler_shape = {"steps": 12, "guidance_strength": 7.5, "guidance_rescale": .5, "rescale_t": 3.}
276
- condition = {key: float(camera[key]) for key in ("camera_angle_x", "distance")}
277
- receipt["initial_generation_camera"] = {**condition, "mesh_scale": initial_mesh_scale}
278
- receipt["camera_distance_policy"] = "preserve_prepared_demo_camera"
279
-
280
- def clear_cache():
281
- if device.type == "cuda":
282
- torch.cuda.empty_cache()
283
-
284
- def probe(scale):
285
- original_sampler = pipe.sparse_structure_sampler
286
- receipt["dense_probe_calls"] += 1
287
- try:
288
- pipe.sparse_structure_sampler = copy.deepcopy(original_sampler)
289
- with torch.random.fork_rng(devices=cuda_devices):
290
- torch.default_generator.manual_seed(seed)
291
- if cuda_devices:
292
- with torch.cuda.device(device):
293
- torch.cuda.manual_seed(seed)
294
- with span("geometry.conditioning_probe"):
295
- cond = pipe.get_proj_cond_ss([image], **condition, mesh_scale=scale)
296
- with span("geometry.sampling_probe"):
297
- coords = pipe.sample_sparse_structure(cond, resolution=64, sampler_params=sampler_ss)
298
- del cond
299
- clear_cache()
300
- return coords
301
- finally:
302
- pipe.sparse_structure_sampler = original_sampler
303
-
304
- with span("geometry.scale_search"):
305
- selected, report = calibrate_scale(probe, initial_mesh_scale, target_padding=target_padding,
306
- max_iterations=max_iterations)
307
- condition["mesh_scale"] = selected
308
- receipt.update(scale_search=report, optimized_mesh_scale=selected, final_generation_camera=dict(condition),
309
- uses_dense_check_model=False, final_sparse_structure_resolution=32, resolution=1024)
310
- print(f"[mesh_scale] selected={selected:.6f}, stop={report['stop_reason']}, probes={len(report['probes'])}", flush=True)
311
- torch.manual_seed(seed)
312
- with span("geometry.conditioning_sparse"):
313
- cond = pipe.get_proj_cond_ss([image], **condition)
314
- with span("geometry.sampling_sparse"):
315
- coords = pipe.sample_sparse_structure(cond, resolution=32, sampler_params=sampler_ss)
316
- del cond
317
- clear_cache()
318
- with span("geometry.conditioning_512"):
319
- cond = pipe.get_proj_cond_shape(pipe.image_cond_model_shape_512, [image], coords, **condition)
320
- with span("geometry.sampling_512"):
321
- slat = pipe.sample_shape_slat(cond, pipe.models["shape_slat_flow_model_512"], coords, sampler_shape)
322
- del cond, coords
323
- clear_cache()
324
- with span("geometry.upsample"):
325
- decoder = pipe.models["shape_slat_decoder"]
326
- decoder.to(device)
327
- decoder.low_vram = True
328
- try:
329
- hr_coords = decoder.upsample(slat, upsample_times=4)
330
- finally:
331
- decoder.cpu()
332
- decoder.low_vram = False
333
- # Match native 1024-cascade projection-grid quantization.
334
- coords = torch.cat([hr_coords[:, :1], ((hr_coords[:, 1:] + .5) / 512 * 63).round().int()], dim=1).unique(dim=0)
335
- del slat, hr_coords
336
- clear_cache()
337
- with span("geometry.conditioning_1024"):
338
- cond = pipe.get_proj_cond_shape(pipe.image_cond_model_shape_1024, [image], coords,
339
- grid_resolution_override=64, **condition)
340
- with span("geometry.sampling_1024"):
341
- slat = pipe.sample_shape_slat(cond, pipe.models["shape_slat_flow_model_1024"], coords, sampler_shape)
342
- del cond, coords
343
- clear_cache()
344
- with span("geometry.decode"):
345
- meshes, _ = pipe.decode_shape_slat(slat, 1024)
346
- if len(meshes) != 1 or not meshes[0].vertices.numel() or not meshes[0].faces.numel():
347
- raise RuntimeError("geometry decoder returned an empty mesh")
348
- meshes[0].fill_holes()
349
- mesh = meshes[0]
350
- vertices, faces = mesh.vertices.detach().float().cpu(), mesh.faces.detach().long().cpu()
351
- if (vertices.ndim != 2 or vertices.shape[1] != 3 or not torch.isfinite(vertices).all()
352
- or float(vertices.abs().max()) > .5001 or faces.ndim != 2 or faces.shape[1] != 3
353
- or faces.min() < 0 or faces.max() >= len(vertices)):
354
- raise ValueError("native geometry decoder returned invalid canonical mesh")
355
- if condition["distance"] * selected <= float(vertices[:, 2].max()):
356
- raise ValueError("optimized camera lies inside or behind the geometry")
357
- final_camera = {**camera, **condition,
358
- "moge_initial_camera": {key: camera[key] for key in ("camera_angle_x", "distance", "mesh_scale") if key in camera},
359
- "geometry_scale_optimization": {"initial_mesh_scale": initial_mesh_scale,
360
- "optimized_mesh_scale": selected,
361
- "effective_camera_distance": condition["distance"] * selected,
362
- "objective": "dense_grid_boundary_padding"}}
363
- return {"verts": vertices, "faces": faces, "resolution": 1024}, final_camera
364
-
365
-
366
- def run(package: Path, model_root: Path, input_dir: Path, output_dir: Path, *, seed: int = 42,
367
- initial_mesh_scale: float = 1.0, target_padding: int = 3, max_iterations: int = 2, pipeline=None):
368
- import torch
369
-
370
- for name in ("geometry.pt", "camera.json", "geometry_receipt.json"):
371
- if (output_dir / name).exists():
372
- raise FileExistsError(f"geometry output already exists: {output_dir / name}")
373
- if not torch.cuda.is_available():
374
- raise RuntimeError("geometry generation requires CUDA")
375
- from supratex.inference.runtime import configure_inference_runtime
376
- runtime = configure_inference_runtime()
377
- camera = json.loads((input_dir / "camera.json").read_text(encoding="utf-8"))
378
- if any(key in camera for key in ("extrinsic", "intrinsic", "projection_intrinsic")):
379
- raise ValueError("geometry generation requires a canonical scalar camera")
380
- sys.path.insert(0, str(package.resolve()))
381
- native = (package / "pixal3d/pipelines/pixal3d_image_to_3d.py").is_file()
382
- kernel_config = None
383
- if native:
384
- from supratex.inference.kernel_config import configure_sparse_kernels
385
- kernel_config = configure_sparse_kernels()
386
- module = importlib.import_module("pixal3d.pipelines.pixal3d_image_to_3d" if native else "pixal3dpipeline2stage")
387
- if not Path(module.__file__).resolve().is_relative_to(package.resolve()):
388
- raise RuntimeError("geometry pipeline was imported outside the selected source package")
389
- receipt = {"schema": "supratex.geometry.v2", "generator": "Pixal3D paper two-stage",
390
- "runtime": runtime,
391
- "source_package": str(package.resolve()), "model_root": str(model_root.resolve()),
392
- "camera_estimator": camera.get("camera_estimator", "supplied camera"),
393
- "initial_mesh_scale": initial_mesh_scale, "target_padding": target_padding,
394
- "max_iterations": max_iterations, "seed": seed, "dense_probe_calls": 0,
395
- "objective": "dense_grid_boundary_padding", "geometry_frame": "canonical [-0.5,0.5]",
396
- "scope": "paper geometry generation with prepared demo camera and bounded main-model scale calibration"}
397
- if native:
398
- from PIL import Image
399
- from supratex.frontend.geometry_acceleration import NativeGeometryAcceleration
400
- receipt.update(generator="Pixal3D native geometry cascade",
401
- sparse_kernel_config=kernel_config,
402
- scope="native geometry generation with prepared demo camera and bounded main-model scale calibration")
403
- if pipeline is None:
404
- with span("geometry.model_load"):
405
- pipeline = build_native_pipeline(model_root)
406
- else:
407
- receipt['model_loading'] = 'startup_pool'
408
- with span("geometry.inference"), Image.open(input_dir / "condition_rgb.png") as image, torch.inference_mode(), NativeGeometryAcceleration(pipeline) as acceleration:
409
- geometry, final_camera = infer_native(pipeline, image.convert("RGB"), camera, receipt, seed=seed,
410
- initial_mesh_scale=initial_mesh_scale, target_padding=target_padding,
411
- max_iterations=max_iterations)
412
- receipt["conditioning_acceleration"] = dict(acceleration.stats)
413
- else:
414
- pipeline_class = traced_pipeline(module.Pixal3DPipeline2Stage, camera, receipt)
415
- with span("geometry.model_load"):
416
- pipeline = pipeline_class.from_pretrained(ckpt_dir=str(model_root), repo_id=None,
417
- use_moge=False, use_dense_check=False)
418
- receipt["uses_dense_check_model"] = bool(pipeline.has_dense_check)
419
- with span("geometry.inference"), torch.inference_mode():
420
- mesh = pipeline.infer(image=str(input_dir / "condition_rgb.png"), dense_seed=seed,
421
- sparse_seed=seed, mesh_scale=initial_mesh_scale,
422
- optimize_mesh_scale=True, target_padding=target_padding,
423
- max_optim_iterations=max_iterations)
424
- geometry, final_camera = canonical_handoff(mesh, camera, receipt)
425
- with span("geometry.output_write"):
426
- receipt["vertices"] = int(geometry["verts"].shape[0])
427
- receipt["faces"] = int(geometry["faces"].shape[0])
428
- output_dir.mkdir(parents=True, exist_ok=True)
429
- torch.save(geometry, output_dir / "geometry.pt")
430
- (output_dir / "camera.json").write_text(json.dumps(final_camera, indent=2) + "\n", encoding="utf-8")
431
- # Publish the receipt last, after the complete final mesh/camera handoff.
432
- (output_dir / "geometry_receipt.json").write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")
433
-
434
-
435
- def main():
436
- parser = argparse.ArgumentParser(description=__doc__)
437
- for name in ("package", "model-root", "input-dir", "output-dir"):
438
- parser.add_argument(f"--{name}", type=Path, required=True)
439
- parser.add_argument("--seed", type=int, default=42)
440
- parser.add_argument("--initial-mesh-scale", type=float, default=1.0)
441
- parser.add_argument("--target-padding", type=int, default=3)
442
- parser.add_argument("--max-iterations", type=int, default=2)
443
- args = parser.parse_args()
444
- run(args.package, args.model_root, args.input_dir, args.output_dir, seed=args.seed,
445
- initial_mesh_scale=args.initial_mesh_scale, target_padding=args.target_padding,
446
- max_iterations=args.max_iterations)
447
-
448
-
449
- if __name__ == "__main__":
450
- main()
 
1
+ """Isolated adapter for native and paper Pixal3D geometry generators.
2
+
3
+ The prepared demo camera owns FOV and distance. This adapter calibrates scale
4
+ using the main coarse geometry model, then publishes the final mesh/camera in
5
+ the SUPRA-Tex canonical frame.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import copy
11
+ import importlib
12
+ import json
13
+ import math
14
+ from pathlib import Path
15
+ import sys
16
+
17
+
18
+ def calibrate_scale(probe, initial_scale: float = 1.0, *, target_padding: int = 3,
19
+ max_iterations: int = 2) -> tuple[float, dict]:
20
+ """Keep the closest safe occupancy, with a bounded number of scale updates.
21
+
22
+ ``probe(scale)`` returns single-sample [N,4] decoded grid indices. Invalid
23
+ occupancy ends the search; inference/runtime errors propagate to the caller.
24
+ """
25
+ import torch
26
+
27
+ # geometry.run validates these options before launching this runner.
28
+ max_iterations = min(max_iterations, 2)
29
+ lower, upper = max(1, target_padding - 1), target_padding + 1
30
+ scale = best = float(initial_scale)
31
+ best_padding, best_error = None, math.inf
32
+ report = {"algorithm": "bounded_main_dense_padding", "initial_mesh_scale": scale,
33
+ "target_padding": target_padding, "padding_tolerance": [lower, upper],
34
+ "scale_bounds": [scale / 2, scale * 2], "max_iterations": max_iterations, "probes": []}
35
+ reason = "max_iterations"
36
+ for iteration in range(max_iterations + 1):
37
+ coords = probe(scale)
38
+ valid = (isinstance(coords, torch.Tensor) and coords.ndim == 2 and coords.shape[1] == 4
39
+ and coords.shape[0] > 0 and not coords.is_complex())
40
+ if valid:
41
+ valid = bool(torch.isfinite(coords).all() and (coords[:, 0] == 0).all()
42
+ and (coords[:, 1:] >= 0).all() and (coords[:, 1:] <= 63).all()
43
+ and (coords == coords.float().round()).all())
44
+ state = {"mesh_scale": scale, "valid": valid}
45
+ report["probes"].append(state)
46
+ if not valid:
47
+ reason = "invalid_occupancy"
48
+ break
49
+ xyz = coords[:, 1:].float()
50
+ padding = min(float(xyz.min()), 63 - float(xyz.max()))
51
+ offset = float((xyz - 31.5).abs().max())
52
+ safe, error = padding >= lower, abs(padding - target_padding)
53
+ state.update(padding=padding, max_offset=offset, occupied_voxels=len(coords), safe=safe)
54
+ if safe and error < best_error:
55
+ best, best_padding, best_error = scale, padding, error
56
+ if lower <= padding <= upper:
57
+ reason = "target_reached"
58
+ break
59
+ if iteration == max_iterations:
60
+ break
61
+ proposed = scale * (31.5 - target_padding) / offset
62
+ if not math.isfinite(proposed) or not report["scale_bounds"][0] <= proposed <= report["scale_bounds"][1]:
63
+ reason = "scale_out_of_bounds"
64
+ break
65
+ if math.isclose(proposed, scale, rel_tol=1e-6):
66
+ reason = "scale_stalled"
67
+ break
68
+ scale = proposed
69
+ report.update(selected_mesh_scale=best, selected_padding=best_padding,
70
+ converged=best_padding is not None and lower <= best_padding <= upper,
71
+ used_baseline=best == initial_scale, stop_reason=reason)
72
+ return best, report
73
+
74
+
75
+ def traced_pipeline(base_class, camera: dict, receipt: dict, *, scale_search: bool = False):
76
+ """Customize scale calibration while keeping the upstream generation path."""
77
+ fov = float(camera["camera_angle_x"])
78
+ if not math.isfinite(fov) or not 0 < fov < math.pi:
79
+ raise ValueError("camera_angle_x must be finite and between zero and pi")
80
+ distance = float(camera["distance"])
81
+ if not math.isfinite(distance) or distance <= 0:
82
+ raise ValueError("camera distance must be finite and positive")
83
+
84
+ class GeometryPipeline(base_class):
85
+ _optimizing_scale = False
86
+
87
+ def estimate_fov(self, image_path):
88
+ return fov
89
+
90
+ def _optimize_mesh_scale(self, image_tensor, camera_angle_x_tensor, distance_tensor,
91
+ initial_mesh_scale, dense_steps, dense_guidance_scale, dense_seed,
92
+ dense_threshold, target_padding=3, max_iterations=2):
93
+ import torch
94
+
95
+ device = image_tensor.device
96
+ cuda_devices = [device.index if device.index is not None else torch.cuda.current_device()] if device.type == "cuda" else []
97
+ receipt["upstream_initial_generation_camera"] = {
98
+ "camera_angle_x": float(camera_angle_x_tensor.item()),
99
+ "distance": float(distance_tensor.item()), "mesh_scale": float(initial_mesh_scale)}
100
+ # The upstream infer() re-derives distance using its 518px/padding
101
+ # convention. Mutate its shared camera tensor before any probe so
102
+ # final dense and both sparse stages retain the prepared demo camera.
103
+ distance_tensor.fill_(distance)
104
+ receipt["camera_distance_policy"] = "preserve_prepared_demo_camera"
105
+ receipt["initial_generation_camera"] = {
106
+ "camera_angle_x": float(camera_angle_x_tensor.item()),
107
+ "distance": float(distance_tensor.item()), "mesh_scale": float(initial_mesh_scale)}
108
+
109
+ def probe(scale):
110
+ receipt["dense_probe_calls"] = receipt.get("dense_probe_calls", 0) + 1
111
+ original_scheduler = self.dense_scheduler
112
+ try:
113
+ # Probe state must not leak into another probe or final generation.
114
+ self.dense_scheduler = copy.deepcopy(original_scheduler)
115
+ with torch.random.fork_rng(devices=cuda_devices):
116
+ latents = self.infer_dense(
117
+ image_tensor, camera_angle_x_tensor, distance_tensor,
118
+ torch.tensor([scale], device=device, dtype=torch.float32),
119
+ dense_steps, dense_guidance_scale, dense_seed)
120
+ with torch.autocast(device.type, dtype=torch.float16, enabled=device.type == "cuda"):
121
+ return self.dense_vae.decode_mesh(latents, mc_threshold=dense_threshold, return_index=True)[0]
122
+ finally:
123
+ self.dense_scheduler = original_scheduler
124
+
125
+ if not scale_search:
126
+ result, report = float(initial_mesh_scale), {"enabled": False, "probes": [], "stop_reason": "disabled"}
127
+ else:
128
+ self._optimizing_scale = True
129
+ try:
130
+ result, report = calibrate_scale(probe, initial_mesh_scale, target_padding=target_padding,
131
+ max_iterations=max_iterations)
132
+ finally:
133
+ self._optimizing_scale = False
134
+ receipt["scale_search"] = report
135
+ receipt["optimized_mesh_scale"] = float(result)
136
+ print(f"[mesh_scale] selected={result:.6f}, stop={report['stop_reason']}, probes={len(report['probes'])}", flush=True)
137
+ return result
138
+
139
+ def infer_dense(self, image, camera_angle_x, distance, mesh_scale, *args, **kwargs):
140
+ if not math.isclose(float(distance.item()), float(camera["distance"]), rel_tol=1e-5):
141
+ raise RuntimeError("geometry generation did not retain the prepared demo camera distance")
142
+ if not self._optimizing_scale:
143
+ receipt["final_generation_camera"] = {
144
+ "camera_angle_x": float(camera_angle_x.item()),
145
+ "distance": float(distance.item()),
146
+ "mesh_scale": float(mesh_scale.item()),
147
+ }
148
+ return super().infer_dense(image, camera_angle_x, distance, mesh_scale, *args, **kwargs)
149
+
150
+ return GeometryPipeline
151
+
152
+
153
+ def canonical_handoff(mesh, camera: dict, receipt: dict):
154
+ """Undo upstream export rotation and scale while preserving projection."""
155
+ import numpy as np
156
+ import torch
157
+
158
+ try:
159
+ selected = receipt["final_generation_camera"]
160
+ scale = float(selected["mesh_scale"])
161
+ optimized = float(receipt["optimized_mesh_scale"])
162
+ except (KeyError, TypeError, ValueError) as error:
163
+ raise RuntimeError("geometry source did not expose the two-stage scale/camera hooks") from error
164
+ if not math.isfinite(scale) or scale <= 0 or not math.isclose(scale, optimized, rel_tol=1e-5):
165
+ raise RuntimeError("final geometry camera does not match optimized mesh_scale")
166
+ if not math.isfinite(selected["distance"]) or selected["distance"] <= 0:
167
+ raise RuntimeError("final geometry distance must be finite and positive")
168
+ if not math.isclose(selected["distance"], float(camera["distance"]), rel_tol=1e-5):
169
+ raise RuntimeError("final geometry did not retain the prepared demo camera distance")
170
+ if not math.isclose(selected["camera_angle_x"], float(camera["camera_angle_x"]), rel_tol=1e-5):
171
+ raise RuntimeError("geometry generation did not retain the supplied MoGe-2 FOV")
172
+ # Upstream exports R @ raw_xyz * (0.5 / scale), with R mapping
173
+ # (x,y,z) -> (x,-z,y). Canonical points are raw_xyz * 0.5.
174
+ rotation = np.array([[1., 0., 0.], [0., 0., -1.], [0., 1., 0.]])
175
+ vertices = np.asarray(mesh.vertices, dtype=np.float32) @ rotation * scale
176
+ faces = np.asarray(mesh.faces)
177
+ if vertices.ndim != 2 or vertices.shape[1:] != (3,) or not len(vertices) or not np.isfinite(vertices).all():
178
+ raise ValueError("geometry generator returned invalid vertices")
179
+ if faces.ndim != 2 or faces.shape[1:] != (3,) or not len(faces) or not np.issubdtype(faces.dtype, np.integer):
180
+ raise ValueError("geometry generator returned invalid faces")
181
+ if faces.min() < 0 or faces.max() >= len(vertices):
182
+ raise ValueError("geometry generator returned out-of-range face indices")
183
+ if float(np.abs(vertices).max()) > 0.5001:
184
+ raise ValueError("converted geometry exceeds the canonical [-0.5, 0.5] volume")
185
+ effective_distance = selected["distance"] * scale
186
+ if effective_distance <= float(vertices[:, 2].max()):
187
+ raise ValueError("optimized camera lies inside or behind the geometry")
188
+ final_camera = dict(camera)
189
+ final_camera.update(selected)
190
+ final_camera["moge_initial_camera"] = {key: camera[key] for key in
191
+ ("camera_angle_x", "distance", "mesh_scale") if key in camera}
192
+ final_camera["geometry_scale_optimization"] = {
193
+ "initial_mesh_scale": receipt["initial_mesh_scale"],
194
+ "optimized_mesh_scale": scale,
195
+ "effective_camera_distance": effective_distance,
196
+ "objective": "dense_grid_boundary_padding",
197
+ }
198
+ geometry = {"verts": torch.from_numpy(vertices.copy()).float(),
199
+ "faces": torch.from_numpy(faces.astype(np.int64, copy=True)).long()}
200
+ return geometry, final_camera
201
+
202
+
203
+ def build_native_pipeline(model_root: Path):
204
+ """Load only native geometry networks and their projection conditioners."""
205
+ import torch
206
+ local_naf = Path(torch.hub.get_dir()) / "valeoai_NAF_main"
207
+ for artifact in (local_naf / "hubconf.py", local_naf.parent / "checkpoints/naf_release.pth"):
208
+ if not artifact.is_file():
209
+ raise FileNotFoundError(f"native geometry requires the local NAF cache: {artifact}")
210
+ from pixal3d.pipelines import Pixal3DImageTo3DPipeline, samplers
211
+ from pixal3d.modules.projected_image_feature_extractor import DinoV3ProjFeatureExtractor
212
+ from supratex.frontend.geometry_loading import load_native_geometry_model
213
+
214
+ class GeometryOnlyPipeline(Pixal3DImageTo3DPipeline):
215
+ model_names_to_load = ["sparse_structure_flow_model", "sparse_structure_decoder",
216
+ "shape_slat_flow_model_512", "shape_slat_flow_model_1024", "shape_slat_decoder"]
217
+
218
+ # Conditions already include foreground removal. Base loading avoids creating
219
+ # another background-removal model or any texture networks/samplers.
220
+ args = json.loads((model_root / "pipeline.json").read_text(encoding="utf-8"))["args"]
221
+ networks = {name: load_native_geometry_model(model_root / checkpoint)
222
+ for name, checkpoint in args["models"].items()
223
+ if name in GeometryOnlyPipeline.model_names_to_load}
224
+ missing = set(GeometryOnlyPipeline.model_names_to_load) - networks.keys()
225
+ if missing:
226
+ raise ValueError(f"Native geometry config is missing models: {sorted(missing)}")
227
+ pipe = GeometryOnlyPipeline(networks)
228
+ pipe._pretrained_args = args
229
+ for name in ("sparse_structure", "shape_slat"):
230
+ spec = args[f"{name}_sampler"]
231
+ setattr(pipe, f"{name}_sampler", getattr(samplers, spec["name"])(**spec["args"]))
232
+ setattr(pipe, f"{name}_sampler_params", spec["params"])
233
+ pipe.shape_slat_normalization = args["shape_slat_normalization"]
234
+ original_load = torch.hub.load
235
+
236
+ def local_hub_load(repo_or_dir, model, *args, **kwargs):
237
+ if repo_or_dir == "valeoai/NAF":
238
+ kwargs.pop("trust_repo", None)
239
+ kwargs["source"] = "local"
240
+ repo_or_dir = str(local_naf)
241
+ return original_load(repo_or_dir, model, *args, **kwargs)
242
+
243
+ try:
244
+ torch.hub.load = local_hub_load
245
+ for name, size, grid, use_naf in (("ss", 512, 16, False),
246
+ ("shape_512", 512, 32, True),
247
+ ("shape_1024", 1024, 64, True)):
248
+ conditioner = DinoV3ProjFeatureExtractor(
249
+ model_name=str(model_root / "dino"), image_size=size, grid_resolution=grid,
250
+ use_naf_upsample=use_naf, naf_target_size=512).eval()
251
+ if use_naf:
252
+ conditioner._load_naf()
253
+ setattr(pipe, f"image_cond_model_{name}", conditioner)
254
+ finally:
255
+ torch.hub.load = original_load
256
+ pipe._device = torch.device("cuda")
257
+ pipe.low_vram = True
258
+ return pipe
259
+
260
+
261
+ def infer_native(pipe, image, camera: dict, receipt: dict, *, seed: int,
262
+ initial_mesh_scale: float, scale_search: bool, target_padding: int, max_iterations: int):
263
+ """Optionally calibrate native 64-cell occupancy, then run the Pixal3D shape cascade."""
264
+ import torch
265
+
266
+ device = torch.device(pipe.device)
267
+ cuda_devices = [device.index if device.index is not None else torch.cuda.current_device()] if device.type == "cuda" else []
268
+ sampler_ss = {"steps": 12, "guidance_strength": 7.5, "guidance_rescale": .7, "rescale_t": 5.}
269
+ sampler_shape = {"steps": 12, "guidance_strength": 7.5, "guidance_rescale": .5, "rescale_t": 3.}
270
+ condition = {key: float(camera[key]) for key in ("camera_angle_x", "distance")}
271
+ receipt["initial_generation_camera"] = {**condition, "mesh_scale": initial_mesh_scale}
272
+ receipt["camera_distance_policy"] = "preserve_prepared_demo_camera"
273
+
274
+ def clear_cache():
275
+ if device.type == "cuda":
276
+ torch.cuda.empty_cache()
277
+
278
+ def probe(scale):
279
+ original_sampler = pipe.sparse_structure_sampler
280
+ receipt["dense_probe_calls"] += 1
281
+ try:
282
+ pipe.sparse_structure_sampler = copy.deepcopy(original_sampler)
283
+ with torch.random.fork_rng(devices=cuda_devices):
284
+ torch.default_generator.manual_seed(seed)
285
+ if cuda_devices:
286
+ with torch.cuda.device(device):
287
+ torch.cuda.manual_seed(seed)
288
+ cond = pipe.get_proj_cond_ss([image], **condition, mesh_scale=scale)
289
+ coords = pipe.sample_sparse_structure(cond, resolution=64, sampler_params=sampler_ss)
290
+ del cond
291
+ clear_cache()
292
+ return coords
293
+ finally:
294
+ pipe.sparse_structure_sampler = original_sampler
295
+
296
+ if scale_search:
297
+ selected, report = calibrate_scale(probe, initial_mesh_scale, target_padding=target_padding,
298
+ max_iterations=max_iterations)
299
+ else:
300
+ # Default: keep Pixal3D's fixed mesh scale without dense probes.
301
+ selected, report = initial_mesh_scale, {"enabled": False, "probes": [], "stop_reason": "disabled"}
302
+ condition["mesh_scale"] = selected
303
+ receipt.update(scale_search=report, optimized_mesh_scale=selected, final_generation_camera=dict(condition),
304
+ uses_dense_check_model=False, final_sparse_structure_resolution=32, resolution=1024)
305
+ print(f"[mesh_scale] selected={selected:.6f}, stop={report['stop_reason']}, probes={len(report['probes'])}", flush=True)
306
+ torch.manual_seed(seed)
307
+ cond = pipe.get_proj_cond_ss([image], **condition)
308
+ coords = pipe.sample_sparse_structure(cond, resolution=32, sampler_params=sampler_ss)
309
+ del cond
310
+ clear_cache()
311
+ cond = pipe.get_proj_cond_shape(pipe.image_cond_model_shape_512, [image], coords, **condition)
312
+ slat = pipe.sample_shape_slat(cond, pipe.models["shape_slat_flow_model_512"], coords, sampler_shape)
313
+ del cond, coords
314
+ clear_cache()
315
+ decoder = pipe.models["shape_slat_decoder"]
316
+ decoder.to(device)
317
+ decoder.low_vram = True
318
+ try:
319
+ hr_coords = decoder.upsample(slat, upsample_times=4)
320
+ finally:
321
+ decoder.cpu()
322
+ decoder.low_vram = False
323
+ # Match native 1024-cascade projection-grid quantization.
324
+ coords = torch.cat([hr_coords[:, :1], ((hr_coords[:, 1:] + .5) / 512 * 63).round().int()], dim=1).unique(dim=0)
325
+ del slat, hr_coords
326
+ clear_cache()
327
+ cond = pipe.get_proj_cond_shape(pipe.image_cond_model_shape_1024, [image], coords,
328
+ grid_resolution_override=64, **condition)
329
+ slat = pipe.sample_shape_slat(cond, pipe.models["shape_slat_flow_model_1024"], coords, sampler_shape)
330
+ del cond, coords
331
+ clear_cache()
332
+ meshes, _ = pipe.decode_shape_slat(slat, 1024)
333
+ if len(meshes) != 1 or not meshes[0].vertices.numel() or not meshes[0].faces.numel():
334
+ raise RuntimeError("geometry decoder returned an empty mesh")
335
+ meshes[0].fill_holes()
336
+ mesh = meshes[0]
337
+ vertices, faces = mesh.vertices.detach().float().cpu(), mesh.faces.detach().long().cpu()
338
+ if (vertices.ndim != 2 or vertices.shape[1] != 3 or not torch.isfinite(vertices).all()
339
+ or float(vertices.abs().max()) > .5001 or faces.ndim != 2 or faces.shape[1] != 3
340
+ or faces.min() < 0 or faces.max() >= len(vertices)):
341
+ raise ValueError("native geometry decoder returned invalid canonical mesh")
342
+ if condition["distance"] * selected <= float(vertices[:, 2].max()):
343
+ raise ValueError("optimized camera lies inside or behind the geometry")
344
+ final_camera = {**camera, **condition,
345
+ "moge_initial_camera": {key: camera[key] for key in ("camera_angle_x", "distance", "mesh_scale") if key in camera},
346
+ "geometry_scale_optimization": {"initial_mesh_scale": initial_mesh_scale,
347
+ "optimized_mesh_scale": selected,
348
+ "effective_camera_distance": condition["distance"] * selected,
349
+ "objective": "dense_grid_boundary_padding"}}
350
+ return {"verts": vertices, "faces": faces, "resolution": 1024}, final_camera
351
+
352
+
353
+ def run(package: Path, model_root: Path, input_dir: Path, output_dir: Path, *, seed: int = 42,
354
+ initial_mesh_scale: float = 1.0, scale_search: bool = False, target_padding: int = 3,
355
+ max_iterations: int = 2):
356
+ import torch
357
+
358
+ for name in ("geometry.pt", "camera.json", "geometry_receipt.json"):
359
+ if (output_dir / name).exists():
360
+ raise FileExistsError(f"geometry output already exists: {output_dir / name}")
361
+ if not torch.cuda.is_available():
362
+ raise RuntimeError("geometry generation requires CUDA")
363
+ from supratex.inference.runtime import configure_inference_runtime
364
+ runtime = configure_inference_runtime()
365
+ camera = json.loads((input_dir / "camera.json").read_text(encoding="utf-8"))
366
+ if any(key in camera for key in ("extrinsic", "intrinsic", "projection_intrinsic")):
367
+ raise ValueError("geometry generation requires a canonical scalar camera")
368
+ sys.path.insert(0, str(package.resolve()))
369
+ native = (package / "pixal3d/pipelines/pixal3d_image_to_3d.py").is_file()
370
+ kernel_config = None
371
+ if native:
372
+ from supratex.inference.kernel_config import configure_sparse_kernels
373
+ kernel_config = configure_sparse_kernels()
374
+ module = importlib.import_module("pixal3d.pipelines.pixal3d_image_to_3d" if native else "pixal3dpipeline2stage")
375
+ if not Path(module.__file__).resolve().is_relative_to(package.resolve()):
376
+ raise RuntimeError("geometry pipeline was imported outside the selected source package")
377
+ receipt = {"schema": "supratex.geometry.v2", "generator": "Pixal3D paper two-stage",
378
+ "runtime": runtime,
379
+ "source_package": str(package.resolve()), "model_root": str(model_root.resolve()),
380
+ "camera_estimator": camera.get("camera_estimator", "supplied camera"),
381
+ "initial_mesh_scale": initial_mesh_scale, "scale_search": scale_search,
382
+ "target_padding": target_padding,
383
+ "max_iterations": max_iterations, "seed": seed, "dense_probe_calls": 0,
384
+ "objective": "dense_grid_boundary_padding", "geometry_frame": "canonical [-0.5,0.5]",
385
+ "scope": "paper geometry generation with prepared demo camera; optional bounded scale calibration"}
386
+ if native:
387
+ from PIL import Image
388
+ from supratex.frontend.geometry_acceleration import NativeGeometryAcceleration
389
+ receipt.update(generator="Pixal3D native geometry cascade",
390
+ sparse_kernel_config=kernel_config,
391
+ scope="native geometry generation with prepared demo camera; optional bounded scale calibration")
392
+ pipeline = build_native_pipeline(model_root)
393
+ with Image.open(input_dir / "condition_rgb.png") as image, torch.inference_mode(), NativeGeometryAcceleration(pipeline) as acceleration:
394
+ geometry, final_camera = infer_native(pipeline, image.convert("RGB"), camera, receipt, seed=seed,
395
+ initial_mesh_scale=initial_mesh_scale, scale_search=scale_search,
396
+ target_padding=target_padding, max_iterations=max_iterations)
397
+ receipt["conditioning_acceleration"] = dict(acceleration.stats)
398
+ else:
399
+ pipeline_class = traced_pipeline(module.Pixal3DPipeline2Stage, camera, receipt, scale_search=scale_search)
400
+ pipeline = pipeline_class.from_pretrained(ckpt_dir=str(model_root), repo_id=None,
401
+ use_moge=False, use_dense_check=False)
402
+ receipt["uses_dense_check_model"] = bool(pipeline.has_dense_check)
403
+ with torch.inference_mode():
404
+ mesh = pipeline.infer(image=str(input_dir / "condition_rgb.png"), dense_seed=seed,
405
+ sparse_seed=seed, mesh_scale=initial_mesh_scale,
406
+ optimize_mesh_scale=True, target_padding=target_padding,
407
+ max_optim_iterations=max_iterations)
408
+ geometry, final_camera = canonical_handoff(mesh, camera, receipt)
409
+ receipt["vertices"] = int(geometry["verts"].shape[0])
410
+ receipt["faces"] = int(geometry["faces"].shape[0])
411
+ output_dir.mkdir(parents=True, exist_ok=True)
412
+ torch.save(geometry, output_dir / "geometry.pt")
413
+ (output_dir / "camera.json").write_text(json.dumps(final_camera, indent=2) + "\n", encoding="utf-8")
414
+ # Publish the receipt last, after the complete final mesh/camera handoff.
415
+ (output_dir / "geometry_receipt.json").write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")
416
+
417
+
418
+ def main():
419
+ parser = argparse.ArgumentParser(description=__doc__)
420
+ for name in ("package", "model-root", "input-dir", "output-dir"):
421
+ parser.add_argument(f"--{name}", type=Path, required=True)
422
+ parser.add_argument("--seed", type=int, default=42)
423
+ parser.add_argument("--initial-mesh-scale", type=float, default=1.0)
424
+ parser.add_argument("--scale-search", action="store_true")
425
+ parser.add_argument("--target-padding", type=int, default=3)
426
+ parser.add_argument("--max-iterations", type=int, default=2)
427
+ args = parser.parse_args()
428
+ run(args.package, args.model_root, args.input_dir, args.output_dir, seed=args.seed,
429
+ initial_mesh_scale=args.initial_mesh_scale, scale_search=args.scale_search,
430
+ target_padding=args.target_padding, max_iterations=args.max_iterations)
431
+
432
+
433
+ if __name__ == "__main__":
434
+ main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
supratex/frontend/material_model/runtime/pbr/conditioning.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Adapted for SUPRA-Tex distribution (2026): packaged local inference runtime.
2
+ # Upstream FireRed portions remain under Apache-2.0; see THIRD_PARTY_NOTICES.md.
3
+ # Copyright (c) 2025 FireRed-Image-Edit. All rights reserved.
4
+ """Image preprocessing and prompt conditioning for material inference."""
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ from typing import List, Tuple
9
+
10
+ from PIL import Image
11
+ import torch
12
+ from torchvision import transforms
13
+
14
+
15
+ DEFAULT_SYSTEM_PROMPT = "Describe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate."
16
+
17
+ DEFAULT_SYSTEM_PROMPT_T2I = "Describe the image by detailing the color, shape, size, texture, quantity, text, spatial relationships of the objects and background:"
18
+
19
+ ASPECT_RATIO_512 = {
20
+ '0.25': [256.0, 1024.0], '0.26': [256.0, 992.0], '0.27': [256.0, 960.0], '0.28': [256.0, 928.0],
21
+ '0.32': [288.0, 896.0], '0.33': [288.0, 864.0], '0.35': [288.0, 832.0], '0.4': [320.0, 800.0],
22
+ '0.42': [320.0, 768.0], '0.48': [352.0, 736.0], '0.5': [352.0, 704.0], '0.52': [352.0, 672.0],
23
+ '0.57': [384.0, 672.0], '0.6': [384.0, 640.0], '0.68': [416.0, 608.0], '0.72': [416.0, 576.0],
24
+ '0.78': [448.0, 576.0], '0.82': [448.0, 544.0], '0.88': [480.0, 544.0], '0.94': [480.0, 512.0],
25
+ '1.0': [512.0, 512.0], '1.07': [512.0, 480.0], '1.13': [544.0, 480.0], '1.21': [544.0, 448.0],
26
+ '1.29': [576.0, 448.0], '1.38': [576.0, 416.0], '1.46': [608.0, 416.0], '1.67': [640.0, 384.0],
27
+ '1.75': [672.0, 384.0], '2.0': [704.0, 352.0], '2.09': [736.0, 352.0], '2.4': [768.0, 320.0],
28
+ '2.5': [800.0, 320.0], '2.89': [832.0, 288.0], '3.0': [864.0, 288.0], '3.11': [896.0, 288.0],
29
+ '3.62': [928.0, 256.0], '3.75': [960.0, 256.0], '3.88': [992.0, 256.0], '4.0': [1024.0, 256.0]
30
+ }
31
+
32
+ CONDITION_IMAGE_SIZE = 384 * 384
33
+
34
+
35
+ def calculate_shift(
36
+ image_seq_len,
37
+ base_seq_len: int = 256,
38
+ max_seq_len: int = 4096,
39
+ base_shift: float = 0.5,
40
+ max_shift: float = 1.15,
41
+ ):
42
+ """根据图像序列长度计算 flow 的 shift 系数(线性插值)。"""
43
+ m = (max_shift - base_shift) / (max_seq_len - base_seq_len)
44
+ b = base_shift - m * base_seq_len
45
+ mu = image_seq_len * m + b
46
+ return mu
47
+
48
+ def get_embedding(source_exist, instructions, source_images, processor, text_encoder):
49
+ def _extract_valid_hidden_states(
50
+ hidden_states: torch.Tensor,
51
+ attention_mask: torch.Tensor,
52
+ ):
53
+ batch_size = hidden_states.shape[0]
54
+ valid_hidden_states = []
55
+ for i in range(batch_size):
56
+ valid_indices = attention_mask[i].nonzero(as_tuple=True)[0]
57
+ valid_hidden_states.append(hidden_states[i][valid_indices])
58
+ return valid_hidden_states
59
+
60
+ with torch.no_grad():
61
+ conversations = []
62
+ if not source_exist:
63
+ source_images = [[] for _ in range(len(instructions))]
64
+ for instruction, source_images_sample in zip(instructions, source_images):
65
+ system_prompt = DEFAULT_SYSTEM_PROMPT_T2I if not source_exist else DEFAULT_SYSTEM_PROMPT
66
+ messages = [
67
+ {"role": "system", "content": [{"type": "text", "text": system_prompt}]},
68
+ {"role": "user", "content": []},
69
+ ]
70
+ for condition_image in source_images_sample:
71
+ messages[1]["content"].append({"type": "image", "image": condition_image})
72
+ messages[1]["content"].append({"type": "text", "text": instruction})
73
+ conversations.append(messages)
74
+
75
+ batch_inputs = processor.apply_chat_template(
76
+ conversations,
77
+ add_generation_prompt=True,
78
+ padding=True,
79
+ tokenize=True,
80
+ return_tensors="pt",
81
+ return_dict=True,
82
+ ).to(text_encoder.device)
83
+
84
+ encoder_hidden_states = text_encoder(
85
+ input_ids=batch_inputs["input_ids"],
86
+ attention_mask=batch_inputs["attention_mask"],
87
+ pixel_values=batch_inputs["pixel_values"] if source_exist else None,
88
+ image_grid_thw=batch_inputs["image_grid_thw"] if source_exist else None,
89
+ output_hidden_states=True,
90
+ )
91
+
92
+ hidden_states = encoder_hidden_states.hidden_states[-1]
93
+ split_hidden_states = _extract_valid_hidden_states(hidden_states, batch_inputs["attention_mask"])
94
+ start_idx = 64 if source_exist else 34
95
+ split_hidden_states = [e[start_idx:] for e in split_hidden_states]
96
+ attn_mask_list = [torch.ones(e.size(0), dtype=torch.long, device=e.device) for e in split_hidden_states]
97
+ max_seq_len = max([e.size(0) for e in split_hidden_states])
98
+ prompt_embeds = torch.stack(
99
+ [torch.cat([u, u.new_zeros(max_seq_len - u.size(0), u.size(1))]) for u in split_hidden_states]
100
+ )
101
+ encoder_attention_mask = torch.stack(
102
+ [torch.cat([u, u.new_zeros(max_seq_len - u.size(0))]) for u in attn_mask_list]
103
+ )
104
+
105
+ return prompt_embeds, encoder_attention_mask
106
+
107
+ def get_closest_ratio(height: float, width: float, ratios: dict = ASPECT_RATIO_512) -> Tuple[List[float], float]:
108
+ aspect_ratio = height / width
109
+ closest_ratio = min(ratios.keys(), key=lambda r: abs(float(r) - aspect_ratio))
110
+ return ratios[closest_ratio], float(closest_ratio)
111
+
112
+ def calculate_dimensions(target_area: float, ratio: float) -> Tuple[int, int]:
113
+ width = math.sqrt(target_area * ratio)
114
+ height = width / ratio
115
+ width = round(width / 32) * 32
116
+ height = round(height / 32) * 32
117
+ return int(width), int(height)
118
+
119
+ def resize_source_images_for_condition(
120
+ source_images_list: List[List[Image.Image]],
121
+ ref_height: int,
122
+ ref_width: int,
123
+ image_sample_size: int,
124
+ condition_image_size: int = CONDITION_IMAGE_SIZE,
125
+ ) -> List[List[Image.Image]]:
126
+ """
127
+ 对送入 text_encoder 的 source 图像做 Resize + CenterCrop,再缩放到 condition_image_size 对应尺寸。
128
+ 参考尺寸为 (ref_height, ref_width)。source_images_list: 每样本多张 source 图的 list of list of PIL。
129
+ 返回相同结构的 list of list of PIL Image。
130
+ """
131
+ if not source_images_list:
132
+ return source_images_list
133
+ return [
134
+ apply_condition_transform_to_images(sample, ref_height, ref_width, image_sample_size, condition_image_size)
135
+ for sample in source_images_list
136
+ ]
137
+
138
+ def apply_condition_transform_to_images(
139
+ images: List[Image.Image],
140
+ ref_height: int,
141
+ ref_width: int,
142
+ image_sample_size: int,
143
+ condition_image_size: int = CONDITION_IMAGE_SIZE,
144
+ ) -> List[Image.Image]:
145
+ """
146
+ 对多张图像应用统一的 Resize+CenterCrop+最终缩放。参考尺寸 (ref_height, ref_width)。
147
+ 返回与 images 等长的 list of PIL Image。
148
+ """
149
+ if not images:
150
+ return []
151
+ h, w = float(ref_height), float(ref_width)
152
+ aspect_ratio_sample_size = {
153
+ k: [x / 512 * image_sample_size for x in ASPECT_RATIO_512[k]]
154
+ for k in ASPECT_RATIO_512
155
+ }
156
+ closest_size, _ = get_closest_ratio(h, w, ratios=aspect_ratio_sample_size)
157
+ closest_size = [int(x / 16) * 16 for x in closest_size]
158
+ closest_size = list(map(int, closest_size))
159
+ if closest_size[0] / h > closest_size[1] / w:
160
+ resize_size = (closest_size[0], int(w * closest_size[0] / h))
161
+ else:
162
+ resize_size = (int(h * closest_size[1] / w), closest_size[1])
163
+
164
+ source_transform = transforms.Compose([
165
+ transforms.Resize(resize_size, interpolation=transforms.InterpolationMode.BILINEAR),
166
+ transforms.CenterCrop(closest_size),
167
+ ])
168
+
169
+ result = []
170
+ for im in images:
171
+ if im.mode != "RGB":
172
+ im = im.convert("RGB")
173
+ cropped = source_transform(im)
174
+ width_c, height_c = cropped.size
175
+ aspect_ratio = width_c / height_c
176
+ new_width, new_height = calculate_dimensions(condition_image_size, aspect_ratio)
177
+ result.append(cropped.resize((new_width, new_height), Image.Resampling.LANCZOS))
178
+ return result
supratex/frontend/material_model/runtime/pbr/model_loading.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Adapted for SUPRA-Tex distribution (2026): packaged local inference runtime.
2
+ # Upstream FireRed portions remain under Apache-2.0; see THIRD_PARTY_NOTICES.md.
3
+ # Copyright (c) 2025 FireRed-Image-Edit. All rights reserved.
4
+ """Strict checkpoint loading for the FireRed material inference model."""
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from pathlib import Path
9
+
10
+ import torch
11
+
12
+ logger = logging.getLogger(__name__)
13
+ JOINT_MODULE_PREFIX = "modality_joint_blocks."
14
+
15
+
16
+ def _require_exact_loading_info(loading_info, source: str) -> None:
17
+ problems = {
18
+ name: list(loading_info.get(name, []))
19
+ for name in ("missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs")
20
+ if loading_info.get(name)
21
+ }
22
+ if problems:
23
+ summary = ", ".join(f"{name}={len(values)}" for name, values in problems.items())
24
+ raise RuntimeError(f"{source} transformer load is not exact: {summary}")
25
+
26
+ def _load_base_transformer(args, weight_dtype, *, use_layer3d_rope: bool = False):
27
+ from .qwen_pbr_factorized import (
28
+ PRODUCTION_JOINT_HEADS,
29
+ PRODUCTION_JOINT_MODALITIES,
30
+ PRODUCTION_JOINT_RANK,
31
+ QwenImagePBRFactorizedTransformer2DModel,
32
+ )
33
+
34
+ loaded = QwenImagePBRFactorizedTransformer2DModel.from_pretrained(
35
+ args.pretrained_model_name_or_path,
36
+ subfolder="transformer",
37
+ torch_dtype=weight_dtype,
38
+ joint_sites=(),
39
+ joint_modalities=PRODUCTION_JOINT_MODALITIES,
40
+ joint_rank=PRODUCTION_JOINT_RANK,
41
+ joint_heads=PRODUCTION_JOINT_HEADS,
42
+ use_layer3d_rope=use_layer3d_rope,
43
+ output_loading_info=True,
44
+ )
45
+ if not isinstance(loaded, tuple) or len(loaded) != 2:
46
+ raise RuntimeError("custom transformer loader did not return loading information")
47
+ transformer, loading_info = loaded
48
+ _require_exact_loading_info(loading_info, "base")
49
+ if tuple(transformer.joint_sites):
50
+ raise RuntimeError("base transformer must load without joint modules")
51
+ return transformer
52
+
53
+ def _resolve_full_transformer_checkpoint(path: str | Path) -> Path:
54
+ checkpoint = Path(path)
55
+ if checkpoint.is_dir():
56
+ checkpoint = checkpoint / "diffusion_pytorch_model.safetensors"
57
+ if not checkpoint.is_file():
58
+ raise FileNotFoundError(f"full transformer checkpoint not found: {checkpoint}")
59
+ return checkpoint
60
+
61
+ def _load_checkpoint_state(path: Path):
62
+ if path.suffix == ".safetensors":
63
+ from safetensors.torch import load_file
64
+
65
+ state = load_file(str(path), device="cpu")
66
+ else:
67
+ state = torch.load(str(path), map_location="cpu", weights_only=True)
68
+ if isinstance(state, dict) and "state_dict" in state:
69
+ state = state["state_dict"]
70
+ if not isinstance(state, dict):
71
+ raise ValueError(f"transformer checkpoint must contain a state dict: {path}")
72
+ return state
73
+
74
+ def _load_exact_full_transformer(
75
+ transformer,
76
+ checkpoint_path: str | Path,
77
+ *,
78
+ allow_joint_sidecar: bool = False,
79
+ ) -> None:
80
+ path = _resolve_full_transformer_checkpoint(checkpoint_path)
81
+ state = _load_checkpoint_state(path)
82
+ if allow_joint_sidecar:
83
+ state = {
84
+ name: value
85
+ for name, value in state.items()
86
+ if not name.startswith(JOINT_MODULE_PREFIX)
87
+ }
88
+ result = transformer.load_state_dict(state, strict=False)
89
+ if result.missing_keys or result.unexpected_keys:
90
+ raise RuntimeError(
91
+ "full transformer checkpoint load is not exact: "
92
+ f"missing_keys={len(result.missing_keys)}, "
93
+ f"unexpected_keys={len(result.unexpected_keys)}"
94
+ )
95
+ logger.info( "Loaded exact full transformer checkpoint: %s", path)
96
+
97
+ def _resolve_joint_adapter_checkpoint(path: str | Path) -> Path:
98
+ checkpoint = Path(path)
99
+ if checkpoint.is_dir():
100
+ checkpoint = checkpoint / "joint_adapter.safetensors"
101
+ if not checkpoint.is_file():
102
+ raise FileNotFoundError(f"joint adapter checkpoint not found: {checkpoint}")
103
+ return checkpoint
104
+
105
+ def _load_exact_joint_adapter(transformer, checkpoint_path: str | Path) -> None:
106
+ path = _resolve_joint_adapter_checkpoint(checkpoint_path)
107
+ state = _load_checkpoint_state(path)
108
+ expected = {
109
+ name: parameter
110
+ for name, parameter in transformer.named_parameters()
111
+ if name.startswith(JOINT_MODULE_PREFIX)
112
+ }
113
+ actual_keys = set(state)
114
+ expected_keys = set(expected)
115
+ if actual_keys != expected_keys:
116
+ missing = sorted(expected_keys - actual_keys)
117
+ unexpected = sorted(actual_keys - expected_keys)
118
+ raise RuntimeError(
119
+ "joint adapter checkpoint is not exact: "
120
+ f"missing={missing[:3]}, unexpected={unexpected[:3]}"
121
+ )
122
+ mismatched = [
123
+ name
124
+ for name, parameter in expected.items()
125
+ if not isinstance(state[name], torch.Tensor)
126
+ or tuple(state[name].shape) != tuple(parameter.shape)
127
+ ]
128
+ if mismatched:
129
+ raise RuntimeError(f"joint adapter checkpoint shape mismatch: {mismatched[:3]}")
130
+ result = transformer.load_state_dict(state, strict=False)
131
+ if result.unexpected_keys:
132
+ raise RuntimeError(
133
+ "joint adapter checkpoint has unexpected keys after load: "
134
+ f"{list(result.unexpected_keys)[:3]}"
135
+ )
136
+ logger.info( "Loaded exact joint adapter checkpoint: %s", path)
supratex/frontend/material_model/runtime/pbr/predict_joint_pbr.py CHANGED
@@ -1,595 +1,592 @@
1
- #!/usr/bin/env python
2
- # Adapted for SUPRA-Tex distribution (2026): packaged local inference runtime.
3
- # Upstream FireRed portions remain under Apache-2.0; see LICENSE.
4
- """Joint albedo/MRA/normal inference for the factorized PBR checkpoint.
5
-
6
- The stock QwenImageEditPlusPipeline rejects prompt batches larger than one, so
7
- it cannot exercise modality-axis attention. This runner keeps the accepted
8
- pipeline preprocessing/scheduler/decoder semantics, but folds one physical
9
- item into the fixed ``albedo, mra, normal`` batch and advances all three streams
10
- together. Joint checkpoints are trained only at sigma=1, therefore this
11
- runner intentionally accepts one-step, CFG-free inference. The checkpoint
12
- contract selects the recorded 512/1024 resolution for v2/v3.
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import argparse
18
- import importlib
19
- import json
20
- import os
21
- import sys
22
- import time
23
- from pathlib import Path
24
- from types import SimpleNamespace
25
-
26
-
27
- REPO = Path(__file__).absolute().parent.parent
28
- TRAIN_ROOT = REPO / "train"
29
- MODALITIES = ("albedo", "mra", "normal")
30
- JOINT_ROUTES = ("aligned", "self_slot", "blockdiag")
31
-
32
-
33
- def log(message: str) -> None:
34
- print(f"[predict_joint_pbr {time.strftime('%H:%M:%S')}] {message}", flush=True)
35
-
36
-
37
- def output_path(out_dir: Path, item: dict, modality: str) -> Path:
38
- return out_dir / f"{item['key']}_{modality}.png"
39
-
40
-
41
- def load_items(path: Path, limit: int | None) -> list[dict]:
42
- required = {
43
- "key",
44
- "dataset",
45
- "sha",
46
- "asset_id",
47
- "view",
48
- "cond",
49
- "albedo_gt",
50
- "mra_gt",
51
- "normal_gt",
52
- "normal_valid",
53
- "instr_albedo",
54
- "instr_mra",
55
- "instr_normal",
56
- "split",
57
- }
58
- items = []
59
- seen = set()
60
- with path.open() as stream:
61
- for line_number, raw_line in enumerate(stream, 1):
62
- if not raw_line.strip():
63
- continue
64
- item = json.loads(raw_line)
65
- missing = required - set(item)
66
- if missing:
67
- raise ValueError(f"{path}:{line_number}: missing fields {sorted(missing)}")
68
- key = item["key"]
69
- if not isinstance(key, str) or not key or Path(key).name != key or key in {".", ".."} or "\\" in key:
70
- raise ValueError(f"{path}:{line_number}: key must be a single safe filename component")
71
- if item["key"] in seen:
72
- raise ValueError(f"{path}:{line_number}: duplicate key {item['key']}")
73
- if item["split"] != "eval":
74
- raise ValueError(f"{path}:{line_number}: expected split=eval")
75
- seen.add(item["key"])
76
- items.append(item)
77
- if limit is not None:
78
- items = items[:limit]
79
- if not items:
80
- raise ValueError(f"no evaluation items in {path}")
81
- return items
82
-
83
-
84
- def validate_checkpoint(checkpoint: Path, route: str) -> SimpleNamespace:
85
- manifest_path = checkpoint / "checkpoint_complete.json"
86
- contract_path = checkpoint / "joint_contract.json"
87
- run_contract_path = checkpoint.parent / "RUN_CONTRACT.json"
88
- adapter_path = checkpoint / "joint_adapter.safetensors"
89
- full_transformer_path = checkpoint / "diffusion_pytorch_model.safetensors"
90
- for path in (manifest_path, contract_path):
91
- if not path.is_file():
92
- raise FileNotFoundError(path)
93
- manifest = json.loads(manifest_path.read_text())
94
- contract = json.loads(contract_path.read_text())
95
- common_expected = {
96
- "sites": [29, 39, 49, 59],
97
- "modalities": list(MODALITIES),
98
- "rank": 256,
99
- "heads": 4,
100
- "training_route": "aligned",
101
- }
102
- schema = contract.get("schema")
103
- if schema == "pbr_joint_checkpoint_v1":
104
- expected = {
105
- **common_expected,
106
- "schema": schema,
107
- "trainable_tensor_count": 60,
108
- "trainable_parameter_count": 7_385_088,
109
- }
110
- input_size = 512
111
- elif schema in ("pbr_joint_checkpoint_v2", "pbr_joint_checkpoint_v3"):
112
- if not run_contract_path.is_file():
113
- raise FileNotFoundError(run_contract_path)
114
- run_contract = json.loads(run_contract_path.read_text())
115
- input_size = run_contract.get("resolution")
116
- if input_size not in (512, 1024):
117
- raise ValueError(
118
- f"unsupported joint-v2/v3 run resolution: {input_size!r}"
119
- )
120
- expected = {
121
- **common_expected,
122
- "schema": schema,
123
- "joint_tensor_count": 60,
124
- "joint_parameter_count": 7_385_088,
125
- "trainable_modules": ["modality_joint_blocks."],
126
- }
127
- backbone_low_lr = [
128
- f"transformer_blocks.{index}." for index in range(29, 50)
129
- ]
130
- if schema == "pbr_joint_checkpoint_v2":
131
- expected.update(
132
- low_lr_modules=backbone_low_lr,
133
- low_lr_ratio=0.0025,
134
- )
135
- if contract.get("fresh_joint_w_up_init_std") not in (0.0, 1e-4):
136
- expected["fresh_joint_w_up_init_std"] = "0.0 or 1e-4"
137
- residual_mode = "raw"
138
- else:
139
- low_lr_modules = contract.get("low_lr_modules")
140
- if low_lr_modules == []:
141
- expected.update(low_lr_modules=[], low_lr_ratio=None)
142
- elif low_lr_modules == backbone_low_lr:
143
- expected.update(
144
- low_lr_modules=backbone_low_lr,
145
- low_lr_ratio=0.0025,
146
- )
147
- else:
148
- expected["low_lr_modules"] = "[] or transformer blocks 29 through 49"
149
- residual_mode = contract.get("residual_mode")
150
- if residual_mode != "rms_scaled":
151
- expected["residual_mode"] = "rms_scaled"
152
- if contract.get("fresh_joint_w_up_init_std") not in (0.0, 3e-3):
153
- expected["fresh_joint_w_up_init_std"] = "0.0 or 0.003"
154
- if contract.get("source_joint_adapter_action") not in (
155
- "fresh", "loaded", "reset"
156
- ):
157
- expected["source_joint_adapter_action"] = "fresh, loaded, or reset"
158
- else:
159
- raise ValueError(f"unsupported joint checkpoint schema: {schema!r}")
160
- bad = {
161
- key: (contract.get(key), value)
162
- for key, value in expected.items()
163
- if contract.get(key) != value
164
- }
165
- if bad:
166
- raise ValueError(f"joint checkpoint contract mismatch: {bad}")
167
- if manifest.get("joint_contract") != contract:
168
- raise ValueError("checkpoint manifest is not bound to joint_contract.json")
169
- if schema == "pbr_joint_checkpoint_v1":
170
- if (manifest.get("global_step"), manifest.get("world_size"), manifest.get("seed")) \
171
- != (2000, 40, 42):
172
- raise ValueError(f"joint checkpoint step/world/seed mismatch: {manifest}")
173
- elif (
174
- manifest.get("format_version") != 2
175
- or not isinstance(manifest.get("global_step"), int)
176
- or manifest["global_step"] <= 0
177
- ):
178
- raise ValueError(f"joint-v2/v3 checkpoint manifest mismatch: {manifest}")
179
- if route in JOINT_ROUTES and schema == "pbr_joint_checkpoint_v1":
180
- if not adapter_path.is_file():
181
- raise FileNotFoundError(adapter_path)
182
- if route in JOINT_ROUTES and schema in (
183
- "pbr_joint_checkpoint_v2", "pbr_joint_checkpoint_v3"
184
- ):
185
- if not full_transformer_path.is_file():
186
- raise FileNotFoundError(full_transformer_path)
187
- return SimpleNamespace(
188
- schema=schema,
189
- input_size=input_size,
190
- adapter_path=adapter_path if schema == "pbr_joint_checkpoint_v1" else None,
191
- full_transformer_path=(
192
- full_transformer_path
193
- if schema in ("pbr_joint_checkpoint_v2", "pbr_joint_checkpoint_v3")
194
- else None
195
- ),
196
- residual_mode="raw" if schema != "pbr_joint_checkpoint_v3" else residual_mode,
197
- )
198
-
199
-
200
- def validate_payloads(items: list[dict]) -> None:
201
- fields = ("cond", "albedo_gt", "mra_gt", "normal_gt", "normal_valid")
202
- for item in items:
203
- missing = [item[field] for field in fields if not Path(item[field]).is_file()]
204
- if missing:
205
- raise FileNotFoundError(
206
- f"{item['key']}: missing payload(s): {missing[:3]}"
207
- )
208
-
209
-
210
- def load_source_rgb(path: str, size: int):
211
- from PIL import Image
212
-
213
- image = Image.open(path)
214
- if image.mode != "RGB":
215
- rgba = image.convert("RGBA")
216
- background = Image.new("RGBA", rgba.size, (0, 0, 0, 255))
217
- image = Image.alpha_composite(background, rgba).convert("RGB")
218
- if image.size != (size, size):
219
- image = image.resize((size, size), Image.Resampling.BICUBIC)
220
- return image
221
-
222
-
223
- def _flash_attention_available(module_name: str) -> bool:
224
- try:
225
- module = importlib.import_module(module_name)
226
- except (ImportError, OSError, RuntimeError):
227
- return False
228
- return all(
229
- callable(getattr(module, name, None))
230
- for name in ("flash_attn_func", "flash_attn_varlen_func")
231
- )
232
-
233
-
234
- def select_attention_backends(torch, device) -> tuple[str, str]:
235
- """Resolve inference attention without depending on the parent CLI package.
236
-
237
- The image transformer supports FA3 and native PyTorch SDPA. FA2 can still
238
- accelerate the text encoder when the image transformer uses native SDPA.
239
- An explicit SDPA selection applies to both components.
240
- """
241
- selected = os.environ.get("ATTN_BACKEND", "auto")
242
- if selected == "sdpa":
243
- return "native", "sdpa"
244
- supported = ("auto", "flash_attn_3", "flash_attn", "xformers", "naive", "flash_attn_4")
245
- if selected not in supported:
246
- raise ValueError(f"unsupported ATTN_BACKEND for FireRed: {selected!r}")
247
-
248
- device = torch.device(device)
249
- capability = (
250
- torch.cuda.get_device_capability(device)
251
- if device.type == "cuda" and torch.cuda.is_available()
252
- else (0, 0)
253
- )
254
- use_fa3 = (
255
- selected in ("auto", "flash_attn_3")
256
- and capability[0] == 9
257
- and _flash_attention_available("flash_attn_interface")
258
- )
259
- if selected == "flash_attn_3" and not use_fa3:
260
- raise RuntimeError(
261
- "ATTN_BACKEND=flash_attn_3 requires Hopper (SM90) and an importable "
262
- "flash_attn_interface extension; select sdpa for native attention"
263
- )
264
- use_fa2 = capability[0] in (8, 9) and _flash_attention_available("flash_attn")
265
- if selected == "flash_attn" and not use_fa2:
266
- raise RuntimeError(
267
- "ATTN_BACKEND=flash_attn requires an Ampere/Ada/Hopper GPU and an "
268
- "importable flash_attn extension; select sdpa for native attention"
269
- )
270
- return "fa3" if use_fa3 else "native", "flash_attention_2" if use_fa2 else "sdpa"
271
-
272
-
273
- def load_pipeline(args, device):
274
- import torch
275
- from diffusers import FlowMatchEulerDiscreteScheduler, QwenImageEditPlusPipeline
276
-
277
- image_backend, text_backend = select_attention_backends(torch, device)
278
- sys.path.insert(0, str(TRAIN_ROOT))
279
- from src.joint_model_provider import (
280
- _load_base_transformer,
281
- _load_exact_full_transformer,
282
- _load_exact_joint_adapter,
283
- )
284
- from src.models import (
285
- PBR_ATTENTION_FA3,
286
- PBR_ATTENTION_NATIVE,
287
- PRODUCTION_JOINT_HEADS,
288
- PRODUCTION_JOINT_MODALITIES,
289
- PRODUCTION_JOINT_RANK,
290
- PRODUCTION_JOINT_SITES,
291
- )
292
-
293
- model_args = SimpleNamespace(pretrained_model_name_or_path=str(args.base_model))
294
- transformer = _load_base_transformer(model_args, torch.bfloat16)
295
- if args.route == "base" or args.joint_spec.schema == "pbr_joint_checkpoint_v1":
296
- _load_exact_full_transformer(
297
- transformer,
298
- args.transformer_path,
299
- allow_joint_sidecar=args.route == "base",
300
- )
301
- if args.route in JOINT_ROUTES:
302
- transformer.attach_joint_modules(
303
- sites=PRODUCTION_JOINT_SITES,
304
- modalities=PRODUCTION_JOINT_MODALITIES,
305
- rank=PRODUCTION_JOINT_RANK,
306
- heads=PRODUCTION_JOINT_HEADS,
307
- residual_mode=args.joint_spec.residual_mode,
308
- )
309
- if args.joint_spec.schema == "pbr_joint_checkpoint_v1":
310
- _load_exact_joint_adapter(transformer, args.joint_spec.adapter_path)
311
- else:
312
- _load_exact_full_transformer(
313
- transformer, args.joint_spec.full_transformer_path
314
- )
315
- transformer.set_pbr_attention_backend(
316
- PBR_ATTENTION_FA3 if image_backend == "fa3" else PBR_ATTENTION_NATIVE,
317
- device=device,
318
- dtype=torch.bfloat16,
319
- )
320
- transformer.to(dtype=torch.bfloat16)
321
- transformer.requires_grad_(False)
322
- transformer.eval()
323
-
324
- pipe = QwenImageEditPlusPipeline.from_pretrained(
325
- str(args.base_model),
326
- transformer=transformer,
327
- torch_dtype=torch.bfloat16,
328
- local_files_only=True,
329
- )
330
- scheduler_config = dict(pipe.scheduler.config)
331
- scheduler_config["shift_terminal"] = None
332
- pipe.scheduler = FlowMatchEulerDiscreteScheduler.from_config(scheduler_config)
333
- pipe.to(device)
334
- pipe.text_encoder.set_attn_implementation(text_backend)
335
- pipe.set_progress_bar_config(disable=True)
336
- log(f"attention image={image_backend} text={text_backend}")
337
- return pipe
338
-
339
-
340
- def make_generators(torch, device, seed: int):
341
- # Keep per-modality noise independent, deterministic, and invariant to GPU
342
- # sharding. All routes use these exact three streams.
343
- offsets = (104_729, 209_759, 314_159)
344
- return [
345
- torch.Generator(device=device).manual_seed(seed + offset)
346
- for offset in offsets
347
- ]
348
-
349
-
350
- def encode_source(pipe, source, device, dtype, seed: int):
351
- import numpy as np
352
- import torch
353
-
354
- pixels = torch.from_numpy(
355
- np.asarray(source, dtype=np.float32).copy()
356
- ).permute(2, 0, 1).unsqueeze(0)
357
- pixels = (pixels / 127.5 - 1.0).to(device=device, dtype=dtype).unsqueeze(2)
358
- generator = torch.Generator(device=device).manual_seed(seed)
359
- source_latents = pipe._encode_vae_image(pixels, generator=generator)
360
- _, channels, _, height, width = source_latents.shape
361
- return pipe._pack_latents(source_latents, 1, channels, height, width)
362
-
363
-
364
- def encode_prompts(pipe, item: dict, source, input_size: int, device, dtype):
365
- sys.path.insert(0, str(TRAIN_ROOT))
366
- from src.forward_step import get_embedding
367
- from src.utils.image_utils import resize_source_images_for_condition
368
-
369
- prompts = [item[f"instr_{modality}"] for modality in MODALITIES]
370
- source_lists = resize_source_images_for_condition(
371
- [[source] for _ in MODALITIES],
372
- ref_height=input_size,
373
- ref_width=input_size,
374
- image_sample_size=input_size,
375
- )
376
- embeds, mask = get_embedding(
377
- True,
378
- prompts,
379
- source_lists,
380
- pipe.processor,
381
- pipe.text_encoder,
382
- )
383
- return embeds.to(device=device, dtype=dtype), mask.to(device=device)
384
-
385
-
386
- def generate_triplet(pipe, item: dict, args, device):
387
- import numpy as np
388
- import torch
389
-
390
- sys.path.insert(0, str(TRAIN_ROOT))
391
- from src.forward_step import calculate_shift
392
-
393
- source = load_source_rgb(item["cond"], args.input_size)
394
- dtype = pipe.transformer.dtype
395
- prompt_embeds, prompt_mask = encode_prompts(
396
- pipe, item, source, args.input_size, device, dtype
397
- )
398
- source_latents = encode_source(pipe, source, device, dtype, args.seed)
399
- source_latents = source_latents.expand(len(MODALITIES), -1, -1)
400
-
401
- channels = pipe.transformer.config.in_channels // 4
402
- generators = make_generators(torch, device, args.seed)
403
- latents, _ = pipe.prepare_latents(
404
- images=None,
405
- batch_size=len(MODALITIES),
406
- num_channels_latents=channels,
407
- height=args.input_size,
408
- width=args.input_size,
409
- dtype=dtype,
410
- device=device,
411
- generator=generators,
412
- latents=None,
413
- )
414
- target_seq_len = latents.shape[1]
415
- grid = args.input_size // pipe.vae_scale_factor // 2
416
- img_shapes = [[(1, grid, grid), (1, grid, grid)] for _ in MODALITIES]
417
-
418
- sigmas = np.linspace(1.0, 1.0, 1)
419
- mu = calculate_shift(
420
- target_seq_len,
421
- pipe.scheduler.config.get("base_image_seq_len", 256),
422
- pipe.scheduler.config.get("max_image_seq_len", 4096),
423
- pipe.scheduler.config.get("base_shift", 0.5),
424
- pipe.scheduler.config.get("max_shift", 1.15),
425
- )
426
- pipe.scheduler.set_timesteps(sigmas=sigmas, device=device, mu=mu)
427
- pipe.scheduler.set_begin_index(0)
428
-
429
- with torch.inference_mode():
430
- for timestep in pipe.scheduler.timesteps:
431
- hidden_states = torch.cat([latents, source_latents], dim=1)
432
- model_timestep = timestep.expand(len(MODALITIES)).to(dtype) / 1000
433
- transformer_kwargs = {
434
- "hidden_states": hidden_states,
435
- "timestep": model_timestep,
436
- "guidance": None,
437
- "encoder_hidden_states_mask": prompt_mask,
438
- "encoder_hidden_states": prompt_embeds,
439
- "img_shapes": img_shapes,
440
- "attention_kwargs": {},
441
- "return_dict": False,
442
- }
443
- if args.route in JOINT_ROUTES:
444
- transformer_kwargs.update(
445
- pbr_joint_batch_size=1,
446
- pbr_joint_target_seq_len=target_seq_len,
447
- pbr_joint_route=args.route,
448
- )
449
- with pipe.transformer.cache_context("cond"):
450
- prediction = pipe.transformer(**transformer_kwargs)[0]
451
- prediction = prediction[:, :target_seq_len]
452
- latents = pipe.scheduler.step(
453
- prediction, timestep, latents, return_dict=False
454
- )[0]
455
-
456
- unpacked = pipe._unpack_latents(
457
- latents, args.input_size, args.input_size, pipe.vae_scale_factor
458
- ).to(pipe.vae.dtype)
459
- mean = torch.tensor(pipe.vae.config.latents_mean).view(
460
- 1, pipe.vae.config.z_dim, 1, 1, 1
461
- ).to(unpacked.device, unpacked.dtype)
462
- std = 1.0 / torch.tensor(pipe.vae.config.latents_std).view(
463
- 1, pipe.vae.config.z_dim, 1, 1, 1
464
- ).to(unpacked.device, unpacked.dtype)
465
- unpacked = unpacked / std + mean
466
- decoded = pipe.vae.decode(unpacked, return_dict=False)[0][:, :, 0]
467
- images = pipe.image_processor.postprocess(decoded, output_type="pil")
468
- if len(images) != len(MODALITIES):
469
- raise RuntimeError(f"decoder returned {len(images)} images, expected 3")
470
- return images
471
-
472
-
473
- def save_triplet(out_dir: Path, item: dict, images) -> None:
474
- for modality, image in zip(MODALITIES, images):
475
- destination = output_path(out_dir, item, modality)
476
- temporary = destination.with_name(destination.name + f".tmp.{os.getpid()}.png")
477
- image.save(temporary, format="PNG")
478
- os.replace(temporary, destination)
479
-
480
-
481
- def main() -> None:
482
- parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
483
- parser.add_argument("--items-jsonl", type=Path, required=True)
484
- parser.add_argument("--out-dir", type=Path, required=True)
485
- parser.add_argument("--base-model", type=Path, required=True)
486
- parser.add_argument("--transformer-path", type=Path, required=True)
487
- parser.add_argument("--joint-checkpoint", type=Path, required=True)
488
- parser.add_argument("--route", choices=("base",) + JOINT_ROUTES, required=True)
489
- parser.add_argument("--input-size", type=int, default=512)
490
- parser.add_argument("--steps", type=int, default=1)
491
- parser.add_argument("--cfg", type=float, default=1.0)
492
- parser.add_argument("--seed", type=int, default=42)
493
- parser.add_argument("--limit", type=int, default=None)
494
- parser.add_argument("--num-shards", type=int, default=1)
495
- parser.add_argument("--shard-index", type=int, default=0)
496
- parser.add_argument("--dry-run", action="store_true")
497
- args = parser.parse_args()
498
-
499
- if args.limit is not None and args.limit <= 0:
500
- parser.error("--limit must be positive")
501
- if args.num_shards < 1 or not 0 <= args.shard_index < args.num_shards:
502
- parser.error("require num_shards >= 1 and 0 <= shard_index < num_shards")
503
-
504
- for path in (args.base_model / "model_index.json", args.transformer_path):
505
- if not path.is_file():
506
- raise FileNotFoundError(path)
507
- args.joint_spec = validate_checkpoint(args.joint_checkpoint, args.route)
508
- if (
509
- args.input_size != args.joint_spec.input_size
510
- or args.steps != 1
511
- or args.cfg != 1.0
512
- ):
513
- parser.error(
514
- f"{args.joint_spec.schema} evaluation is pinned to "
515
- f"--input-size {args.joint_spec.input_size} --steps 1 --cfg 1"
516
- )
517
- items = load_items(args.items_jsonl, args.limit)
518
- validate_payloads(items)
519
- shard_items = items[args.shard_index::args.num_shards]
520
- args.out_dir.mkdir(parents=True, exist_ok=True)
521
- pending = [
522
- item for item in shard_items
523
- if not all(output_path(args.out_dir, item, modality).stat().st_size > 0
524
- if output_path(args.out_dir, item, modality).is_file() else False
525
- for modality in MODALITIES)
526
- ]
527
- log(
528
- f"route={args.route} shard={args.shard_index}/{args.num_shards} "
529
- f"items={len(shard_items)} pending={len(pending)} seed={args.seed} "
530
- f"protocol={args.input_size}/s1/cfg1 schema={args.joint_spec.schema}"
531
- )
532
- if args.dry_run:
533
- for item in shard_items[:10]:
534
- log(f"DRY {item['key']} -> {args.out_dir}")
535
- log("dry run complete; no model or GPU was loaded")
536
- return
537
- if not pending:
538
- log("nothing to do")
539
- return
540
-
541
- os.environ.setdefault("HF_HUB_OFFLINE", "1")
542
- os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
543
- import torch
544
-
545
- if not torch.cuda.is_available():
546
- raise RuntimeError("CUDA is required")
547
- device = torch.device("cuda")
548
- torch.manual_seed(args.seed)
549
- log(f"loading model on {torch.cuda.get_device_name(device)}")
550
- pipe = load_pipeline(args, device)
551
- log("model ready")
552
-
553
- started = time.time()
554
- for index, item in enumerate(pending, 1):
555
- images = generate_triplet(pipe, item, args, device)
556
- save_triplet(args.out_dir, item, images)
557
- if index == 1 or index % 10 == 0 or index == len(pending):
558
- elapsed = time.time() - started
559
- rate = elapsed / index
560
- eta = rate * (len(pending) - index)
561
- log(
562
- f"{index}/{len(pending)} key={item['key']} "
563
- f"avg={rate:.2f}s/item eta={eta / 60:.1f}min"
564
- )
565
-
566
- manifest = {
567
- "schema": "joint_pbr_prediction_shard_v1",
568
- "route": args.route,
569
- "resolution": args.input_size,
570
- "steps": args.steps,
571
- "cfg": args.cfg,
572
- "seed": args.seed,
573
- "items_jsonl": str(args.items_jsonl.absolute()),
574
- "joint_checkpoint": str(args.joint_checkpoint.absolute()),
575
- "joint_checkpoint_schema": args.joint_spec.schema,
576
- "base_transformer": str(args.transformer_path.absolute()),
577
- "shard_index": args.shard_index,
578
- "num_shards": args.num_shards,
579
- "n_items": len(shard_items),
580
- "n_outputs": len(shard_items) * len(MODALITIES),
581
- "elapsed_seconds": time.time() - started,
582
- "peak_cuda_allocated_bytes": torch.cuda.max_memory_allocated(),
583
- "peak_cuda_reserved_bytes": torch.cuda.max_memory_reserved(),
584
- }
585
- manifest_path = args.out_dir / (
586
- f"prediction_shard_{args.shard_index:02d}_of_{args.num_shards:02d}.json"
587
- )
588
- temporary = manifest_path.with_name(manifest_path.name + f".tmp.{os.getpid()}")
589
- temporary.write_text(json.dumps(manifest, indent=2) + "\n")
590
- os.replace(temporary, manifest_path)
591
- log(f"DONE outputs={manifest['n_outputs']} manifest={manifest_path}")
592
-
593
-
594
- if __name__ == "__main__":
595
- main()
 
1
+ #!/usr/bin/env python
2
+ # Adapted for SUPRA-Tex distribution (2026): packaged local inference runtime.
3
+ # Upstream FireRed portions remain under Apache-2.0; see THIRD_PARTY_NOTICES.md.
4
+ """Joint albedo/MRA/normal inference for the factorized PBR checkpoint.
5
+
6
+ The stock QwenImageEditPlusPipeline rejects prompt batches larger than one, so
7
+ it cannot exercise modality-axis attention. This runner keeps the accepted
8
+ pipeline preprocessing/scheduler/decoder semantics, but folds one physical
9
+ item into the fixed ``albedo, mra, normal`` batch and advances all three streams
10
+ together. Joint checkpoints are trained only at sigma=1, therefore this
11
+ runner intentionally accepts one-step, CFG-free inference. The checkpoint
12
+ contract selects the recorded 512/1024 resolution for v2/v3.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import importlib
19
+ import json
20
+ import os
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+ from types import SimpleNamespace
25
+
26
+
27
+ REPO = Path(__file__).absolute().parent.parent
28
+ sys.path.insert(0, str(REPO))
29
+ MODALITIES = ("albedo", "mra", "normal")
30
+ JOINT_ROUTES = ("aligned", "self_slot", "blockdiag")
31
+
32
+
33
+ def log(message: str) -> None:
34
+ print(f"[predict_joint_pbr {time.strftime('%H:%M:%S')}] {message}", flush=True)
35
+
36
+
37
+ def output_path(out_dir: Path, item: dict, modality: str) -> Path:
38
+ return out_dir / f"{item['key']}_{modality}.png"
39
+
40
+
41
+ def load_items(path: Path, limit: int | None) -> list[dict]:
42
+ required = {
43
+ "key",
44
+ "dataset",
45
+ "sha",
46
+ "asset_id",
47
+ "view",
48
+ "cond",
49
+ "albedo_gt",
50
+ "mra_gt",
51
+ "normal_gt",
52
+ "normal_valid",
53
+ "instr_albedo",
54
+ "instr_mra",
55
+ "instr_normal",
56
+ "split",
57
+ }
58
+ items = []
59
+ seen = set()
60
+ with path.open() as stream:
61
+ for line_number, raw_line in enumerate(stream, 1):
62
+ if not raw_line.strip():
63
+ continue
64
+ item = json.loads(raw_line)
65
+ missing = required - set(item)
66
+ if missing:
67
+ raise ValueError(f"{path}:{line_number}: missing fields {sorted(missing)}")
68
+ key = item["key"]
69
+ if not isinstance(key, str) or not key or Path(key).name != key or key in {".", ".."} or "\\" in key:
70
+ raise ValueError(f"{path}:{line_number}: key must be a single safe filename component")
71
+ if item["key"] in seen:
72
+ raise ValueError(f"{path}:{line_number}: duplicate key {item['key']}")
73
+ if item["split"] != "eval":
74
+ raise ValueError(f"{path}:{line_number}: expected split=eval")
75
+ seen.add(item["key"])
76
+ items.append(item)
77
+ if limit is not None:
78
+ items = items[:limit]
79
+ if not items:
80
+ raise ValueError(f"no evaluation items in {path}")
81
+ return items
82
+
83
+
84
+ def validate_checkpoint(checkpoint: Path, route: str) -> SimpleNamespace:
85
+ manifest_path = checkpoint / "checkpoint_complete.json"
86
+ contract_path = checkpoint / "joint_contract.json"
87
+ run_contract_path = checkpoint.parent / "RUN_CONTRACT.json"
88
+ adapter_path = checkpoint / "joint_adapter.safetensors"
89
+ full_transformer_path = checkpoint / "diffusion_pytorch_model.safetensors"
90
+ for path in (manifest_path, contract_path):
91
+ if not path.is_file():
92
+ raise FileNotFoundError(path)
93
+ manifest = json.loads(manifest_path.read_text())
94
+ contract = json.loads(contract_path.read_text())
95
+ common_expected = {
96
+ "sites": [29, 39, 49, 59],
97
+ "modalities": list(MODALITIES),
98
+ "rank": 256,
99
+ "heads": 4,
100
+ "training_route": "aligned",
101
+ }
102
+ schema = contract.get("schema")
103
+ if schema == "pbr_joint_checkpoint_v1":
104
+ expected = {
105
+ **common_expected,
106
+ "schema": schema,
107
+ "trainable_tensor_count": 60,
108
+ "trainable_parameter_count": 7_385_088,
109
+ }
110
+ input_size = 512
111
+ elif schema in ("pbr_joint_checkpoint_v2", "pbr_joint_checkpoint_v3"):
112
+ if not run_contract_path.is_file():
113
+ raise FileNotFoundError(run_contract_path)
114
+ run_contract = json.loads(run_contract_path.read_text())
115
+ input_size = run_contract.get("resolution")
116
+ if input_size not in (512, 1024):
117
+ raise ValueError(
118
+ f"unsupported joint-v2/v3 run resolution: {input_size!r}"
119
+ )
120
+ expected = {
121
+ **common_expected,
122
+ "schema": schema,
123
+ "joint_tensor_count": 60,
124
+ "joint_parameter_count": 7_385_088,
125
+ "trainable_modules": ["modality_joint_blocks."],
126
+ }
127
+ backbone_low_lr = [
128
+ f"transformer_blocks.{index}." for index in range(29, 50)
129
+ ]
130
+ if schema == "pbr_joint_checkpoint_v2":
131
+ expected.update(
132
+ low_lr_modules=backbone_low_lr,
133
+ low_lr_ratio=0.0025,
134
+ )
135
+ if contract.get("fresh_joint_w_up_init_std") not in (0.0, 1e-4):
136
+ expected["fresh_joint_w_up_init_std"] = "0.0 or 1e-4"
137
+ residual_mode = "raw"
138
+ else:
139
+ low_lr_modules = contract.get("low_lr_modules")
140
+ if low_lr_modules == []:
141
+ expected.update(low_lr_modules=[], low_lr_ratio=None)
142
+ elif low_lr_modules == backbone_low_lr:
143
+ expected.update(
144
+ low_lr_modules=backbone_low_lr,
145
+ low_lr_ratio=0.0025,
146
+ )
147
+ else:
148
+ expected["low_lr_modules"] = "[] or transformer blocks 29 through 49"
149
+ residual_mode = contract.get("residual_mode")
150
+ if residual_mode != "rms_scaled":
151
+ expected["residual_mode"] = "rms_scaled"
152
+ if contract.get("fresh_joint_w_up_init_std") not in (0.0, 3e-3):
153
+ expected["fresh_joint_w_up_init_std"] = "0.0 or 0.003"
154
+ if contract.get("source_joint_adapter_action") not in (
155
+ "fresh", "loaded", "reset"
156
+ ):
157
+ expected["source_joint_adapter_action"] = "fresh, loaded, or reset"
158
+ else:
159
+ raise ValueError(f"unsupported joint checkpoint schema: {schema!r}")
160
+ bad = {
161
+ key: (contract.get(key), value)
162
+ for key, value in expected.items()
163
+ if contract.get(key) != value
164
+ }
165
+ if bad:
166
+ raise ValueError(f"joint checkpoint contract mismatch: {bad}")
167
+ if manifest.get("joint_contract") != contract:
168
+ raise ValueError("checkpoint manifest is not bound to joint_contract.json")
169
+ if schema == "pbr_joint_checkpoint_v1":
170
+ if (manifest.get("global_step"), manifest.get("world_size"), manifest.get("seed")) \
171
+ != (2000, 40, 42):
172
+ raise ValueError(f"joint checkpoint step/world/seed mismatch: {manifest}")
173
+ elif (
174
+ manifest.get("format_version") != 2
175
+ or not isinstance(manifest.get("global_step"), int)
176
+ or manifest["global_step"] <= 0
177
+ ):
178
+ raise ValueError(f"joint-v2/v3 checkpoint manifest mismatch: {manifest}")
179
+ if route in JOINT_ROUTES and schema == "pbr_joint_checkpoint_v1":
180
+ if not adapter_path.is_file():
181
+ raise FileNotFoundError(adapter_path)
182
+ if route in JOINT_ROUTES and schema in (
183
+ "pbr_joint_checkpoint_v2", "pbr_joint_checkpoint_v3"
184
+ ):
185
+ if not full_transformer_path.is_file():
186
+ raise FileNotFoundError(full_transformer_path)
187
+ return SimpleNamespace(
188
+ schema=schema,
189
+ input_size=input_size,
190
+ adapter_path=adapter_path if schema == "pbr_joint_checkpoint_v1" else None,
191
+ full_transformer_path=(
192
+ full_transformer_path
193
+ if schema in ("pbr_joint_checkpoint_v2", "pbr_joint_checkpoint_v3")
194
+ else None
195
+ ),
196
+ residual_mode="raw" if schema != "pbr_joint_checkpoint_v3" else residual_mode,
197
+ )
198
+
199
+
200
+ def validate_payloads(items: list[dict]) -> None:
201
+ fields = ("cond", "albedo_gt", "mra_gt", "normal_gt", "normal_valid")
202
+ for item in items:
203
+ missing = [item[field] for field in fields if not Path(item[field]).is_file()]
204
+ if missing:
205
+ raise FileNotFoundError(
206
+ f"{item['key']}: missing payload(s): {missing[:3]}"
207
+ )
208
+
209
+
210
+ def load_source_rgb(path: str, size: int):
211
+ from PIL import Image
212
+
213
+ image = Image.open(path)
214
+ if image.mode != "RGB":
215
+ rgba = image.convert("RGBA")
216
+ background = Image.new("RGBA", rgba.size, (0, 0, 0, 255))
217
+ image = Image.alpha_composite(background, rgba).convert("RGB")
218
+ if image.size != (size, size):
219
+ image = image.resize((size, size), Image.Resampling.BICUBIC)
220
+ return image
221
+
222
+
223
+ def _flash_attention_available(module_name: str) -> bool:
224
+ try:
225
+ module = importlib.import_module(module_name)
226
+ except (ImportError, OSError, RuntimeError):
227
+ return False
228
+ return all(
229
+ callable(getattr(module, name, None))
230
+ for name in ("flash_attn_func", "flash_attn_varlen_func")
231
+ )
232
+
233
+
234
+ def select_attention_backends(torch, device) -> tuple[str, str]:
235
+ """Resolve inference attention without depending on the parent CLI package.
236
+
237
+ The image transformer supports FA3 and native PyTorch SDPA. FA2 can still
238
+ accelerate the text encoder when the image transformer uses native SDPA.
239
+ An explicit SDPA selection applies to both components.
240
+ """
241
+ selected = os.environ.get("ATTN_BACKEND", "auto")
242
+ if selected == "sdpa":
243
+ return "native", "sdpa"
244
+ supported = ("auto", "flash_attn_3", "flash_attn", "xformers", "naive", "flash_attn_4")
245
+ if selected not in supported:
246
+ raise ValueError(f"unsupported ATTN_BACKEND for FireRed: {selected!r}")
247
+
248
+ device = torch.device(device)
249
+ capability = (
250
+ torch.cuda.get_device_capability(device)
251
+ if device.type == "cuda" and torch.cuda.is_available()
252
+ else (0, 0)
253
+ )
254
+ use_fa3 = (
255
+ selected in ("auto", "flash_attn_3")
256
+ and capability[0] == 9
257
+ and _flash_attention_available("flash_attn_interface")
258
+ )
259
+ if selected == "flash_attn_3" and not use_fa3:
260
+ raise RuntimeError(
261
+ "ATTN_BACKEND=flash_attn_3 requires Hopper (SM90) and an importable "
262
+ "flash_attn_interface extension; select sdpa for native attention"
263
+ )
264
+ use_fa2 = capability[0] in (8, 9) and _flash_attention_available("flash_attn")
265
+ if selected == "flash_attn" and not use_fa2:
266
+ raise RuntimeError(
267
+ "ATTN_BACKEND=flash_attn requires an Ampere/Ada/Hopper GPU and an "
268
+ "importable flash_attn extension; select sdpa for native attention"
269
+ )
270
+ return "fa3" if use_fa3 else "native", "flash_attention_2" if use_fa2 else "sdpa"
271
+
272
+
273
+ def load_pipeline(args, device):
274
+ import torch
275
+ from diffusers import FlowMatchEulerDiscreteScheduler, QwenImageEditPlusPipeline
276
+
277
+ image_backend, text_backend = select_attention_backends(torch, device)
278
+ from pbr.model_loading import (
279
+ _load_base_transformer,
280
+ _load_exact_full_transformer,
281
+ _load_exact_joint_adapter,
282
+ )
283
+ from pbr.qwen_pbr_factorized import (
284
+ PBR_ATTENTION_FA3,
285
+ PBR_ATTENTION_NATIVE,
286
+ PRODUCTION_JOINT_HEADS,
287
+ PRODUCTION_JOINT_MODALITIES,
288
+ PRODUCTION_JOINT_RANK,
289
+ PRODUCTION_JOINT_SITES,
290
+ )
291
+
292
+ model_args = SimpleNamespace(pretrained_model_name_or_path=str(args.base_model))
293
+ transformer = _load_base_transformer(model_args, torch.bfloat16)
294
+ if args.route == "base" or args.joint_spec.schema == "pbr_joint_checkpoint_v1":
295
+ _load_exact_full_transformer(
296
+ transformer,
297
+ args.transformer_path,
298
+ allow_joint_sidecar=args.route == "base",
299
+ )
300
+ if args.route in JOINT_ROUTES:
301
+ transformer.attach_joint_modules(
302
+ sites=PRODUCTION_JOINT_SITES,
303
+ modalities=PRODUCTION_JOINT_MODALITIES,
304
+ rank=PRODUCTION_JOINT_RANK,
305
+ heads=PRODUCTION_JOINT_HEADS,
306
+ residual_mode=args.joint_spec.residual_mode,
307
+ )
308
+ if args.joint_spec.schema == "pbr_joint_checkpoint_v1":
309
+ _load_exact_joint_adapter(transformer, args.joint_spec.adapter_path)
310
+ else:
311
+ _load_exact_full_transformer(
312
+ transformer, args.joint_spec.full_transformer_path
313
+ )
314
+ transformer.set_pbr_attention_backend(
315
+ PBR_ATTENTION_FA3 if image_backend == "fa3" else PBR_ATTENTION_NATIVE,
316
+ device=device,
317
+ dtype=torch.bfloat16,
318
+ )
319
+ transformer.to(dtype=torch.bfloat16)
320
+ transformer.requires_grad_(False)
321
+ transformer.eval()
322
+
323
+ pipe = QwenImageEditPlusPipeline.from_pretrained(
324
+ str(args.base_model),
325
+ transformer=transformer,
326
+ torch_dtype=torch.bfloat16,
327
+ local_files_only=True,
328
+ )
329
+ scheduler_config = dict(pipe.scheduler.config)
330
+ scheduler_config["shift_terminal"] = None
331
+ pipe.scheduler = FlowMatchEulerDiscreteScheduler.from_config(scheduler_config)
332
+ pipe.to(device)
333
+ pipe.text_encoder.set_attn_implementation(text_backend)
334
+ pipe.set_progress_bar_config(disable=True)
335
+ log(f"attention image={image_backend} text={text_backend}")
336
+ return pipe
337
+
338
+
339
+ def make_generators(torch, device, seed: int):
340
+ # Keep per-modality noise independent, deterministic, and invariant to GPU
341
+ # sharding. All routes use these exact three streams.
342
+ offsets = (104_729, 209_759, 314_159)
343
+ return [
344
+ torch.Generator(device=device).manual_seed(seed + offset)
345
+ for offset in offsets
346
+ ]
347
+
348
+
349
+ def encode_source(pipe, source, device, dtype, seed: int):
350
+ import numpy as np
351
+ import torch
352
+
353
+ pixels = torch.from_numpy(
354
+ np.asarray(source, dtype=np.float32).copy()
355
+ ).permute(2, 0, 1).unsqueeze(0)
356
+ pixels = (pixels / 127.5 - 1.0).to(device=device, dtype=dtype).unsqueeze(2)
357
+ generator = torch.Generator(device=device).manual_seed(seed)
358
+ source_latents = pipe._encode_vae_image(pixels, generator=generator)
359
+ _, channels, _, height, width = source_latents.shape
360
+ return pipe._pack_latents(source_latents, 1, channels, height, width)
361
+
362
+
363
+ def encode_prompts(pipe, item: dict, source, input_size: int, device, dtype):
364
+ from pbr.conditioning import get_embedding
365
+ from pbr.conditioning import resize_source_images_for_condition
366
+
367
+ prompts = [item[f"instr_{modality}"] for modality in MODALITIES]
368
+ source_lists = resize_source_images_for_condition(
369
+ [[source] for _ in MODALITIES],
370
+ ref_height=input_size,
371
+ ref_width=input_size,
372
+ image_sample_size=input_size,
373
+ )
374
+ embeds, mask = get_embedding(
375
+ True,
376
+ prompts,
377
+ source_lists,
378
+ pipe.processor,
379
+ pipe.text_encoder,
380
+ )
381
+ return embeds.to(device=device, dtype=dtype), mask.to(device=device)
382
+
383
+
384
+ def generate_triplet(pipe, item: dict, args, device):
385
+ import numpy as np
386
+ import torch
387
+
388
+ from pbr.conditioning import calculate_shift
389
+
390
+ source = load_source_rgb(item["cond"], args.input_size)
391
+ dtype = pipe.transformer.dtype
392
+ prompt_embeds, prompt_mask = encode_prompts(
393
+ pipe, item, source, args.input_size, device, dtype
394
+ )
395
+ source_latents = encode_source(pipe, source, device, dtype, args.seed)
396
+ source_latents = source_latents.expand(len(MODALITIES), -1, -1)
397
+
398
+ channels = pipe.transformer.config.in_channels // 4
399
+ generators = make_generators(torch, device, args.seed)
400
+ latents, _ = pipe.prepare_latents(
401
+ images=None,
402
+ batch_size=len(MODALITIES),
403
+ num_channels_latents=channels,
404
+ height=args.input_size,
405
+ width=args.input_size,
406
+ dtype=dtype,
407
+ device=device,
408
+ generator=generators,
409
+ latents=None,
410
+ )
411
+ target_seq_len = latents.shape[1]
412
+ grid = args.input_size // pipe.vae_scale_factor // 2
413
+ img_shapes = [[(1, grid, grid), (1, grid, grid)] for _ in MODALITIES]
414
+
415
+ sigmas = np.linspace(1.0, 1.0, 1)
416
+ mu = calculate_shift(
417
+ target_seq_len,
418
+ pipe.scheduler.config.get("base_image_seq_len", 256),
419
+ pipe.scheduler.config.get("max_image_seq_len", 4096),
420
+ pipe.scheduler.config.get("base_shift", 0.5),
421
+ pipe.scheduler.config.get("max_shift", 1.15),
422
+ )
423
+ pipe.scheduler.set_timesteps(sigmas=sigmas, device=device, mu=mu)
424
+ pipe.scheduler.set_begin_index(0)
425
+
426
+ with torch.inference_mode():
427
+ for timestep in pipe.scheduler.timesteps:
428
+ hidden_states = torch.cat([latents, source_latents], dim=1)
429
+ model_timestep = timestep.expand(len(MODALITIES)).to(dtype) / 1000
430
+ transformer_kwargs = {
431
+ "hidden_states": hidden_states,
432
+ "timestep": model_timestep,
433
+ "guidance": None,
434
+ "encoder_hidden_states_mask": prompt_mask,
435
+ "encoder_hidden_states": prompt_embeds,
436
+ "img_shapes": img_shapes,
437
+ "attention_kwargs": {},
438
+ "return_dict": False,
439
+ }
440
+ if args.route in JOINT_ROUTES:
441
+ transformer_kwargs.update(
442
+ pbr_joint_batch_size=1,
443
+ pbr_joint_target_seq_len=target_seq_len,
444
+ pbr_joint_route=args.route,
445
+ )
446
+ with pipe.transformer.cache_context("cond"):
447
+ prediction = pipe.transformer(**transformer_kwargs)[0]
448
+ prediction = prediction[:, :target_seq_len]
449
+ latents = pipe.scheduler.step(
450
+ prediction, timestep, latents, return_dict=False
451
+ )[0]
452
+
453
+ unpacked = pipe._unpack_latents(
454
+ latents, args.input_size, args.input_size, pipe.vae_scale_factor
455
+ ).to(pipe.vae.dtype)
456
+ mean = torch.tensor(pipe.vae.config.latents_mean).view(
457
+ 1, pipe.vae.config.z_dim, 1, 1, 1
458
+ ).to(unpacked.device, unpacked.dtype)
459
+ std = 1.0 / torch.tensor(pipe.vae.config.latents_std).view(
460
+ 1, pipe.vae.config.z_dim, 1, 1, 1
461
+ ).to(unpacked.device, unpacked.dtype)
462
+ unpacked = unpacked / std + mean
463
+ decoded = pipe.vae.decode(unpacked, return_dict=False)[0][:, :, 0]
464
+ images = pipe.image_processor.postprocess(decoded, output_type="pil")
465
+ if len(images) != len(MODALITIES):
466
+ raise RuntimeError(f"decoder returned {len(images)} images, expected 3")
467
+ return images
468
+
469
+
470
+ def save_triplet(out_dir: Path, item: dict, images) -> None:
471
+ for modality, image in zip(MODALITIES, images):
472
+ destination = output_path(out_dir, item, modality)
473
+ temporary = destination.with_name(destination.name + f".tmp.{os.getpid()}.png")
474
+ image.save(temporary, format="PNG")
475
+ os.replace(temporary, destination)
476
+
477
+
478
+ def main() -> None:
479
+ parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
480
+ parser.add_argument("--items-jsonl", type=Path, required=True)
481
+ parser.add_argument("--out-dir", type=Path, required=True)
482
+ parser.add_argument("--base-model", type=Path, required=True)
483
+ parser.add_argument("--transformer-path", type=Path, required=True)
484
+ parser.add_argument("--joint-checkpoint", type=Path, required=True)
485
+ parser.add_argument("--route", choices=("base",) + JOINT_ROUTES, required=True)
486
+ parser.add_argument("--input-size", type=int, default=512)
487
+ parser.add_argument("--steps", type=int, default=1)
488
+ parser.add_argument("--cfg", type=float, default=1.0)
489
+ parser.add_argument("--seed", type=int, default=42)
490
+ parser.add_argument("--limit", type=int, default=None)
491
+ parser.add_argument("--num-shards", type=int, default=1)
492
+ parser.add_argument("--shard-index", type=int, default=0)
493
+ parser.add_argument("--dry-run", action="store_true")
494
+ args = parser.parse_args()
495
+
496
+ if args.limit is not None and args.limit <= 0:
497
+ parser.error("--limit must be positive")
498
+ if args.num_shards < 1 or not 0 <= args.shard_index < args.num_shards:
499
+ parser.error("require num_shards >= 1 and 0 <= shard_index < num_shards")
500
+
501
+ for path in (args.base_model / "model_index.json", args.transformer_path):
502
+ if not path.is_file():
503
+ raise FileNotFoundError(path)
504
+ args.joint_spec = validate_checkpoint(args.joint_checkpoint, args.route)
505
+ if (
506
+ args.input_size != args.joint_spec.input_size
507
+ or args.steps != 1
508
+ or args.cfg != 1.0
509
+ ):
510
+ parser.error(
511
+ f"{args.joint_spec.schema} evaluation is pinned to "
512
+ f"--input-size {args.joint_spec.input_size} --steps 1 --cfg 1"
513
+ )
514
+ items = load_items(args.items_jsonl, args.limit)
515
+ validate_payloads(items)
516
+ shard_items = items[args.shard_index::args.num_shards]
517
+ args.out_dir.mkdir(parents=True, exist_ok=True)
518
+ pending = [
519
+ item for item in shard_items
520
+ if not all(output_path(args.out_dir, item, modality).stat().st_size > 0
521
+ if output_path(args.out_dir, item, modality).is_file() else False
522
+ for modality in MODALITIES)
523
+ ]
524
+ log(
525
+ f"route={args.route} shard={args.shard_index}/{args.num_shards} "
526
+ f"items={len(shard_items)} pending={len(pending)} seed={args.seed} "
527
+ f"protocol={args.input_size}/s1/cfg1 schema={args.joint_spec.schema}"
528
+ )
529
+ if args.dry_run:
530
+ for item in shard_items[:10]:
531
+ log(f"DRY {item['key']} -> {args.out_dir}")
532
+ log("dry run complete; no model or GPU was loaded")
533
+ return
534
+ if not pending:
535
+ log("nothing to do")
536
+ return
537
+
538
+ os.environ.setdefault("HF_HUB_OFFLINE", "1")
539
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
540
+ import torch
541
+
542
+ if not torch.cuda.is_available():
543
+ raise RuntimeError("CUDA is required")
544
+ device = torch.device("cuda")
545
+ torch.manual_seed(args.seed)
546
+ log(f"loading model on {torch.cuda.get_device_name(device)}")
547
+ pipe = load_pipeline(args, device)
548
+ log("model ready")
549
+
550
+ started = time.time()
551
+ for index, item in enumerate(pending, 1):
552
+ images = generate_triplet(pipe, item, args, device)
553
+ save_triplet(args.out_dir, item, images)
554
+ if index == 1 or index % 10 == 0 or index == len(pending):
555
+ elapsed = time.time() - started
556
+ rate = elapsed / index
557
+ eta = rate * (len(pending) - index)
558
+ log(
559
+ f"{index}/{len(pending)} key={item['key']} "
560
+ f"avg={rate:.2f}s/item eta={eta / 60:.1f}min"
561
+ )
562
+
563
+ manifest = {
564
+ "schema": "joint_pbr_prediction_shard_v1",
565
+ "route": args.route,
566
+ "resolution": args.input_size,
567
+ "steps": args.steps,
568
+ "cfg": args.cfg,
569
+ "seed": args.seed,
570
+ "items_jsonl": str(args.items_jsonl.absolute()),
571
+ "joint_checkpoint": str(args.joint_checkpoint.absolute()),
572
+ "joint_checkpoint_schema": args.joint_spec.schema,
573
+ "base_transformer": str(args.transformer_path.absolute()),
574
+ "shard_index": args.shard_index,
575
+ "num_shards": args.num_shards,
576
+ "n_items": len(shard_items),
577
+ "n_outputs": len(shard_items) * len(MODALITIES),
578
+ "elapsed_seconds": time.time() - started,
579
+ "peak_cuda_allocated_bytes": torch.cuda.max_memory_allocated(),
580
+ "peak_cuda_reserved_bytes": torch.cuda.max_memory_reserved(),
581
+ }
582
+ manifest_path = args.out_dir / (
583
+ f"prediction_shard_{args.shard_index:02d}_of_{args.num_shards:02d}.json"
584
+ )
585
+ temporary = manifest_path.with_name(manifest_path.name + f".tmp.{os.getpid()}")
586
+ temporary.write_text(json.dumps(manifest, indent=2) + "\n")
587
+ os.replace(temporary, manifest_path)
588
+ log(f"DONE outputs={manifest['n_outputs']} manifest={manifest_path}")
589
+
590
+
591
+ if __name__ == "__main__":
592
+ main()
 
 
 
supratex/frontend/material_model/runtime/pbr/qwen_pbr_factorized.py ADDED
@@ -0,0 +1,853 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Adapted for SUPRA-Tex distribution (2026): packaged local inference runtime.
2
+ # Upstream FireRed portions remain under Apache-2.0; see THIRD_PARTY_NOTICES.md.
3
+ """Factorized modality-axis attention for joint RGB-to-PBR prediction.
4
+
5
+ The stock Qwen image transformer continues to process each modality as an
6
+ independent folded-batch item. Small registered residual modules then mix the
7
+ three modalities at equal latent-grid positions after selected complete Qwen
8
+ blocks. The final up projection is zero initialized, so attaching the modules
9
+ is an exact no-op before training.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from functools import lru_cache
15
+ from math import prod
16
+ from typing import Any, Sequence
17
+
18
+ import numpy as np
19
+ import torch
20
+ from torch import nn
21
+
22
+ from diffusers.configuration_utils import register_to_config
23
+ from diffusers.models.modeling_outputs import Transformer2DModelOutput
24
+ from diffusers.models.transformers.transformer_qwenimage import (
25
+ QwenDoubleStreamAttnProcessor2_0,
26
+ QwenImageTransformer2DModel,
27
+ apply_rotary_emb_qwen,
28
+ compute_text_seq_len_from_mask,
29
+ )
30
+ from diffusers.utils import apply_lora_scale, deprecate
31
+
32
+
33
+ PRODUCTION_JOINT_MODALITIES = ("albedo", "mra", "normal")
34
+ PRODUCTION_JOINT_SITES = (29, 39, 49, 59)
35
+ PRODUCTION_JOINT_RANK = 256
36
+ PRODUCTION_JOINT_HEADS = 4
37
+
38
+ JOINT_RESIDUAL_RAW = "raw"
39
+ JOINT_RESIDUAL_RMS_SCALED = "rms_scaled"
40
+ JOINT_RESIDUAL_MODES = (JOINT_RESIDUAL_RAW, JOINT_RESIDUAL_RMS_SCALED)
41
+
42
+ PBR_ATTENTION_NATIVE = "native"
43
+ PBR_ATTENTION_FA3 = "fa3"
44
+ PBR_ATTENTION_BACKENDS = (PBR_ATTENTION_NATIVE, PBR_ATTENTION_FA3)
45
+
46
+ ROUTE_ALIGNED = 0
47
+ ROUTE_SELF_SLOT = 1
48
+ ROUTE_BLOCKDIAG = 2
49
+ _ROUTE_NAME_TO_CODE = {
50
+ "aligned": ROUTE_ALIGNED,
51
+ "self_slot": ROUTE_SELF_SLOT,
52
+ "blockdiag": ROUTE_BLOCKDIAG,
53
+ }
54
+
55
+
56
+ @lru_cache(maxsize=1)
57
+ def _load_flash_attention_3_ops():
58
+ try:
59
+ from flash_attn_interface import flash_attn_func, flash_attn_varlen_func
60
+ except (ImportError, OSError, RuntimeError) as error:
61
+ raise RuntimeError(
62
+ "FireRed image attention requires the t2h FlashAttention-3 "
63
+ "flash_attn_interface extension"
64
+ ) from error
65
+ return flash_attn_func, flash_attn_varlen_func
66
+
67
+
68
+ @lru_cache(maxsize=16)
69
+ def validate_fa3_runtime(device: torch.device | str, dtype: torch.dtype) -> None:
70
+ """Fail closed unless the selected worker can execute Hopper BF16/FP16 FA3."""
71
+
72
+ device = torch.device(device)
73
+ if device.type != "cuda" or not torch.cuda.is_available():
74
+ raise RuntimeError("FireRed FA3 image attention requires a CUDA device")
75
+ if dtype not in (torch.bfloat16, torch.float16):
76
+ raise RuntimeError(
77
+ "FireRed FA3 image attention requires bfloat16 or float16, "
78
+ f"got {dtype}"
79
+ )
80
+ capability = torch.cuda.get_device_capability(device)
81
+ if capability < (9, 0):
82
+ raise RuntimeError(
83
+ "FireRed FA3 image attention requires Hopper compute capability >= 9.0, "
84
+ f"got {capability[0]}.{capability[1]}"
85
+ )
86
+ _load_flash_attention_3_ops()
87
+
88
+
89
+ def prepare_fa3_key_mask_plan(
90
+ attention_mask: torch.Tensor,
91
+ query_seq_len: int,
92
+ ) -> dict[str, torch.Tensor | int]:
93
+ """Pack arbitrary valid K/V positions once for all Qwen transformer blocks.
94
+
95
+ Qwen joint attention lays tokens out as ``[text, image]``. Because text is
96
+ padded before the image suffix, its key mask is not necessarily a valid
97
+ prefix. Diffusers' generic FA3-varlen backend slices ``K/V[:valid_len]``
98
+ and would therefore drop real image tokens. This plan gathers the true
99
+ boolean-mask positions while leaving every query in its original RoPE
100
+ position.
101
+ """
102
+
103
+ if attention_mask.dtype != torch.bool or attention_mask.ndim != 2:
104
+ raise ValueError(
105
+ "FA3 Qwen attention_mask must be a 2D bool tensor, "
106
+ f"got shape={tuple(attention_mask.shape)} dtype={attention_mask.dtype}"
107
+ )
108
+ batch_size, key_seq_len = attention_mask.shape
109
+ query_seq_len = int(query_seq_len)
110
+ if batch_size <= 0 or key_seq_len <= 0 or query_seq_len <= 0:
111
+ raise ValueError("FA3 Qwen attention dimensions must be positive")
112
+
113
+ key_lengths = attention_mask.sum(dim=1, dtype=torch.int32)
114
+ if not bool((key_lengths > 0).all().item()):
115
+ raise ValueError("every FA3 Qwen sample must retain at least one key token")
116
+ key_indices = attention_mask.reshape(-1).nonzero(as_tuple=False).flatten()
117
+ cu_seqlens_k = torch.zeros(
118
+ batch_size + 1,
119
+ dtype=torch.int32,
120
+ device=attention_mask.device,
121
+ )
122
+ cu_seqlens_k[1:] = key_lengths.cumsum(dim=0)
123
+ cu_seqlens_q = torch.arange(
124
+ 0,
125
+ (batch_size + 1) * query_seq_len,
126
+ query_seq_len,
127
+ dtype=torch.int32,
128
+ device=attention_mask.device,
129
+ )
130
+ return {
131
+ "key_indices": key_indices,
132
+ "cu_seqlens_q": cu_seqlens_q,
133
+ "cu_seqlens_k": cu_seqlens_k,
134
+ "max_seqlen_q": query_seq_len,
135
+ "max_seqlen_k": int(key_lengths.max().item()),
136
+ }
137
+
138
+
139
+ def flash_attention_3_with_key_mask(
140
+ query: torch.Tensor,
141
+ key: torch.Tensor,
142
+ value: torch.Tensor,
143
+ *,
144
+ attention_mask: torch.Tensor | None,
145
+ mask_plan: dict[str, torch.Tensor | int] | None = None,
146
+ ) -> torch.Tensor:
147
+ """Run exact non-causal FA3 attention with Qwen's arbitrary key mask."""
148
+
149
+ if query.ndim != 4 or key.ndim != 4 or value.ndim != 4:
150
+ raise ValueError("FA3 Qwen Q/K/V must have shape [B, S, H, D]")
151
+ if query.shape != key.shape or key.shape != value.shape:
152
+ raise ValueError(
153
+ "FA3 Qwen requires equal Q/K/V shapes, got "
154
+ f"{tuple(query.shape)}, {tuple(key.shape)}, {tuple(value.shape)}"
155
+ )
156
+ if query.device != key.device or key.device != value.device:
157
+ raise ValueError("FA3 Qwen Q/K/V must be on the same device")
158
+ if query.dtype != key.dtype or key.dtype != value.dtype:
159
+ raise ValueError("FA3 Qwen Q/K/V must have the same dtype")
160
+ validate_fa3_runtime(query.device, query.dtype)
161
+ flash_attn_func, flash_attn_varlen_func = _load_flash_attention_3_ops()
162
+
163
+ query = query.contiguous()
164
+ key = key.contiguous()
165
+ value = value.contiguous()
166
+ if attention_mask is None:
167
+ return flash_attn_func(
168
+ query,
169
+ key,
170
+ value,
171
+ softmax_scale=None,
172
+ causal=False,
173
+ )
174
+
175
+ if attention_mask.shape != query.shape[:2]:
176
+ raise ValueError(
177
+ "FA3 Qwen key mask must match [B, S], got "
178
+ f"{tuple(attention_mask.shape)} for {tuple(query.shape[:2])}"
179
+ )
180
+ if attention_mask.device != query.device:
181
+ raise ValueError("FA3 Qwen key mask must be on the Q/K/V device")
182
+ if mask_plan is None:
183
+ mask_plan = prepare_fa3_key_mask_plan(attention_mask, query.shape[1])
184
+
185
+ batch_size, seq_len, heads, head_dim = query.shape
186
+ key_indices = mask_plan["key_indices"]
187
+ if not isinstance(key_indices, torch.Tensor) or key_indices.device != query.device:
188
+ raise ValueError("invalid FA3 key_indices mask plan")
189
+ flat_key = key.reshape(batch_size * seq_len, heads, head_dim)
190
+ flat_value = value.reshape(batch_size * seq_len, heads, head_dim)
191
+ packed_key = flat_key.index_select(0, key_indices)
192
+ packed_value = flat_value.index_select(0, key_indices)
193
+ packed_query = query.reshape(batch_size * seq_len, heads, head_dim)
194
+ output = flash_attn_varlen_func(
195
+ packed_query,
196
+ packed_key,
197
+ packed_value,
198
+ mask_plan["cu_seqlens_q"],
199
+ mask_plan["cu_seqlens_k"],
200
+ int(mask_plan["max_seqlen_q"]),
201
+ int(mask_plan["max_seqlen_k"]),
202
+ softmax_scale=None,
203
+ causal=False,
204
+ )
205
+ return output.reshape(batch_size, seq_len, heads, head_dim)
206
+
207
+
208
+ class QwenDoubleStreamAttnProcessorFA3:
209
+ """Qwen double-stream processor using mask-correct Hopper FA3 kernels."""
210
+
211
+ _attention_backend = "_flash_3"
212
+ _parallel_config = None
213
+
214
+ def __init__(self) -> None:
215
+ _load_flash_attention_3_ops()
216
+
217
+ def __call__(
218
+ self,
219
+ attn,
220
+ hidden_states: torch.Tensor,
221
+ encoder_hidden_states: torch.Tensor | None = None,
222
+ encoder_hidden_states_mask: torch.Tensor | None = None,
223
+ attention_mask: torch.Tensor | None = None,
224
+ image_rotary_emb: torch.Tensor | None = None,
225
+ fa3_key_indices: torch.Tensor | None = None,
226
+ fa3_cu_seqlens_q: torch.Tensor | None = None,
227
+ fa3_cu_seqlens_k: torch.Tensor | None = None,
228
+ fa3_max_seqlen_q: int | None = None,
229
+ fa3_max_seqlen_k: int | None = None,
230
+ ) -> tuple[torch.Tensor, torch.Tensor]:
231
+ if encoder_hidden_states is None:
232
+ raise ValueError(
233
+ "QwenDoubleStreamAttnProcessorFA3 requires encoder_hidden_states"
234
+ )
235
+
236
+ seq_txt = encoder_hidden_states.shape[1]
237
+ img_query = attn.to_q(hidden_states)
238
+ img_key = attn.to_k(hidden_states)
239
+ img_value = attn.to_v(hidden_states)
240
+ txt_query = attn.add_q_proj(encoder_hidden_states)
241
+ txt_key = attn.add_k_proj(encoder_hidden_states)
242
+ txt_value = attn.add_v_proj(encoder_hidden_states)
243
+
244
+ img_query = img_query.unflatten(-1, (attn.heads, -1))
245
+ img_key = img_key.unflatten(-1, (attn.heads, -1))
246
+ img_value = img_value.unflatten(-1, (attn.heads, -1))
247
+ txt_query = txt_query.unflatten(-1, (attn.heads, -1))
248
+ txt_key = txt_key.unflatten(-1, (attn.heads, -1))
249
+ txt_value = txt_value.unflatten(-1, (attn.heads, -1))
250
+
251
+ if attn.norm_q is not None:
252
+ img_query = attn.norm_q(img_query)
253
+ if attn.norm_k is not None:
254
+ img_key = attn.norm_k(img_key)
255
+ if attn.norm_added_q is not None:
256
+ txt_query = attn.norm_added_q(txt_query)
257
+ if attn.norm_added_k is not None:
258
+ txt_key = attn.norm_added_k(txt_key)
259
+
260
+ if image_rotary_emb is not None:
261
+ img_freqs, txt_freqs = image_rotary_emb
262
+ img_query = apply_rotary_emb_qwen(img_query, img_freqs, use_real=False)
263
+ img_key = apply_rotary_emb_qwen(img_key, img_freqs, use_real=False)
264
+ txt_query = apply_rotary_emb_qwen(txt_query, txt_freqs, use_real=False)
265
+ txt_key = apply_rotary_emb_qwen(txt_key, txt_freqs, use_real=False)
266
+
267
+ joint_query = torch.cat([txt_query, img_query], dim=1)
268
+ joint_key = torch.cat([txt_key, img_key], dim=1)
269
+ joint_value = torch.cat([txt_value, img_value], dim=1)
270
+ mask_plan = None
271
+ if attention_mask is not None:
272
+ required_plan = (
273
+ fa3_key_indices,
274
+ fa3_cu_seqlens_q,
275
+ fa3_cu_seqlens_k,
276
+ fa3_max_seqlen_q,
277
+ fa3_max_seqlen_k,
278
+ )
279
+ if any(item is None for item in required_plan):
280
+ raise ValueError("FA3 Qwen attention mask plan is incomplete")
281
+ mask_plan = {
282
+ "key_indices": fa3_key_indices,
283
+ "cu_seqlens_q": fa3_cu_seqlens_q,
284
+ "cu_seqlens_k": fa3_cu_seqlens_k,
285
+ "max_seqlen_q": fa3_max_seqlen_q,
286
+ "max_seqlen_k": fa3_max_seqlen_k,
287
+ }
288
+ joint_hidden_states = flash_attention_3_with_key_mask(
289
+ joint_query,
290
+ joint_key,
291
+ joint_value,
292
+ attention_mask=attention_mask,
293
+ mask_plan=mask_plan,
294
+ )
295
+ joint_hidden_states = joint_hidden_states.flatten(2, 3).to(joint_query.dtype)
296
+
297
+ txt_attn_output = joint_hidden_states[:, :seq_txt, :]
298
+ img_attn_output = joint_hidden_states[:, seq_txt:, :]
299
+ img_attn_output = attn.to_out[0](img_attn_output.contiguous())
300
+ if len(attn.to_out) > 1:
301
+ img_attn_output = attn.to_out[1](img_attn_output)
302
+ txt_attn_output = attn.to_add_out(txt_attn_output.contiguous())
303
+ return img_attn_output, txt_attn_output
304
+
305
+
306
+ def _normalize_modalities(modalities: Sequence[str]) -> tuple[str, ...]:
307
+ result = tuple(str(item) for item in modalities)
308
+ if result != PRODUCTION_JOINT_MODALITIES:
309
+ raise ValueError(
310
+ "the first factorized PBR model fixes modality order to "
311
+ f"{PRODUCTION_JOINT_MODALITIES}, got {result}"
312
+ )
313
+ return result
314
+
315
+
316
+ def _normalize_sites(sites: Sequence[int], num_layers: int) -> tuple[int, ...]:
317
+ result = tuple(int(site) for site in sites)
318
+ if len(set(result)) != len(result):
319
+ raise ValueError(f"joint sites must be unique, got {result}")
320
+ if any(site < 0 or site >= num_layers for site in result):
321
+ raise ValueError(f"joint sites must be within [0, {num_layers - 1}], got {result}")
322
+ return result
323
+
324
+
325
+ def _normalize_residual_mode(residual_mode: str) -> str:
326
+ result = str(residual_mode)
327
+ if result not in JOINT_RESIDUAL_MODES:
328
+ raise ValueError(
329
+ f"joint residual mode must be one of {JOINT_RESIDUAL_MODES}, got {result!r}"
330
+ )
331
+ return result
332
+
333
+
334
+ class ModalityAxisJointAttention(nn.Module):
335
+ """Mix equal-position modality tokens while keeping objects/tokens separate.
336
+
337
+ Inputs use object-major folded-batch order: ``[B*M, L, D]`` where M is the
338
+ fixed modality count. Attention is over the donor-modality axis only. The
339
+ checkpointable forward accepts tensors exclusively.
340
+ """
341
+
342
+ def __init__(
343
+ self,
344
+ hidden_dim: int,
345
+ rank: int = PRODUCTION_JOINT_RANK,
346
+ heads: int = PRODUCTION_JOINT_HEADS,
347
+ modalities: Sequence[str] = PRODUCTION_JOINT_MODALITIES,
348
+ residual_mode: str = JOINT_RESIDUAL_RAW,
349
+ ) -> None:
350
+ super().__init__()
351
+ self.hidden_dim = int(hidden_dim)
352
+ self.rank = int(rank)
353
+ self.heads = int(heads)
354
+ self.modalities = _normalize_modalities(modalities)
355
+ self.num_modalities = len(self.modalities)
356
+ self.residual_mode = _normalize_residual_mode(residual_mode)
357
+
358
+ if self.hidden_dim <= 0 or self.rank <= 0 or self.heads <= 0:
359
+ raise ValueError("hidden_dim, rank, and heads must all be positive")
360
+ if self.rank % self.heads != 0:
361
+ raise ValueError(f"rank={self.rank} must be divisible by heads={self.heads}")
362
+ self.head_dim = self.rank // self.heads
363
+ self.scale = self.head_dim**-0.5
364
+
365
+ self.norm = nn.LayerNorm(self.hidden_dim)
366
+ self.w_down = nn.Linear(self.hidden_dim, self.rank)
367
+ self.modality_embedding = nn.Parameter(torch.empty(self.num_modalities, self.rank))
368
+ self.to_q = nn.Linear(self.rank, self.rank)
369
+ self.to_k = nn.Linear(self.rank, self.rank)
370
+ self.to_v = nn.Linear(self.rank, self.rank)
371
+ self.to_out = nn.Linear(self.rank, self.rank)
372
+ self.w_up = nn.Linear(self.rank, self.hidden_dim)
373
+
374
+ nn.init.normal_(self.modality_embedding, mean=0.0, std=0.02)
375
+ nn.init.zeros_(self.w_up.weight)
376
+ nn.init.zeros_(self.w_up.bias)
377
+
378
+ @staticmethod
379
+ def _route_value(route_code: torch.Tensor) -> int:
380
+ if not isinstance(route_code, torch.Tensor) or route_code.numel() != 1:
381
+ raise ValueError("route_code must be a scalar tensor")
382
+ route = int(route_code.detach().item())
383
+ if route not in (ROUTE_ALIGNED, ROUTE_SELF_SLOT, ROUTE_BLOCKDIAG):
384
+ raise ValueError(f"unknown joint route code: {route}")
385
+ return route
386
+
387
+ def forward(
388
+ self,
389
+ hidden_states: torch.Tensor,
390
+ route_code: torch.Tensor,
391
+ modality_attention_mask: torch.Tensor,
392
+ ) -> torch.Tensor:
393
+ """Return a modality-mixed residual update for target-prefix tokens."""
394
+
395
+ if hidden_states.ndim != 3:
396
+ raise ValueError(
397
+ f"hidden_states must have shape [B*M, L, D], got {tuple(hidden_states.shape)}"
398
+ )
399
+ folded_batch, seq_len, hidden_dim = hidden_states.shape
400
+ if hidden_dim != self.hidden_dim:
401
+ raise ValueError(f"expected hidden dim {self.hidden_dim}, got {hidden_dim}")
402
+ if folded_batch % self.num_modalities != 0:
403
+ raise ValueError(
404
+ f"folded batch {folded_batch} is not divisible by M={self.num_modalities}"
405
+ )
406
+ if seq_len <= 0:
407
+ raise ValueError("the target token sequence must be non-empty")
408
+
409
+ route = self._route_value(route_code)
410
+ if route == ROUTE_BLOCKDIAG and self.training:
411
+ raise ValueError("blockdiag is an evaluation-only intervention")
412
+
413
+ if not isinstance(modality_attention_mask, torch.Tensor):
414
+ raise ValueError("modality_attention_mask must be a tensor")
415
+ if tuple(modality_attention_mask.shape) != (self.num_modalities, self.num_modalities):
416
+ raise ValueError(
417
+ "modality_attention_mask must have shape "
418
+ f"[{self.num_modalities}, {self.num_modalities}], got "
419
+ f"{tuple(modality_attention_mask.shape)}"
420
+ )
421
+ attention_mask = modality_attention_mask.to(device=hidden_states.device, dtype=torch.bool)
422
+ if not bool(attention_mask.any(dim=-1).all().item()):
423
+ raise ValueError("every modality query must have at least one permitted donor")
424
+ if route == ROUTE_BLOCKDIAG:
425
+ expected = torch.eye(self.num_modalities, dtype=torch.bool, device=hidden_states.device)
426
+ if not torch.equal(attention_mask, expected):
427
+ raise ValueError("blockdiag route requires an identity modality attention mask")
428
+
429
+ batch_size = folded_batch // self.num_modalities
430
+ # Object-major fold: flat index = object_index * M + modality_index.
431
+ x = hidden_states.reshape(batch_size, self.num_modalities, seq_len, hidden_dim)
432
+ x = x.permute(0, 2, 1, 3) # [B, L, M, D]
433
+ z = self.w_down(self.norm(x)) # [B, L, M, R]
434
+
435
+ modality_embedding = self.modality_embedding.to(dtype=z.dtype)
436
+ query_input = z + modality_embedding.view(1, 1, self.num_modalities, self.rank)
437
+
438
+ if route == ROUTE_SELF_SLOT:
439
+ # Each query supplies its own content to all donor slots. Donor
440
+ # modality embeddings keep the slots distinct before K/V.
441
+ donor_input = z.unsqueeze(3).expand(-1, -1, -1, self.num_modalities, -1)
442
+ else:
443
+ # Every query sees the real aligned modality tokens at this object
444
+ # and latent-grid position. Blockdiag changes only the edge mask.
445
+ donor_input = z.unsqueeze(2).expand(-1, -1, self.num_modalities, -1, -1)
446
+ donor_input = donor_input + modality_embedding.view(
447
+ 1, 1, 1, self.num_modalities, self.rank
448
+ )
449
+
450
+ query = self.to_q(query_input).reshape(
451
+ batch_size, seq_len, self.num_modalities, self.heads, self.head_dim
452
+ )
453
+ query = query.permute(0, 1, 3, 2, 4) # [B, L, H, Q, dh]
454
+
455
+ key = self.to_k(donor_input).reshape(
456
+ batch_size,
457
+ seq_len,
458
+ self.num_modalities,
459
+ self.num_modalities,
460
+ self.heads,
461
+ self.head_dim,
462
+ )
463
+ value = self.to_v(donor_input).reshape_as(key)
464
+ key = key.permute(0, 1, 4, 2, 3, 5) # [B, L, H, Q, K, dh]
465
+ value = value.permute(0, 1, 4, 2, 3, 5)
466
+
467
+ logits = (query.unsqueeze(-2) * key).sum(dim=-1) * self.scale
468
+ mask = attention_mask.view(1, 1, 1, self.num_modalities, self.num_modalities)
469
+ logits = logits.masked_fill(~mask, torch.finfo(logits.dtype).min)
470
+ weights = torch.softmax(logits.float(), dim=-1).to(dtype=logits.dtype)
471
+ context = (weights.unsqueeze(-1) * value).sum(dim=-2)
472
+ context = context.permute(0, 1, 3, 2, 4).reshape(
473
+ batch_size, seq_len, self.num_modalities, self.rank
474
+ )
475
+ delta = self.w_up(self.to_out(context))
476
+ if self.residual_mode == JOINT_RESIDUAL_RMS_SCALED:
477
+ # Qwen image residual states can reach 1e6--1e7 in BF16 even though
478
+ # every following block consumes a LayerNorm-normalized view. A
479
+ # raw adapter update therefore disappears at the residual add. In
480
+ # the repaired parameterization, the adapter predicts a normalized
481
+ # update and this detached RMS converts it back to the host unit.
482
+ token_rms = torch.linalg.vector_norm(
483
+ x, dim=-1, keepdim=True, dtype=torch.float32
484
+ ) * (hidden_dim**-0.5)
485
+ delta = delta * token_rms.detach().to(dtype=delta.dtype)
486
+ output = x + delta
487
+ return output.permute(0, 2, 1, 3).reshape(folded_batch, seq_len, hidden_dim)
488
+
489
+
490
+ class QwenImagePBRFactorizedTransformer2DModel(QwenImageTransformer2DModel):
491
+ """Qwen image transformer with registered factorized PBR joint modules."""
492
+
493
+ @register_to_config
494
+ def __init__(
495
+ self,
496
+ patch_size: int = 2,
497
+ in_channels: int = 64,
498
+ out_channels: int | None = 16,
499
+ num_layers: int = 60,
500
+ attention_head_dim: int = 128,
501
+ num_attention_heads: int = 24,
502
+ joint_attention_dim: int = 3584,
503
+ guidance_embeds: bool = False,
504
+ axes_dims_rope: tuple[int, int, int] = (16, 56, 56),
505
+ zero_cond_t: bool = False,
506
+ use_additional_t_cond: bool = False,
507
+ use_layer3d_rope: bool = False,
508
+ joint_sites: Sequence[int] = (),
509
+ joint_modalities: Sequence[str] = PRODUCTION_JOINT_MODALITIES,
510
+ joint_rank: int = PRODUCTION_JOINT_RANK,
511
+ joint_heads: int = PRODUCTION_JOINT_HEADS,
512
+ joint_residual_mode: str = JOINT_RESIDUAL_RAW,
513
+ ) -> None:
514
+ super().__init__(
515
+ patch_size=patch_size,
516
+ in_channels=in_channels,
517
+ out_channels=out_channels,
518
+ num_layers=num_layers,
519
+ attention_head_dim=attention_head_dim,
520
+ num_attention_heads=num_attention_heads,
521
+ joint_attention_dim=joint_attention_dim,
522
+ guidance_embeds=guidance_embeds,
523
+ axes_dims_rope=axes_dims_rope,
524
+ zero_cond_t=zero_cond_t,
525
+ use_additional_t_cond=use_additional_t_cond,
526
+ use_layer3d_rope=use_layer3d_rope,
527
+ )
528
+ self._pbr_attention_backend = PBR_ATTENTION_NATIVE
529
+ self.modality_joint_blocks = nn.ModuleDict()
530
+ self.joint_modalities = _normalize_modalities(joint_modalities)
531
+ self.joint_rank = int(joint_rank)
532
+ self.joint_heads = int(joint_heads)
533
+ self.joint_residual_mode = _normalize_residual_mode(joint_residual_mode)
534
+
535
+ normalized_sites = _normalize_sites(joint_sites, len(self.transformer_blocks))
536
+ if normalized_sites:
537
+ self._attach_joint_modules(
538
+ sites=normalized_sites,
539
+ modalities=self.joint_modalities,
540
+ rank=self.joint_rank,
541
+ heads=self.joint_heads,
542
+ residual_mode=self.joint_residual_mode,
543
+ update_config=True,
544
+ )
545
+
546
+ @property
547
+ def joint_sites(self) -> tuple[int, ...]:
548
+ return tuple(int(site) for site in self.modality_joint_blocks.keys())
549
+
550
+ def _attach_joint_modules(
551
+ self,
552
+ sites: Sequence[int],
553
+ modalities: Sequence[str],
554
+ rank: int,
555
+ heads: int,
556
+ residual_mode: str,
557
+ *,
558
+ update_config: bool,
559
+ ) -> None:
560
+ if len(self.modality_joint_blocks) != 0:
561
+ raise RuntimeError(f"joint modules are already attached at sites {self.joint_sites}")
562
+ normalized_sites = _normalize_sites(sites, len(self.transformer_blocks))
563
+ if not normalized_sites:
564
+ raise ValueError("attach_joint_modules requires at least one site")
565
+ normalized_modalities = _normalize_modalities(modalities)
566
+ rank = int(rank)
567
+ heads = int(heads)
568
+ residual_mode = _normalize_residual_mode(residual_mode)
569
+ if rank <= 0 or heads <= 0 or rank % heads != 0:
570
+ raise ValueError(f"joint rank={rank} must be positive and divisible by heads={heads}")
571
+
572
+ self.modality_joint_blocks.update(
573
+ {
574
+ str(site): ModalityAxisJointAttention(
575
+ hidden_dim=self.inner_dim,
576
+ rank=rank,
577
+ heads=heads,
578
+ modalities=normalized_modalities,
579
+ residual_mode=residual_mode,
580
+ )
581
+ for site in normalized_sites
582
+ }
583
+ )
584
+ self.joint_modalities = normalized_modalities
585
+ self.joint_rank = rank
586
+ self.joint_heads = heads
587
+ self.joint_residual_mode = residual_mode
588
+ if update_config:
589
+ self.register_to_config(
590
+ architecture_type="QwenImagePBRFactorized",
591
+ joint_sites=list(normalized_sites),
592
+ joint_modalities=list(normalized_modalities),
593
+ joint_rank=rank,
594
+ joint_heads=heads,
595
+ joint_residual_mode=residual_mode,
596
+ supported_joint_routes=["self_slot", "aligned"],
597
+ blockdiag_eval_only=True,
598
+ )
599
+
600
+ def attach_joint_modules(
601
+ self,
602
+ sites: Sequence[int] = PRODUCTION_JOINT_SITES,
603
+ modalities: Sequence[str] = PRODUCTION_JOINT_MODALITIES,
604
+ rank: int = PRODUCTION_JOINT_RANK,
605
+ heads: int = PRODUCTION_JOINT_HEADS,
606
+ residual_mode: str = JOINT_RESIDUAL_RAW,
607
+ ) -> None:
608
+ """Attach initialized modules after an exact base-only load."""
609
+
610
+ self._attach_joint_modules(
611
+ sites=sites,
612
+ modalities=modalities,
613
+ rank=rank,
614
+ heads=heads,
615
+ residual_mode=residual_mode,
616
+ update_config=True,
617
+ )
618
+
619
+ def set_pbr_attention_backend(
620
+ self,
621
+ backend: str,
622
+ *,
623
+ device: torch.device | str | None = None,
624
+ dtype: torch.dtype | None = None,
625
+ ) -> None:
626
+ """Select the Qwen image-transformer attention kernel for this runtime."""
627
+
628
+ backend = str(backend)
629
+ if backend not in PBR_ATTENTION_BACKENDS:
630
+ raise ValueError(
631
+ f"pbr attention backend must be one of {PBR_ATTENTION_BACKENDS}, "
632
+ f"got {backend!r}"
633
+ )
634
+ if backend == PBR_ATTENTION_FA3:
635
+ if device is None or dtype is None:
636
+ raise ValueError("FA3 backend selection requires runtime device and dtype")
637
+ validate_fa3_runtime(device, dtype)
638
+ processor_factory = QwenDoubleStreamAttnProcessorFA3
639
+ else:
640
+ processor_factory = QwenDoubleStreamAttnProcessor2_0
641
+
642
+ for block in self.transformer_blocks:
643
+ block.attn.set_processor(processor_factory())
644
+ self._pbr_attention_backend = backend
645
+
646
+ @staticmethod
647
+ def _runtime_int(value: int | torch.Tensor | None, name: str) -> int:
648
+ if isinstance(value, torch.Tensor):
649
+ if value.numel() != 1:
650
+ raise ValueError(f"{name} must be a scalar")
651
+ value = int(value.detach().item())
652
+ if not isinstance(value, int):
653
+ raise ValueError(f"{name} must be an integer")
654
+ return value
655
+
656
+ @staticmethod
657
+ def _runtime_route(value: str | int | torch.Tensor | None, device: torch.device) -> torch.Tensor:
658
+ if isinstance(value, str):
659
+ if value not in _ROUTE_NAME_TO_CODE:
660
+ raise ValueError(f"unknown pbr_joint_route: {value!r}")
661
+ code = _ROUTE_NAME_TO_CODE[value]
662
+ elif isinstance(value, torch.Tensor):
663
+ if value.numel() != 1:
664
+ raise ValueError("pbr_joint_route tensor must be scalar")
665
+ code = int(value.detach().item())
666
+ elif isinstance(value, int):
667
+ code = value
668
+ else:
669
+ raise ValueError("pbr_joint_route must be self_slot, aligned, or blockdiag")
670
+ if code not in (ROUTE_ALIGNED, ROUTE_SELF_SLOT, ROUTE_BLOCKDIAG):
671
+ raise ValueError(f"unknown pbr_joint_route code: {code}")
672
+ return torch.tensor(code, dtype=torch.int64, device=device)
673
+
674
+ @apply_lora_scale("attention_kwargs")
675
+ def forward(
676
+ self,
677
+ hidden_states: torch.Tensor,
678
+ encoder_hidden_states: torch.Tensor | None = None,
679
+ encoder_hidden_states_mask: torch.Tensor | None = None,
680
+ timestep: torch.LongTensor | None = None,
681
+ img_shapes: list[tuple[int, int, int]] | None = None,
682
+ txt_seq_lens: list[int] | None = None,
683
+ guidance: torch.Tensor | None = None,
684
+ attention_kwargs: dict[str, Any] | None = None,
685
+ controlnet_block_samples=None,
686
+ additional_t_cond=None,
687
+ return_dict: bool = True,
688
+ pbr_joint_batch_size: int | torch.Tensor | None = None,
689
+ pbr_joint_target_seq_len: int | torch.Tensor | None = None,
690
+ pbr_joint_route: str | int | torch.Tensor | None = None,
691
+ ) -> torch.Tensor | Transformer2DModelOutput:
692
+ """Run stock Qwen blocks and insert target-only modality adapters."""
693
+
694
+ has_joint_modules = len(self.modality_joint_blocks) > 0
695
+ runtime_values = (pbr_joint_batch_size, pbr_joint_target_seq_len, pbr_joint_route)
696
+ if has_joint_modules and any(value is None for value in runtime_values):
697
+ raise ValueError(
698
+ "joint models require pbr_joint_batch_size, pbr_joint_target_seq_len, "
699
+ "and pbr_joint_route"
700
+ )
701
+ if not has_joint_modules and any(value is not None for value in runtime_values):
702
+ raise ValueError("joint runtime arguments were provided before joint modules were attached")
703
+
704
+ if txt_seq_lens is not None:
705
+ deprecate(
706
+ "txt_seq_lens",
707
+ "0.39.0",
708
+ "Passing `txt_seq_lens` is deprecated and will be removed in version 0.39.0. "
709
+ "Please use `encoder_hidden_states_mask` instead. "
710
+ "The mask-based approach is more flexible and supports variable-length sequences.",
711
+ standard_warn=False,
712
+ )
713
+
714
+ if has_joint_modules:
715
+ batch_size = self._runtime_int(pbr_joint_batch_size, "pbr_joint_batch_size")
716
+ target_seq_len = self._runtime_int(
717
+ pbr_joint_target_seq_len, "pbr_joint_target_seq_len"
718
+ )
719
+ if batch_size <= 0:
720
+ raise ValueError("pbr_joint_batch_size must be positive")
721
+ expected_folded_batch = batch_size * len(self.joint_modalities)
722
+ if hidden_states.shape[0] != expected_folded_batch:
723
+ raise ValueError(
724
+ "object-major folded batch mismatch: expected "
725
+ f"B*M={expected_folded_batch}, got {hidden_states.shape[0]}"
726
+ )
727
+ if target_seq_len <= 0 or target_seq_len > hidden_states.shape[1]:
728
+ raise ValueError(
729
+ f"pbr_joint_target_seq_len must be in [1, {hidden_states.shape[1]}], "
730
+ f"got {target_seq_len}"
731
+ )
732
+ route_code = self._runtime_route(pbr_joint_route, hidden_states.device)
733
+ if int(route_code.item()) == ROUTE_BLOCKDIAG:
734
+ modality_attention_mask = torch.eye(
735
+ len(self.joint_modalities), dtype=torch.bool, device=hidden_states.device
736
+ )
737
+ else:
738
+ modality_attention_mask = torch.ones(
739
+ (len(self.joint_modalities), len(self.joint_modalities)),
740
+ dtype=torch.bool,
741
+ device=hidden_states.device,
742
+ )
743
+ else:
744
+ target_seq_len = 0
745
+ route_code = None
746
+ modality_attention_mask = None
747
+
748
+ hidden_states = self.img_in(hidden_states)
749
+ timestep = timestep.to(hidden_states.dtype)
750
+
751
+ if self.zero_cond_t:
752
+ timestep = torch.cat([timestep, timestep * 0], dim=0)
753
+ modulate_index = torch.tensor(
754
+ [[0] * prod(sample[0]) + [1] * sum(prod(shape) for shape in sample[1:]) for sample in img_shapes],
755
+ device=timestep.device,
756
+ dtype=torch.int,
757
+ )
758
+ else:
759
+ modulate_index = None
760
+
761
+ encoder_hidden_states = self.txt_norm(encoder_hidden_states)
762
+ encoder_hidden_states = self.txt_in(encoder_hidden_states)
763
+ text_seq_len, _, encoder_hidden_states_mask = compute_text_seq_len_from_mask(
764
+ encoder_hidden_states, encoder_hidden_states_mask
765
+ )
766
+
767
+ if guidance is not None:
768
+ guidance = guidance.to(hidden_states.dtype) * 1000
769
+
770
+ temb = (
771
+ self.time_text_embed(timestep, hidden_states, additional_t_cond)
772
+ if guidance is None
773
+ else self.time_text_embed(timestep, guidance, hidden_states, additional_t_cond)
774
+ )
775
+ image_rotary_emb = self.pos_embed(
776
+ img_shapes, max_txt_seq_len=text_seq_len, device=hidden_states.device
777
+ )
778
+
779
+ block_attention_kwargs = attention_kwargs.copy() if attention_kwargs is not None else {}
780
+ if encoder_hidden_states_mask is not None:
781
+ folded_batch, image_seq_len = hidden_states.shape[:2]
782
+ image_mask = torch.ones(
783
+ (folded_batch, image_seq_len), dtype=torch.bool, device=hidden_states.device
784
+ )
785
+ joint_attention_mask = torch.cat([encoder_hidden_states_mask, image_mask], dim=1)
786
+ block_attention_kwargs["attention_mask"] = joint_attention_mask
787
+ if self._pbr_attention_backend == PBR_ATTENTION_FA3:
788
+ reserved = {
789
+ "fa3_key_indices",
790
+ "fa3_cu_seqlens_q",
791
+ "fa3_cu_seqlens_k",
792
+ "fa3_max_seqlen_q",
793
+ "fa3_max_seqlen_k",
794
+ }
795
+ conflict = reserved.intersection(block_attention_kwargs)
796
+ if conflict:
797
+ raise ValueError(f"reserved FA3 attention kwargs supplied: {sorted(conflict)}")
798
+ mask_plan = prepare_fa3_key_mask_plan(
799
+ joint_attention_mask,
800
+ query_seq_len=joint_attention_mask.shape[1],
801
+ )
802
+ block_attention_kwargs.update(
803
+ {
804
+ "fa3_key_indices": mask_plan["key_indices"],
805
+ "fa3_cu_seqlens_q": mask_plan["cu_seqlens_q"],
806
+ "fa3_cu_seqlens_k": mask_plan["cu_seqlens_k"],
807
+ "fa3_max_seqlen_q": mask_plan["max_seqlen_q"],
808
+ "fa3_max_seqlen_k": mask_plan["max_seqlen_k"],
809
+ }
810
+ )
811
+
812
+ for index_block, block in enumerate(self.transformer_blocks):
813
+ encoder_hidden_states, hidden_states = block(
814
+ hidden_states=hidden_states,
815
+ encoder_hidden_states=encoder_hidden_states,
816
+ encoder_hidden_states_mask=None,
817
+ temb=temb,
818
+ image_rotary_emb=image_rotary_emb,
819
+ joint_attention_kwargs=block_attention_kwargs,
820
+ modulate_index=modulate_index,
821
+ )
822
+
823
+ mixer_key = str(index_block)
824
+ if mixer_key in self.modality_joint_blocks:
825
+ mixer = self.modality_joint_blocks[mixer_key]
826
+ target_hidden = hidden_states[:, :target_seq_len]
827
+ target_hidden = mixer(
828
+ target_hidden,
829
+ route_code,
830
+ modality_attention_mask,
831
+ )
832
+ if target_seq_len == hidden_states.shape[1]:
833
+ hidden_states = target_hidden
834
+ else:
835
+ # The adapter never reads or writes the common source suffix.
836
+ hidden_states = torch.cat(
837
+ [target_hidden, hidden_states[:, target_seq_len:]], dim=1
838
+ )
839
+
840
+ if controlnet_block_samples is not None:
841
+ interval_control = int(
842
+ np.ceil(len(self.transformer_blocks) / len(controlnet_block_samples))
843
+ )
844
+ hidden_states = hidden_states + controlnet_block_samples[index_block // interval_control]
845
+
846
+ if self.zero_cond_t:
847
+ temb = temb.chunk(2, dim=0)[0]
848
+ hidden_states = self.norm_out(hidden_states, temb)
849
+ output = self.proj_out(hidden_states)
850
+
851
+ if not return_dict:
852
+ return (output,)
853
+ return Transformer2DModelOutput(sample=output)
supratex/frontend/pipeline.py CHANGED
@@ -1,265 +1,291 @@
1
- """Runnable Image-to-Material Translation handoff builder."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import hashlib
6
- import json
7
- from pathlib import Path
8
-
9
- from hf_space.timing import span
10
-
11
- from .camera import estimate
12
- from .firered import run as run_firered
13
- from .preprocess import RMBG2, prepare_image
14
- from .geometry import run as run_geometry
15
- from .qwen_image import run as run_qwen, validate as validate_qwen
16
-
17
-
18
- def predict_material_maps(rgba: Path, rgb: Path, output: Path, *,
19
- material_backend: str, material_base: str | Path,
20
- material_model: str | Path | None = None,
21
- qwen_runtime: Path | None = None, qwen_deps: Path | None = None,
22
- firered_package: Path | None = None, firered_manifest: Path | None = None,
23
- material_weights: Path | None = None, material_spec: Path | None = None,
24
- material_checkpoint_manifest: Path | None = None,
25
- revision: str | None = None, cache_dir: Path | None = None,
26
- local_files_only: bool = False, device: str = "cuda", key: str = "input",
27
- receipt_path: Path | None = None, resident=None) -> tuple[Path, Path]:
28
- """Predict either view with the same material backend and artifact selection."""
29
- if receipt_path is not None and firered_manifest is not None:
30
- raise ValueError("material provenance does not support a custom FireRed manifest; "
31
- "omit it to bind the current condition and use matching prompts for both views")
32
- if receipt_path is not None:
33
- from supratex.weights import resolve_model_dir
34
- material_base = resolve_model_dir(material_base, cache_dir=cache_dir, local_files_only=local_files_only)
35
- if material_model is not None:
36
- patterns = (["checkpoint-0100000.pt", "checkpoint-0100000.complete.json"]
37
- if material_backend == "qwen-image" else
38
- ["full_transformer.safetensors", "joint_contract.json", "checkpoint_complete.json"])
39
- material_model = resolve_model_dir(material_model, revision=revision, cache_dir=cache_dir,
40
- local_files_only=local_files_only, allow_patterns=patterns)
41
- from supratex.backview.material_identity import artifact_identity
42
- before_artifacts = artifact_identity({"base": material_base, "model": material_model,
43
- "weights": material_weights, "spec": material_spec,
44
- "checkpoint_manifest": material_checkpoint_manifest})
45
- if material_backend == "qwen-image":
46
- albedo, mra = run_qwen(qwen_runtime, material_base, material_model, rgba, output,
47
- deps=qwen_deps, cache_dir=cache_dir, revision=revision,
48
- local_files_only=local_files_only, device=device, resident=resident)
49
- elif material_backend == "firered":
50
- albedo, mra = run_firered(firered_package, material_base, rgb, output,
51
- firered_manifest, key=key, weights=material_weights,
52
- spec=material_spec, checkpoint_manifest=material_checkpoint_manifest,
53
- material_model=material_model, revision=revision,
54
- cache_dir=cache_dir, local_files_only=local_files_only)
55
- else:
56
- raise ValueError("material backend must be qwen-image or firered")
57
- if receipt_path is not None:
58
- def identity(value):
59
- if value is None:
60
- return None
61
- path = Path(value).expanduser()
62
- return str(path.resolve()) if path.exists() else str(value)
63
- signature = {"backend": material_backend, "base": identity(material_base),
64
- "model": identity(material_model), "revision": revision,
65
- "runtime": identity(qwen_runtime if material_backend == "qwen-image" else firered_package),
66
- "deps": identity(qwen_deps) if material_backend == "qwen-image" else None,
67
- "weights": identity(material_weights), "spec": identity(material_spec),
68
- "checkpoint_manifest": identity(material_checkpoint_manifest), "seed": 42}
69
- from supratex.backview.material_identity import artifact_identity
70
- signature["artifacts"] = artifact_identity(signature)
71
- if signature["artifacts"] != before_artifacts:
72
- raise RuntimeError("material artifacts changed during prediction; discard this view and use a new output")
73
- # Image digests bind the receipt to the exact maps consumed downstream.
74
- paths = {"condition_rgba.png": rgba, "condition_rgb.png": rgb,
75
- "albedo.png": albedo, "mra.png": mra}
76
- receipt = {"status": "complete", "backend": material_backend, "model_signature": signature,
77
- "files": {name: hashlib.sha256(Path(path).read_bytes()).hexdigest()
78
- for name, path in paths.items()}}
79
- receipt_path = Path(receipt_path)
80
- if receipt_path.exists():
81
- raise FileExistsError(receipt_path)
82
- receipt_path.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")
83
- return albedo, mra
84
-
85
-
86
- def run(input_image: Path, output: Path, *, moge_model: str | Path,
87
- firered_package: Path | None = None, firered_base: str | Path | None = None,
88
- material_backend: str = "qwen-image", material_base: str | Path | None = None,
89
- qwen_runtime: Path | None = None, qwen_deps: Path | None = None,
90
- firered_manifest: Path | None = None, material_weights: Path | None = None, material_spec: Path | None = None,
91
- material_checkpoint_manifest: Path | None = None, rmbg_model: str | Path | None = None, use_input_alpha: bool = False,
92
- device: str = "cuda", moge_module: str = "moge.model.v2", resolution_level: int = 9,
93
- key: str = "input", material_model: str | Path | None = None, revision: str | None = None,
94
- cache_dir: Path | None = None, local_files_only: bool = False,
95
- geometry_package: Path | None = None, geometry_model: str | Path | None = None,
96
- geometry_revision: str | None = None, geometry_initial_mesh_scale: float = 1.0,
97
- geometry_target_padding: int = 3, geometry_max_iterations: int = 2,
98
- geometry_seed: int = 42, record_material_provenance: bool = False,
99
- attention: str = "auto", low_vram: bool | None = None, resident=None) -> dict[str, str]:
100
- """Build the condition, canonical camera, and material-map handoff in one directory."""
101
- with span("frontend.validation"):
102
- if record_material_provenance and firered_manifest is not None:
103
- raise ValueError("material provenance does not support a custom FireRed manifest; "
104
- "omit it to bind the current condition and use matching prompts for both views")
105
- if output.exists() and any(output.iterdir()):
106
- raise FileExistsError("frontend output must be a fresh empty directory")
107
- if (geometry_package is None) != (geometry_model is None):
108
- raise ValueError("--geometry-package and --geometry-model must be supplied together")
109
- if material_backend not in {"qwen-image", "firered"}:
110
- raise ValueError("--material-backend must be qwen-image or firered")
111
- from supratex.inference.runtime import configure_inference_runtime
112
- configure_inference_runtime(attention=attention, low_vram=low_vram, device=device,
113
- material_backend=material_backend)
114
- if material_base is not None and firered_base is not None:
115
- raise ValueError("--material-base and --firered-base cannot be combined")
116
- from supratex.weights import resolve_model_dir
117
- if material_model is not None and any(value is not None for value in
118
- (material_weights, material_spec, material_checkpoint_manifest)):
119
- raise ValueError("--material-model cannot be combined with explicit material artifact paths")
120
- if material_backend == "qwen-image":
121
- if any(value is not None for value in (firered_base, firered_manifest, material_weights,
122
- material_spec, material_checkpoint_manifest)):
123
- raise ValueError("FireRed base, manifest and explicit artifacts require --material-backend firered")
124
- checkpoints = Path(__file__).resolve().parents[2] / "ckpts"
125
- qwen_runtime, material_base, checkpoint, qwen_deps = validate_qwen(
126
- qwen_runtime or checkpoints / "qwen-runtime", material_base or checkpoints / "qwen-base",
127
- material_model or checkpoints / "qwen-material", deps=qwen_deps or checkpoints / "qwen-deps",
128
- cache_dir=cache_dir, revision=revision, local_files_only=local_files_only)
129
- material_model = checkpoint.parent
130
- else:
131
- if qwen_runtime is not None or qwen_deps is not None:
132
- raise ValueError("--qwen-runtime and --qwen-deps require --material-backend qwen-image")
133
- material_base = material_base or firered_base
134
- if material_base is None:
135
- raise ValueError("FireRed requires --material-base or --firered-base")
136
- firered_package = firered_package or Path(__file__).resolve().parent / "material_model"
137
- if not Path(moge_model).expanduser().is_file():
138
- moge_model = resolve_model_dir(moge_model, cache_dir=cache_dir, local_files_only=local_files_only,
139
- allow_patterns=["model.pt"])
140
- else:
141
- moge_model = Path(moge_model).expanduser()
142
- if rmbg_model is not None:
143
- rmbg_model = resolve_model_dir(rmbg_model, cache_dir=cache_dir, local_files_only=local_files_only)
144
- output.mkdir(parents=True, exist_ok=True)
145
- with span("frontend.preprocess"):
146
- if resident is not None and not use_input_alpha:
147
- import torch
148
- with resident.pool.use('background', device=device) as background:
149
- estimator = RMBG2(rmbg_model, device=device)
150
- estimator._model, estimator._torch = background, torch
151
- rgba, rgb, foreground_source = prepare_image(input_image, output_dir=output,
152
- use_input_alpha=False, background_model=estimator)
153
- else:
154
- estimator = RMBG2(rmbg_model, device=device) if rmbg_model and not use_input_alpha else None
155
- try:
156
- rgba, rgb, foreground_source = prepare_image(input_image, output_dir=output, use_input_alpha=use_input_alpha,
157
- background_model=estimator)
158
- finally:
159
- if estimator is not None:
160
- estimator.close()
161
- with span("frontend.camera"):
162
- if resident is not None:
163
- with resident.pool.use('camera', device=device) as camera_model:
164
- camera = estimate(rgb, moge_model, moge_module, resolution_level, device, model=camera_model)
165
- else:
166
- camera = estimate(rgb, moge_model, moge_module, resolution_level, device)
167
- camera["foreground_source"] = foreground_source
168
- camera_path = output / "camera.json"
169
- camera_path.write_text(json.dumps(camera, indent=2) + "\n", encoding="utf-8")
170
- with span("frontend.geometry"):
171
- geometry_result = {}
172
- if geometry_package is not None:
173
- geometry_result = run_geometry(geometry_package, geometry_model, output, output / "geometry",
174
- seed=geometry_seed, initial_mesh_scale=geometry_initial_mesh_scale,
175
- target_padding=geometry_target_padding,
176
- max_iterations=geometry_max_iterations, cache_dir=cache_dir,
177
- revision=geometry_revision, local_files_only=local_files_only, resident=resident)
178
- geometry_path = output / "geometry.pt"
179
- geometry_path.write_bytes(Path(geometry_result["geometry"]).read_bytes())
180
- camera_path.write_bytes(Path(geometry_result["camera"]).read_bytes())
181
- if record_material_provenance:
182
- (output / "geometry_receipt.json").write_bytes(Path(geometry_result["receipt"]).read_bytes())
183
- with span("frontend.material"):
184
- material_dir = output / "material"
185
- albedo, mra = predict_material_maps(
186
- rgba, rgb, material_dir, material_backend=material_backend, material_base=material_base,
187
- material_model=material_model, qwen_runtime=qwen_runtime, qwen_deps=qwen_deps,
188
- firered_package=firered_package, firered_manifest=firered_manifest,
189
- material_weights=material_weights, material_spec=material_spec,
190
- material_checkpoint_manifest=material_checkpoint_manifest, revision=revision,
191
- cache_dir=cache_dir, local_files_only=local_files_only, device=device, key=key,
192
- receipt_path=output / "material_receipt.json" if record_material_provenance else None, resident=resident)
193
- with span("frontend.handoff"):
194
- # The downstream texture stage consumes a single canonical work directory.
195
- final_albedo, final_mra = output / "albedo.png", output / "mra.png"
196
- final_albedo.write_bytes(albedo.read_bytes())
197
- final_mra.write_bytes(mra.read_bytes())
198
- if material_backend == "firered":
199
- (output / "firered_albedo.png").write_bytes(albedo.read_bytes())
200
- (output / "firered_mra.png").write_bytes(mra.read_bytes())
201
- result = {"condition_rgba": str(rgba), "condition_rgb": str(rgb), "camera": str(camera_path),
202
- "albedo": str(final_albedo), "mra": str(final_mra), "foreground_source": foreground_source,
203
- "material_backend": material_backend}
204
- if geometry_result:
205
- result.update(geometry=str(geometry_path), geometry_receipt=geometry_result["receipt"])
206
- return result
207
-
208
-
209
- def main() -> None:
210
- parser = argparse.ArgumentParser(description="Run the complete Supra-Tex Image-to-Material Translation frontend.")
211
- parser.add_argument("--input", type=Path, required=True)
212
- parser.add_argument("--output", type=Path, required=True)
213
- parser.add_argument("--moge-model", required=True, help="local MoGe checkpoint/directory or Hugging Face namespace/repo")
214
- parser.add_argument("--moge-module", default="moge.model.v2")
215
- parser.add_argument("--resolution-level", type=int, default=9)
216
- parser.add_argument("--rmbg-model", help="local RMBG-2 directory or Hugging Face namespace/repo")
217
- parser.add_argument("--use-input-alpha", action="store_true")
218
- parser.add_argument("--firered-package", type=Path, default=Path(__file__).resolve().parent / "material_model")
219
- parser.add_argument("--material-backend", choices=("qwen-image", "firered"), default="qwen-image")
220
- parser.add_argument("--material-base", help="selected backend base directory or Hugging Face namespace/repo")
221
- parser.add_argument("--qwen-runtime", type=Path, help="native Qwen source snapshot (default: ckpts/qwen-runtime)")
222
- parser.add_argument("--qwen-deps", type=Path, help="native Qwen dependency directory (default: ckpts/qwen-deps)")
223
- parser.add_argument("--firered-base", help="FireRed-only alias for --material-base")
224
- parser.add_argument("--material-model", help="local material-model directory or Hugging Face namespace/repo")
225
- parser.add_argument("--revision", help="material-model Hugging Face revision")
226
- parser.add_argument("--cache-dir", type=Path)
227
- parser.add_argument("--local-files-only", action="store_true")
228
- parser.add_argument("--geometry-package", type=Path, help="source checkout for the original two-stage geometry generator")
229
- parser.add_argument("--geometry-model", help="local geometry model directory or Hugging Face namespace/repo")
230
- parser.add_argument("--geometry-revision", help="geometry model Hugging Face revision")
231
- parser.add_argument("--geometry-initial-mesh-scale", type=float, default=1.0)
232
- parser.add_argument("--geometry-target-padding", type=int, default=3)
233
- parser.add_argument("--geometry-max-iterations", type=int, default=2)
234
- parser.add_argument("--firered-manifest", type=Path)
235
- parser.add_argument("--material-weights", type=Path)
236
- parser.add_argument("--material-spec", type=Path)
237
- parser.add_argument("--material-checkpoint-manifest", type=Path)
238
- parser.add_argument("--key", default="input")
239
- parser.add_argument("--record-material-provenance", action="store_true", help="write material bindings for optional dual-view inference")
240
- parser.add_argument("--device", default="cuda")
241
- parser.add_argument("--attention", choices=("auto", "flash_attn_3", "flash_attn", "sdpa", "xformers"), default="auto")
242
- parser.add_argument("--low-vram", action=argparse.BooleanOptionalAction, default=None,
243
- help="CPU offload for Qwen/3D; auto enabled on GPUs with up to 24 GiB")
244
- args = parser.parse_args()
245
- print(json.dumps(run(args.input, args.output, moge_model=args.moge_model, firered_package=args.firered_package,
246
- material_backend=args.material_backend, material_base=args.material_base,
247
- qwen_runtime=args.qwen_runtime, qwen_deps=args.qwen_deps,
248
- firered_base=args.firered_base, firered_manifest=args.firered_manifest,
249
- material_weights=args.material_weights, material_spec=args.material_spec,
250
- material_checkpoint_manifest=args.material_checkpoint_manifest,
251
- material_model=args.material_model, revision=args.revision, cache_dir=args.cache_dir,
252
- local_files_only=args.local_files_only,
253
- geometry_package=args.geometry_package, geometry_model=args.geometry_model,
254
- geometry_revision=args.geometry_revision,
255
- geometry_initial_mesh_scale=args.geometry_initial_mesh_scale,
256
- geometry_target_padding=args.geometry_target_padding,
257
- geometry_max_iterations=args.geometry_max_iterations,
258
- rmbg_model=args.rmbg_model, use_input_alpha=args.use_input_alpha, device=args.device,
259
- moge_module=args.moge_module, resolution_level=args.resolution_level, key=args.key,
260
- record_material_provenance=args.record_material_provenance,
261
- attention=args.attention, low_vram=args.low_vram), indent=2))
262
-
263
-
264
- if __name__ == "__main__":
265
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runnable Image-to-Material Translation handoff builder."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from .camera import estimate
10
+ from .firered import run as run_firered
11
+ from .preprocess import RMBG2, prepare_image
12
+ from .geometry import run as run_geometry
13
+ from .qwen_image import run as run_qwen, validate as validate_qwen
14
+
15
+
16
+ def predict_material_maps(rgba: Path, rgb: Path, output: Path, *,
17
+ material_backend: str, material_base: str | Path,
18
+ material_model: str | Path | None = None,
19
+ qwen_runtime: Path | None = None, qwen_deps: Path | None = None,
20
+ firered_package: Path | None = None, firered_manifest: Path | None = None,
21
+ material_weights: Path | None = None, material_spec: Path | None = None,
22
+ material_checkpoint_manifest: Path | None = None,
23
+ revision: str | None = None, cache_dir: Path | None = None,
24
+ local_files_only: bool = False, device: str = "cuda", key: str = "input",
25
+ receipt_path: Path | None = None,
26
+ cuda_visible_devices: str | None = None) -> tuple[Path, Path]:
27
+ """Predict either view with the same material backend and artifact selection."""
28
+ if receipt_path is not None and firered_manifest is not None:
29
+ raise ValueError("material provenance does not support a custom FireRed manifest; "
30
+ "omit it to bind the current condition and use matching prompts for both views")
31
+ if receipt_path is not None:
32
+ from supratex.weights import resolve_model_dir
33
+ material_base = resolve_model_dir(material_base, cache_dir=cache_dir, local_files_only=local_files_only)
34
+ if material_model is not None:
35
+ patterns = (["adapter_model.safetensors", "adapter_config.json",
36
+ "checkpoint-0100000.pt", "checkpoint-0100000.complete.json"]
37
+ if material_backend == "qwen-image" else
38
+ ["full_transformer.safetensors", "joint_contract.json", "checkpoint_complete.json"])
39
+ material_model = resolve_model_dir(material_model, revision=revision, cache_dir=cache_dir,
40
+ local_files_only=local_files_only, allow_patterns=patterns)
41
+ from supratex.backview.material_identity import artifact_identity
42
+ before_artifacts = artifact_identity({"base": material_base, "model": material_model,
43
+ "weights": material_weights, "spec": material_spec,
44
+ "checkpoint_manifest": material_checkpoint_manifest})
45
+ if material_backend == "qwen-image":
46
+ albedo, mra = run_qwen(qwen_runtime, material_base, material_model, rgba, output,
47
+ deps=qwen_deps, cache_dir=cache_dir, revision=revision,
48
+ local_files_only=local_files_only, device=device,
49
+ cuda_visible_devices=cuda_visible_devices)
50
+ elif material_backend == "firered":
51
+ albedo, mra = run_firered(firered_package, material_base, rgb, output,
52
+ firered_manifest, key=key, weights=material_weights,
53
+ spec=material_spec, checkpoint_manifest=material_checkpoint_manifest,
54
+ material_model=material_model, revision=revision,
55
+ cache_dir=cache_dir, local_files_only=local_files_only)
56
+ else:
57
+ raise ValueError("material backend must be qwen-image or firered")
58
+ if receipt_path is not None:
59
+ def identity(value):
60
+ if value is None:
61
+ return None
62
+ path = Path(value).expanduser()
63
+ return str(path.resolve()) if path.exists() else str(value)
64
+ signature = {"backend": material_backend, "base": identity(material_base),
65
+ "model": identity(material_model), "revision": revision,
66
+ "runtime": identity(qwen_runtime if material_backend == "qwen-image" else firered_package),
67
+ "deps": identity(qwen_deps) if material_backend == "qwen-image" else None,
68
+ "weights": identity(material_weights), "spec": identity(material_spec),
69
+ "checkpoint_manifest": identity(material_checkpoint_manifest), "seed": 42}
70
+ from supratex.backview.material_identity import artifact_identity
71
+ signature["artifacts"] = artifact_identity(signature)
72
+ if signature["artifacts"] != before_artifacts:
73
+ raise RuntimeError("material artifacts changed during prediction; discard this view and use a new output")
74
+ # Image digests bind the receipt to the exact maps consumed downstream.
75
+ paths = {"condition_rgba.png": rgba, "condition_rgb.png": rgb,
76
+ "albedo.png": albedo, "mra.png": mra}
77
+ receipt = {"status": "complete", "backend": material_backend, "model_signature": signature,
78
+ "files": {name: hashlib.sha256(Path(path).read_bytes()).hexdigest()
79
+ for name, path in paths.items()}}
80
+ receipt_path = Path(receipt_path)
81
+ if receipt_path.exists():
82
+ raise FileExistsError(receipt_path)
83
+ receipt_path.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")
84
+ return albedo, mra
85
+
86
+
87
+ def _split_frontend_gpus(material_backend: str, device: str) -> tuple[str, str]:
88
+ """Pick the first two visible GPUs for geometry and Qwen material."""
89
+ import os
90
+ import torch
91
+
92
+ if material_backend != "qwen-image" or device != "cuda" or torch.cuda.device_count() < 2:
93
+ raise ValueError("--parallel-frontend requires the qwen-image backend, device 'cuda', "
94
+ "geometry generation, and at least two visible GPUs")
95
+ visible = os.environ.get("CUDA_VISIBLE_DEVICES")
96
+ names = [name.strip() for name in visible.split(",")] if visible else [str(index) for index in range(torch.cuda.device_count())]
97
+ return names[0], names[1]
98
+
99
+
100
+ def run(input_image: Path, output: Path, *, moge_model: str | Path,
101
+ firered_package: Path | None = None, firered_base: str | Path | None = None,
102
+ material_backend: str = "qwen-image", material_base: str | Path | None = None,
103
+ qwen_runtime: Path | None = None, qwen_deps: Path | None = None,
104
+ firered_manifest: Path | None = None, material_weights: Path | None = None, material_spec: Path | None = None,
105
+ material_checkpoint_manifest: Path | None = None, rmbg_model: str | Path | None = None, use_input_alpha: bool = False,
106
+ device: str = "cuda", moge_module: str = "moge.model.v2", resolution_level: int = 9,
107
+ key: str = "input", material_model: str | Path | None = None, revision: str | None = None,
108
+ cache_dir: Path | None = None, local_files_only: bool = False,
109
+ geometry_package: Path | None = None, geometry_model: str | Path | None = None,
110
+ geometry_revision: str | None = None, geometry_initial_mesh_scale: float = 1.0,
111
+ geometry_scale_search: bool = False, geometry_target_padding: int = 3, geometry_max_iterations: int = 2,
112
+ geometry_seed: int = 42, record_material_provenance: bool = False,
113
+ attention: str = "auto", low_vram: bool | None = None,
114
+ parallel_frontend: bool = False) -> dict[str, str]:
115
+ """Build the condition, canonical camera, and material-map handoff in one directory."""
116
+ if record_material_provenance and firered_manifest is not None:
117
+ raise ValueError("material provenance does not support a custom FireRed manifest; "
118
+ "omit it to bind the current condition and use matching prompts for both views")
119
+ if output.exists() and any(output.iterdir()):
120
+ raise FileExistsError("frontend output must be a fresh empty directory")
121
+ if (geometry_package is None) != (geometry_model is None):
122
+ raise ValueError("--geometry-package and --geometry-model must be supplied together")
123
+ if material_backend not in {"qwen-image", "firered"}:
124
+ raise ValueError("--material-backend must be qwen-image or firered")
125
+ if parallel_frontend and geometry_package is None:
126
+ raise ValueError("--parallel-frontend requires geometry generation")
127
+ gpus = _split_frontend_gpus(material_backend, device) if parallel_frontend else None
128
+ from supratex.inference.runtime import configure_inference_runtime
129
+ configure_inference_runtime(attention=attention, low_vram=low_vram, device=device,
130
+ material_backend=material_backend)
131
+ if material_base is not None and firered_base is not None:
132
+ raise ValueError("--material-base and --firered-base cannot be combined")
133
+ from supratex.weights import resolve_model_dir
134
+ if material_model is not None and any(value is not None for value in
135
+ (material_weights, material_spec, material_checkpoint_manifest)):
136
+ raise ValueError("--material-model cannot be combined with explicit material artifact paths")
137
+ if material_backend == "qwen-image":
138
+ if any(value is not None for value in (firered_base, firered_manifest, material_weights,
139
+ material_spec, material_checkpoint_manifest)):
140
+ raise ValueError("FireRed base, manifest and explicit artifacts require --material-backend firered")
141
+ from supratex.pipeline_config import runtime_root
142
+ root = Path(__file__).resolve().parents[2]
143
+ checkpoints = root / "ckpts"
144
+ qwen_runtime, material_base, checkpoint, qwen_deps = validate_qwen(
145
+ qwen_runtime or runtime_root(root) / "qwen", material_base or checkpoints / "qwen-base",
146
+ material_model or checkpoints / "qwen-material", deps=qwen_deps,
147
+ cache_dir=cache_dir, revision=revision, local_files_only=local_files_only)
148
+ material_model = checkpoint.parent
149
+ else:
150
+ if qwen_runtime is not None or qwen_deps is not None:
151
+ raise ValueError("--qwen-runtime and --qwen-deps require --material-backend qwen-image")
152
+ material_base = material_base or firered_base
153
+ if material_base is None:
154
+ raise ValueError("FireRed requires --material-base or --firered-base")
155
+ firered_package = firered_package or Path(__file__).resolve().parent / "material_model"
156
+ if not Path(moge_model).expanduser().is_file():
157
+ moge_model = resolve_model_dir(moge_model, cache_dir=cache_dir, local_files_only=local_files_only,
158
+ allow_patterns=["model.pt"])
159
+ else:
160
+ moge_model = Path(moge_model).expanduser()
161
+ if rmbg_model is not None:
162
+ rmbg_model = resolve_model_dir(rmbg_model, cache_dir=cache_dir, local_files_only=local_files_only)
163
+ output.mkdir(parents=True, exist_ok=True)
164
+ estimator = RMBG2(rmbg_model, device=device) if rmbg_model else None
165
+ try:
166
+ rgba, rgb, foreground_source = prepare_image(input_image, output_dir=output, use_input_alpha=use_input_alpha,
167
+ background_model=estimator)
168
+ finally:
169
+ if estimator is not None:
170
+ estimator.close()
171
+ camera = estimate(rgb, moge_model, moge_module, resolution_level, device)
172
+ camera["foreground_source"] = foreground_source
173
+ camera_path = output / "camera.json"
174
+ camera_path.write_text(json.dumps(camera, indent=2) + "\n", encoding="utf-8")
175
+ # Material maps depend only on the foreground condition, not on geometry. With
176
+ # --parallel-frontend both subprocesses run concurrently, one GPU each.
177
+ geometry_result, geometry_job = {}, None
178
+ if geometry_package is not None:
179
+ def geometry_call() -> dict:
180
+ return run_geometry(geometry_package, geometry_model, output, output / "geometry",
181
+ seed=geometry_seed, initial_mesh_scale=geometry_initial_mesh_scale,
182
+ scale_search=geometry_scale_search, target_padding=geometry_target_padding,
183
+ max_iterations=geometry_max_iterations, cache_dir=cache_dir,
184
+ revision=geometry_revision, local_files_only=local_files_only,
185
+ cuda_visible_devices=gpus[0] if gpus else None)
186
+ if gpus:
187
+ from concurrent.futures import ThreadPoolExecutor
188
+ executor = ThreadPoolExecutor(max_workers=1)
189
+ geometry_job = executor.submit(geometry_call)
190
+ executor.shutdown(wait=False)
191
+ print(f"Frontend: geometry on GPU {gpus[0]}, material on GPU {gpus[1]} in parallel", flush=True)
192
+ else:
193
+ geometry_result = geometry_call()
194
+ material_dir = output / "material"
195
+ try:
196
+ albedo, mra = predict_material_maps(
197
+ rgba, rgb, material_dir, material_backend=material_backend, material_base=material_base,
198
+ material_model=material_model, qwen_runtime=qwen_runtime, qwen_deps=qwen_deps,
199
+ firered_package=firered_package, firered_manifest=firered_manifest,
200
+ material_weights=material_weights, material_spec=material_spec,
201
+ material_checkpoint_manifest=material_checkpoint_manifest, revision=revision,
202
+ cache_dir=cache_dir, local_files_only=local_files_only, device=device, key=key,
203
+ receipt_path=output / "material_receipt.json" if record_material_provenance else None,
204
+ cuda_visible_devices=gpus[1] if gpus else None)
205
+ finally:
206
+ if geometry_job is not None:
207
+ # Always wait, so a material failure never leaves geometry running.
208
+ geometry_result = geometry_job.result()
209
+ if geometry_package is not None:
210
+ geometry_path = output / "geometry.pt"
211
+ geometry_path.write_bytes(Path(geometry_result["geometry"]).read_bytes())
212
+ camera_path.write_bytes(Path(geometry_result["camera"]).read_bytes())
213
+ if record_material_provenance:
214
+ (output / "geometry_receipt.json").write_bytes(Path(geometry_result["receipt"]).read_bytes())
215
+ # The downstream texture stage consumes a single canonical work directory.
216
+ final_albedo, final_mra = output / "albedo.png", output / "mra.png"
217
+ final_albedo.write_bytes(albedo.read_bytes())
218
+ final_mra.write_bytes(mra.read_bytes())
219
+ if material_backend == "firered":
220
+ (output / "firered_albedo.png").write_bytes(albedo.read_bytes())
221
+ (output / "firered_mra.png").write_bytes(mra.read_bytes())
222
+ result = {"condition_rgba": str(rgba), "condition_rgb": str(rgb), "camera": str(camera_path),
223
+ "albedo": str(final_albedo), "mra": str(final_mra), "foreground_source": foreground_source,
224
+ "material_backend": material_backend}
225
+ if geometry_result:
226
+ result.update(geometry=str(geometry_path), geometry_receipt=geometry_result["receipt"])
227
+ return result
228
+
229
+
230
+ def main() -> None:
231
+ parser = argparse.ArgumentParser(description="Run the complete Supra-Tex Image-to-Material Translation frontend.")
232
+ parser.add_argument("--input", type=Path, required=True)
233
+ parser.add_argument("--output", type=Path, required=True)
234
+ parser.add_argument("--moge-model", required=True, help="local MoGe checkpoint/directory or Hugging Face namespace/repo")
235
+ parser.add_argument("--moge-module", default="moge.model.v2")
236
+ parser.add_argument("--resolution-level", type=int, default=9)
237
+ parser.add_argument("--rmbg-model", help="local RMBG-2 directory or Hugging Face namespace/repo")
238
+ parser.add_argument("--use-input-alpha", action="store_true")
239
+ parser.add_argument("--firered-package", type=Path, default=Path(__file__).resolve().parent / "material_model")
240
+ parser.add_argument("--material-backend", choices=("qwen-image", "firered"), default="qwen-image")
241
+ parser.add_argument("--material-base", help="selected backend base directory or Hugging Face namespace/repo")
242
+ parser.add_argument("--qwen-runtime", type=Path, help="native Qwen source snapshot (default: bundled runtime)")
243
+ parser.add_argument("--qwen-deps", type=Path, help="optional Qwen dependency overlay; otherwise use the active Python environment")
244
+ parser.add_argument("--firered-base", help="FireRed-only alias for --material-base")
245
+ parser.add_argument("--material-model", help="local material-model directory or Hugging Face namespace/repo")
246
+ parser.add_argument("--revision", help="material-model Hugging Face revision")
247
+ parser.add_argument("--cache-dir", type=Path)
248
+ parser.add_argument("--local-files-only", action="store_true")
249
+ parser.add_argument("--geometry-package", type=Path, help="source checkout for the original two-stage geometry generator")
250
+ parser.add_argument("--geometry-model", help="local geometry model directory or Hugging Face namespace/repo")
251
+ parser.add_argument("--geometry-revision", help="geometry model Hugging Face revision")
252
+ parser.add_argument("--geometry-initial-mesh-scale", type=float, default=1.0)
253
+ parser.add_argument("--geometry-scale-search", action="store_true", help="enable bounded mesh-scale calibration (default: Pixal3D fixed scale)")
254
+ parser.add_argument("--parallel-frontend", action="store_true",
255
+ help="run geometry and Qwen material concurrently on the first two visible GPUs")
256
+ parser.add_argument("--geometry-target-padding", type=int, default=3)
257
+ parser.add_argument("--geometry-max-iterations", type=int, default=2)
258
+ parser.add_argument("--firered-manifest", type=Path)
259
+ parser.add_argument("--material-weights", type=Path)
260
+ parser.add_argument("--material-spec", type=Path)
261
+ parser.add_argument("--material-checkpoint-manifest", type=Path)
262
+ parser.add_argument("--key", default="input")
263
+ parser.add_argument("--record-material-provenance", action="store_true", help="write material bindings for optional dual-view inference")
264
+ parser.add_argument("--device", default="cuda")
265
+ parser.add_argument("--attention", choices=("auto", "flash_attn_3", "flash_attn", "sdpa", "xformers"), default="auto")
266
+ parser.add_argument("--low-vram", action=argparse.BooleanOptionalAction, default=None,
267
+ help="CPU offload for Qwen/3D; auto enabled on GPUs with up to 24 GiB")
268
+ args = parser.parse_args()
269
+ print(json.dumps(run(args.input, args.output, moge_model=args.moge_model, firered_package=args.firered_package,
270
+ material_backend=args.material_backend, material_base=args.material_base,
271
+ qwen_runtime=args.qwen_runtime, qwen_deps=args.qwen_deps,
272
+ firered_base=args.firered_base, firered_manifest=args.firered_manifest,
273
+ material_weights=args.material_weights, material_spec=args.material_spec,
274
+ material_checkpoint_manifest=args.material_checkpoint_manifest,
275
+ material_model=args.material_model, revision=args.revision, cache_dir=args.cache_dir,
276
+ local_files_only=args.local_files_only,
277
+ geometry_package=args.geometry_package, geometry_model=args.geometry_model,
278
+ geometry_revision=args.geometry_revision,
279
+ geometry_initial_mesh_scale=args.geometry_initial_mesh_scale,
280
+ geometry_scale_search=args.geometry_scale_search,
281
+ parallel_frontend=args.parallel_frontend,
282
+ geometry_target_padding=args.geometry_target_padding,
283
+ geometry_max_iterations=args.geometry_max_iterations,
284
+ rmbg_model=args.rmbg_model, use_input_alpha=args.use_input_alpha, device=args.device,
285
+ moge_module=args.moge_module, resolution_level=args.resolution_level, key=args.key,
286
+ record_material_provenance=args.record_material_provenance,
287
+ attention=args.attention, low_vram=args.low_vram), indent=2))
288
+
289
+
290
+ if __name__ == "__main__":
291
+ main()
supratex/frontend/preprocess.py CHANGED
@@ -1,128 +1,128 @@
1
- """Foreground preparation for the Supra-Tex Image-to-Material Translation stage."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- from pathlib import Path
6
- from typing import Protocol
7
-
8
- import numpy as np
9
- from PIL import Image
10
-
11
-
12
- class AlphaEstimator(Protocol):
13
- def __call__(self, image: Image.Image) -> Image.Image: ...
14
-
15
-
16
- class RMBG2:
17
- """Local RMBG-2 foreground adapter using its documented Transformers form."""
18
-
19
- def __init__(self, model: str | Path, *, device: str = "cuda", alpha_threshold: float | None = None):
20
- self.model_path = Path(model)
21
- if not self.model_path.is_dir():
22
- raise FileNotFoundError(f"RMBG-2 model directory does not exist: {self.model_path}")
23
- if alpha_threshold is not None and not 0.0 <= alpha_threshold <= 1.0:
24
- raise ValueError("alpha_threshold must be in [0, 1]")
25
- self.device, self.alpha_threshold, self._model = device, alpha_threshold, None
26
-
27
- def _load(self):
28
- if self._model is None:
29
- try:
30
- from transformers import AutoModelForImageSegmentation
31
- import torch
32
- except ImportError as error: # pragma: no cover - optional runtime
33
- raise RuntimeError("RMBG-2 requires torch and transformers") from error
34
- self._torch = torch
35
- self._model = AutoModelForImageSegmentation.from_pretrained(
36
- str(self.model_path), trust_remote_code=True, local_files_only=True
37
- ).to(self.device).eval()
38
- return self._model
39
-
40
- def close(self):
41
- if self._model is not None:
42
- self._model.cpu()
43
- self._model = None
44
- if str(self.device).startswith("cuda"):
45
- self._torch.cuda.empty_cache()
46
-
47
- def __call__(self, image: Image.Image) -> Image.Image:
48
- model, torch = self._load(), self._torch
49
- rgb = image.convert("RGB")
50
- resized = rgb.resize((1024, 1024), Image.Resampling.LANCZOS)
51
- value = np.asarray(resized, dtype=np.float32) / 255.0
52
- value = (value - np.asarray((0.485, 0.456, 0.406), np.float32)) / np.asarray((0.229, 0.224, 0.225), np.float32)
53
- tensor = torch.from_numpy(value).permute(2, 0, 1).unsqueeze(0).to(self.device)
54
- with torch.inference_mode():
55
- prediction = model(tensor)
56
- logits = prediction[-1] if isinstance(prediction, (tuple, list)) else prediction.logits
57
- alpha = logits[0].squeeze().sigmoid().float().cpu().numpy()
58
- mask = Image.fromarray(np.clip(alpha * 255.0, 0, 255).astype(np.uint8), mode="L")
59
- mask = mask.resize(rgb.size, Image.Resampling.LANCZOS)
60
- if self.alpha_threshold is not None:
61
- cutoff = round(self.alpha_threshold * 255)
62
- mask = mask.point(lambda value: 255 if value >= cutoff else 0)
63
- rgb.putalpha(mask)
64
- return rgb
65
-
66
-
67
- def _crop_square(image: Image.Image) -> Image.Image:
68
- alpha = np.asarray(image.convert("RGBA"), dtype=np.uint8)[..., 3]
69
- foreground = np.argwhere(alpha > 0.8 * 255)
70
- if not foreground.size:
71
- raise ValueError("foreground alpha has no pixels above the 0.8 threshold")
72
- x0, y0 = foreground[:, 1].min(), foreground[:, 0].min()
73
- x1, y1 = foreground[:, 1].max(), foreground[:, 0].max()
74
- side = int(max(x1 - x0, y1 - y0) * 1.1)
75
- if side <= 0:
76
- raise ValueError("foreground crop has zero extent")
77
- cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
78
- left, top = int(cx - side // 2), int(cy - side // 2)
79
- return image.crop((left, top, left + side, top + side))
80
-
81
-
82
- def prepare_image(source: str | Path, *, output_dir: str | Path, use_input_alpha: bool = False,
83
- already_cropped: bool = False, background_model: AlphaEstimator | None = None) -> tuple[Path, Path, str]:
84
- """Write aligned condition RGBA/RGB frames and return their paths and provenance."""
85
- image = Image.open(source)
86
- rgba_present = image.mode == "RGBA" and np.any(np.asarray(image)[..., 3] != 255)
87
- if use_input_alpha and not rgba_present:
88
- raise ValueError("--use-input-alpha requires a non-opaque RGBA input")
89
- if not already_cropped:
90
- scale = min(1.0, 1024.0 / max(image.size))
91
- if scale < 1:
92
- image = image.resize((int(image.width * scale), int(image.height * scale)), Image.Resampling.LANCZOS)
93
- if rgba_present:
94
- rgba, provenance = image.convert("RGBA"), "input_alpha"
95
- else:
96
- if background_model is None:
97
- raise ValueError("no foreground alpha supplied; pass an explicit RMBG-2 model")
98
- rgba, provenance = background_model(image.convert("RGB")).convert("RGBA"), "rmbg2"
99
- if not already_cropped:
100
- rgba = _crop_square(rgba)
101
- data = np.asarray(rgba, dtype=np.float32) / 255.0
102
- rgb = Image.fromarray((np.clip(data[..., :3] * data[..., 3:4], 0, 1) * 255).astype(np.uint8))
103
- out = Path(output_dir)
104
- out.mkdir(parents=True, exist_ok=True)
105
- rgba_path, rgb_path = out / "condition_rgba.png", out / "condition_rgb.png"
106
- rgba.save(rgba_path)
107
- rgb.save(rgb_path)
108
- return rgba_path, rgb_path, provenance + ("_precropped" if already_cropped else "")
109
-
110
-
111
- def main() -> None:
112
- parser = argparse.ArgumentParser(description="Prepare an Image-to-Material Translation condition image.")
113
- parser.add_argument("--input", type=Path, required=True)
114
- parser.add_argument("--output-dir", type=Path, required=True)
115
- parser.add_argument("--use-input-alpha", action="store_true")
116
- parser.add_argument("--already-cropped", action="store_true")
117
- parser.add_argument("--rmbg-model", type=Path, help="Local RMBG-2 model directory for opaque images.")
118
- parser.add_argument("--device", default="cuda")
119
- parser.add_argument("--alpha-threshold", type=float)
120
- args = parser.parse_args()
121
- estimator = RMBG2(args.rmbg_model, device=args.device, alpha_threshold=args.alpha_threshold) if args.rmbg_model else None
122
- rgba, rgb, provenance = prepare_image(args.input, output_dir=args.output_dir, use_input_alpha=args.use_input_alpha,
123
- already_cropped=args.already_cropped, background_model=estimator)
124
- print(f"condition_rgba={rgba}\ncondition_rgb={rgb}\nforeground_source={provenance}")
125
-
126
-
127
- if __name__ == "__main__":
128
- main()
 
1
+ """Foreground preparation for the Supra-Tex Image-to-Material Translation stage."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+ import numpy as np
9
+ from PIL import Image
10
+
11
+
12
+ class AlphaEstimator(Protocol):
13
+ def __call__(self, image: Image.Image) -> Image.Image: ...
14
+
15
+
16
+ class RMBG2:
17
+ """Local RMBG-2 foreground adapter using its documented Transformers form."""
18
+
19
+ def __init__(self, model: str | Path, *, device: str = "cuda", alpha_threshold: float | None = None):
20
+ self.model_path = Path(model)
21
+ if not self.model_path.is_dir():
22
+ raise FileNotFoundError(f"RMBG-2 model directory does not exist: {self.model_path}")
23
+ if alpha_threshold is not None and not 0.0 <= alpha_threshold <= 1.0:
24
+ raise ValueError("alpha_threshold must be in [0, 1]")
25
+ self.device, self.alpha_threshold, self._model = device, alpha_threshold, None
26
+
27
+ def _load(self):
28
+ if self._model is None:
29
+ try:
30
+ from transformers import AutoModelForImageSegmentation
31
+ import torch
32
+ except ImportError as error: # pragma: no cover - optional runtime
33
+ raise RuntimeError("RMBG-2 requires torch and transformers") from error
34
+ self._torch = torch
35
+ self._model = AutoModelForImageSegmentation.from_pretrained(
36
+ str(self.model_path), trust_remote_code=True, local_files_only=True
37
+ ).to(self.device).eval()
38
+ return self._model
39
+
40
+ def close(self):
41
+ if self._model is not None:
42
+ self._model.cpu()
43
+ self._model = None
44
+ if str(self.device).startswith("cuda"):
45
+ self._torch.cuda.empty_cache()
46
+
47
+ def __call__(self, image: Image.Image) -> Image.Image:
48
+ model, torch = self._load(), self._torch
49
+ rgb = image.convert("RGB")
50
+ resized = rgb.resize((1024, 1024), Image.Resampling.LANCZOS)
51
+ value = np.asarray(resized, dtype=np.float32) / 255.0
52
+ value = (value - np.asarray((0.485, 0.456, 0.406), np.float32)) / np.asarray((0.229, 0.224, 0.225), np.float32)
53
+ tensor = torch.from_numpy(value).permute(2, 0, 1).unsqueeze(0).to(self.device)
54
+ with torch.inference_mode():
55
+ prediction = model(tensor)
56
+ logits = prediction[-1] if isinstance(prediction, (tuple, list)) else prediction.logits
57
+ alpha = logits[0].squeeze().sigmoid().float().cpu().numpy()
58
+ mask = Image.fromarray(np.clip(alpha * 255.0, 0, 255).astype(np.uint8), mode="L")
59
+ mask = mask.resize(rgb.size, Image.Resampling.LANCZOS)
60
+ if self.alpha_threshold is not None:
61
+ cutoff = round(self.alpha_threshold * 255)
62
+ mask = mask.point(lambda value: 255 if value >= cutoff else 0)
63
+ rgb.putalpha(mask)
64
+ return rgb
65
+
66
+
67
+ def _crop_square(image: Image.Image) -> Image.Image:
68
+ alpha = np.asarray(image.convert("RGBA"), dtype=np.uint8)[..., 3]
69
+ foreground = np.argwhere(alpha > 0.8 * 255)
70
+ if not foreground.size:
71
+ raise ValueError("foreground alpha has no pixels above the 0.8 threshold")
72
+ x0, y0 = foreground[:, 1].min(), foreground[:, 0].min()
73
+ x1, y1 = foreground[:, 1].max(), foreground[:, 0].max()
74
+ side = int(max(x1 - x0, y1 - y0) * 1.1)
75
+ if side <= 0:
76
+ raise ValueError("foreground crop has zero extent")
77
+ cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
78
+ # Same float box as Pixal3D's preprocess_image; PIL rounds it identically.
79
+ return image.crop((cx - side // 2, cy - side // 2, cx + side // 2, cy + side // 2))
80
+
81
+
82
+ def prepare_image(source: str | Path, *, output_dir: str | Path, use_input_alpha: bool = False,
83
+ already_cropped: bool = False, background_model: AlphaEstimator | None = None) -> tuple[Path, Path, str]:
84
+ """Write aligned condition RGBA/RGB frames and return their paths and provenance."""
85
+ image = Image.open(source)
86
+ rgba_present = image.mode == "RGBA" and np.any(np.asarray(image)[..., 3] != 255)
87
+ if use_input_alpha and not rgba_present:
88
+ raise ValueError("--use-input-alpha requires a non-opaque RGBA input")
89
+ if not already_cropped:
90
+ scale = min(1.0, 1024.0 / max(image.size))
91
+ if scale < 1:
92
+ image = image.resize((int(image.width * scale), int(image.height * scale)), Image.Resampling.LANCZOS)
93
+ if rgba_present:
94
+ rgba, provenance = image.convert("RGBA"), "input_alpha"
95
+ else:
96
+ if background_model is None:
97
+ raise ValueError("no foreground alpha supplied; pass an explicit RMBG-2 model")
98
+ rgba, provenance = background_model(image.convert("RGB")).convert("RGBA"), "rmbg2"
99
+ if not already_cropped:
100
+ rgba = _crop_square(rgba)
101
+ data = np.asarray(rgba, dtype=np.float32) / 255.0
102
+ rgb = Image.fromarray((np.clip(data[..., :3] * data[..., 3:4], 0, 1) * 255).astype(np.uint8))
103
+ out = Path(output_dir)
104
+ out.mkdir(parents=True, exist_ok=True)
105
+ rgba_path, rgb_path = out / "condition_rgba.png", out / "condition_rgb.png"
106
+ rgba.save(rgba_path)
107
+ rgb.save(rgb_path)
108
+ return rgba_path, rgb_path, provenance + ("_precropped" if already_cropped else "")
109
+
110
+
111
+ def main() -> None:
112
+ parser = argparse.ArgumentParser(description="Prepare an Image-to-Material Translation condition image.")
113
+ parser.add_argument("--input", type=Path, required=True)
114
+ parser.add_argument("--output-dir", type=Path, required=True)
115
+ parser.add_argument("--use-input-alpha", action="store_true")
116
+ parser.add_argument("--already-cropped", action="store_true")
117
+ parser.add_argument("--rmbg-model", type=Path, help="Local RMBG-2 model directory for opaque images.")
118
+ parser.add_argument("--device", default="cuda")
119
+ parser.add_argument("--alpha-threshold", type=float)
120
+ args = parser.parse_args()
121
+ estimator = RMBG2(args.rmbg_model, device=args.device, alpha_threshold=args.alpha_threshold) if args.rmbg_model else None
122
+ rgba, rgb, provenance = prepare_image(args.input, output_dir=args.output_dir, use_input_alpha=args.use_input_alpha,
123
+ already_cropped=args.already_cropped, background_model=estimator)
124
+ print(f"condition_rgba={rgba}\ncondition_rgb={rgb}\nforeground_source={provenance}")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
supratex/frontend/qwen_image.py CHANGED
@@ -1,186 +1,180 @@
1
- """Qwen Image 2.1 material adapter inference through its native CLI."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import math
6
- import os
7
- from pathlib import Path
8
- import subprocess
9
- import sys
10
-
11
- from supratex.weights import resolve_model_dir
12
-
13
-
14
- def validate_adapter(checkpoint: Path) -> dict:
15
- """Validate a native adapter config and tensor headers without loading tensors."""
16
- from safetensors import safe_open
17
-
18
- checkpoint = Path(checkpoint)
19
- config = json.loads(checkpoint.with_name("adapter_config.json").read_text())
20
- if (not isinstance(config, dict) or config.get("format") != "supratex_qwen_material_adapter"
21
- or type(config.get("schema_version")) is not int or config["schema_version"] != 1
22
- or config.get("adapter_type") != "qwen_image_21_pbr_endpoint"
23
- or config.get("base_model_name_or_path") != "Qwen/Qwen-Image-2.1"
24
- or config.get("weight_file") != checkpoint.name
25
- or type(config.get("source_step")) is not int or config["source_step"] != 100000):
26
- raise ValueError("unsupported Qwen material adapter config")
27
- inference = config.get("inference_config")
28
- booleans = {"lora_output_projection", "omit_all_valid_mask", "bf16_autocast", "deterministic_algorithms"}
29
- required = booleans | {"resolution", "noise_seed", "lora_rank", "lora_alpha", "attention_backend"}
30
- if not isinstance(inference, dict) or set(inference) != required:
31
- raise ValueError("Qwen adapter requires complete inference config")
32
- if (type(inference["resolution"]) is not int or inference["resolution"] != 1024
33
- or type(inference["noise_seed"]) is not int or inference["noise_seed"] != 42
34
- or inference["attention_backend"] != "torch"
35
- or type(inference["lora_rank"]) is not int or inference["lora_rank"] <= 0
36
- or type(inference["lora_alpha"]) not in {int, float}
37
- or not math.isfinite(inference["lora_alpha"]) or inference["lora_alpha"] <= 0
38
- or any(type(inference[key]) is not bool for key in booleans)):
39
- raise ValueError("invalid Qwen adapter inference config")
40
- tensors = config.get("tensors")
41
- if not isinstance(tensors, dict) or not tensors:
42
- raise ValueError("Qwen adapter requires a nonempty tensor manifest")
43
- with safe_open(str(checkpoint), framework="pt", device="cpu") as reader:
44
- metadata = reader.metadata() or {}
45
- if (metadata.get("format") != "pt" or metadata.get("adapter_format") != config["format"]
46
- or metadata.get("schema_version") != str(config["schema_version"])
47
- or set(reader.keys()) != set(tensors)):
48
- raise ValueError("Qwen adapter tensor keys or metadata mismatch")
49
- for name, specification in tensors.items():
50
- header = reader.get_slice(name)
51
- if (not isinstance(specification, dict)
52
- or not isinstance(specification.get("shape"), list)
53
- or any(type(size) is not int or size < 0 for size in specification["shape"])
54
- or specification["shape"] != header.get_shape()
55
- or specification.get("dtype") not in {"F64", "F32", "F16", "BF16"}
56
- or specification["dtype"] != header.get_dtype()):
57
- raise ValueError(f"Qwen adapter tensor shape or dtype mismatch: {name}")
58
- return config
59
-
60
-
61
- def validate(runtime: Path, base_model: str | Path, model_source: str | Path, *,
62
- deps: Path | None = None, cache_dir: Path | None = None,
63
- revision: str | None = None, local_files_only: bool = False
64
- ) -> tuple[Path, Path, Path, Path | None]:
65
- """Check local artifacts without importing the runtime or loading tensors."""
66
- runtime = Path(runtime).expanduser().resolve()
67
- for name in ("infer.py", "train.py", "validate_local.py", "encode_cache.py"):
68
- path = runtime / "examples/qwen_image_21/pbr" / name
69
- if not path.is_file():
70
- raise FileNotFoundError(f"Qwen native runtime is incomplete: {path}")
71
- if not (runtime / "diffsynth").is_dir():
72
- raise FileNotFoundError(runtime / "diffsynth")
73
- if deps is not None:
74
- deps = Path(deps).expanduser().resolve()
75
- if not deps.is_dir():
76
- raise NotADirectoryError(deps)
77
- root = resolve_model_dir(model_source, revision=revision, cache_dir=cache_dir,
78
- local_files_only=local_files_only,
79
- allow_patterns=["adapter_model.safetensors", "adapter_config.json",
80
- "checkpoint-0100000.pt", "checkpoint-0100000.complete.json"])
81
- checkpoint = root / "adapter_model.safetensors"
82
- if checkpoint.exists() or (root / "adapter_config.json").exists():
83
- validate_adapter(checkpoint)
84
- else:
85
- checkpoint = root / "checkpoint-0100000.pt"
86
- marker = json.loads(checkpoint.with_suffix(".complete.json").read_text())
87
- if (marker.get("status") != "complete" or marker.get("step") != 100000
88
- or marker.get("bytes") != checkpoint.stat().st_size or checkpoint.stat().st_size == 0):
89
- raise ValueError("Qwen original checkpoint requires a matching complete record at step 100000")
90
- base = resolve_model_dir(base_model, cache_dir=cache_dir, local_files_only=local_files_only)
91
- for name in ("preprocessor_config.json", "tokenizer_config.json", "tokenizer.json", "chat_template.jinja"):
92
- if not (base / "processor" / name).is_file():
93
- raise FileNotFoundError(base / "processor" / name)
94
- for component in ("vae", "text_encoder", "transformer"):
95
- directory = base / component
96
- if not (directory / "config.json").is_file():
97
- raise FileNotFoundError(directory / "config.json")
98
- weights = list(directory.glob("*.safetensors"))
99
- if not weights or any(path.stat().st_size == 0 for path in weights):
100
- raise ValueError(f"Qwen base component has missing or empty weights: {directory}")
101
- for index in directory.glob("*.safetensors.index.json"):
102
- shards = set(json.loads(index.read_text()).get("weight_map", {}).values())
103
- if not shards or any(not (directory / name).is_file() for name in shards):
104
- raise ValueError(f"Qwen base component has missing indexed shards: {index}")
105
- return runtime, base, checkpoint, deps
106
-
107
-
108
- def run(runtime: Path, base_model: str | Path, model_source: str | Path,
109
- condition: Path, output: Path, *, deps: Path | None = None,
110
- cache_dir: Path | None = None, revision: str | None = None,
111
- local_files_only: bool = False, device: str = "cuda:0", resident=None) -> tuple[Path, Path]:
112
- """Preserve the frontend canvas and translate native MR into completion MRA."""
113
- from PIL import Image, ImageChops
114
-
115
- # The native CLI calls torch.cuda.set_device(), which requires an index.
116
- device = "cuda:0" if device == "cuda" else device
117
- output = Path(output).resolve()
118
- targets = [output / name for name in ("native", "input_rgba.png", "inference.log",
119
- "albedo.png", "mra.png", "qwen_receipt.json")]
120
- if any(path.exists() for path in targets):
121
- raise FileExistsError("Qwen material outputs already exist; select a fresh output directory")
122
- runtime, base, checkpoint, deps = validate(
123
- runtime, base_model, model_source, deps=deps, cache_dir=cache_dir,
124
- revision=revision, local_files_only=local_files_only)
125
- condition = Path(condition).resolve()
126
- with Image.open(condition) as image:
127
- if image.mode != "RGBA" or image.width != image.height:
128
- raise ValueError("Qwen condition must be the frontend's square straight RGBA canvas")
129
- source_size = image.size
130
- square = image.copy() if image.size == (1024, 1024) else image.resize((1024, 1024), Image.Resampling.LANCZOS)
131
- output.mkdir(parents=True, exist_ok=True)
132
- input_path, native = output / "input_rgba.png", output / "native"
133
- square.save(input_path)
134
- env = os.environ.copy()
135
- env.update(PYTHONPATH=os.pathsep.join(str(path) for path in (runtime, deps) if path is not None),
136
- PYTHONNOUSERSITE="1", PYTHONDONTWRITEBYTECODE="1", PYTHONUNBUFFERED="1",
137
- HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1", TOKENIZERS_PARALLELISM="false")
138
- command = [sys.executable, str(runtime / "examples/qwen_image_21/pbr/infer.py"),
139
- "--checkpoint", str(checkpoint), "--model-root", str(base),
140
- "--input", str(input_path), "--output-dir", str(native), "--device", device]
141
- if resident is not None:
142
- import torch
143
- deterministic = torch.are_deterministic_algorithms_enabled()
144
- warn_only = torch.is_deterministic_algorithms_warn_only_enabled()
145
- try:
146
- resident.run_material(input_path, native, device=device)
147
- finally:
148
- torch.use_deterministic_algorithms(deterministic, warn_only=warn_only)
149
- else:
150
- with (output / "inference.log").open("w", encoding="utf-8") as log:
151
- subprocess.run(command, check=True, env=env, stdout=log, stderr=subprocess.STDOUT, cwd=runtime)
152
- report = json.loads((native / "REPORT.json").read_text())
153
- config = report.get("config", {})
154
- if (report.get("status") != "complete" or report.get("checkpoint_step") != 100000
155
- or config.get("resolution") != 1024 or config.get("noise_seed") != 42
156
- or config.get("attention_backend") != "torch"):
157
- raise ValueError("Qwen native report does not match the original 100k inference contract")
158
- coverage = square.getchannel("A")
159
- maps = []
160
- for name in ("albedo", "mra"):
161
- with Image.open(native / f"{name}.png") as image:
162
- if image.mode != "RGBA" or image.size != (1024, 1024):
163
- raise ValueError(f"Qwen native {name} must be RGBA 1024x1024")
164
- if ImageChops.difference(image.getchannel("A"), coverage).getbbox() is not None:
165
- raise ValueError(f"Qwen native {name} coverage differs from its input")
166
- if name == "mra":
167
- if image.getchannel("B").getextrema() != (255, 255):
168
- raise ValueError("Qwen native MR.B must be the constant 255 placeholder")
169
- maps.append(Image.merge("RGB", (image.getchannel("R"), image.getchannel("G"), coverage)))
170
- else:
171
- maps.append(image.convert("RGB"))
172
- albedo, mra = output / "albedo.png", output / "mra.png"
173
- for image, path in zip(maps, (albedo, mra)):
174
- image.save(path)
175
- receipt = {
176
- "backend": "qwen-image", "checkpoint": str(checkpoint), "checkpoint_step": 100000,
177
- "base_model": str(base), "runtime": str(runtime), "condition": str(condition),
178
- "source_size": list(source_size), "canvas_size": [1024, 1024],
179
- "preprocessing": "square canvas resized once with LANCZOS; straight RGBA; no crop or compositing",
180
- "native_report": str(native / "REPORT.json"),
181
- "albedo": "sRGB; no alpha premultiplication",
182
- "mra": "R=linear metallic, G=linear roughness, B=input coverage; no predicted material transparency",
183
- "noise_seed": 42, "attention_backend": "torch", "status": "complete",
184
- }
185
- (output / "qwen_receipt.json").write_text(json.dumps(receipt, indent=2) + "\n")
186
- return albedo, mra
 
1
+ """Qwen Image 2.1 material adapter inference through its native CLI."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ import os
7
+ from pathlib import Path
8
+ import subprocess
9
+ import sys
10
+
11
+ from supratex.weights import resolve_model_dir
12
+
13
+
14
+ def validate_adapter(checkpoint: Path) -> dict:
15
+ """Validate a native adapter config and tensor headers without loading tensors."""
16
+ from safetensors import safe_open
17
+
18
+ checkpoint = Path(checkpoint)
19
+ config = json.loads(checkpoint.with_name("adapter_config.json").read_text())
20
+ if (not isinstance(config, dict) or config.get("format") != "supratex_qwen_material_adapter"
21
+ or type(config.get("schema_version")) is not int or config["schema_version"] != 1
22
+ or config.get("adapter_type") != "qwen_image_21_pbr_endpoint"
23
+ or config.get("base_model_name_or_path") != "Qwen/Qwen-Image-2.1"
24
+ or config.get("weight_file") != checkpoint.name
25
+ or type(config.get("source_step")) is not int or config["source_step"] != 100000):
26
+ raise ValueError("unsupported Qwen material adapter config")
27
+ inference = config.get("inference_config")
28
+ booleans = {"lora_output_projection", "omit_all_valid_mask", "bf16_autocast", "deterministic_algorithms"}
29
+ required = booleans | {"resolution", "noise_seed", "lora_rank", "lora_alpha", "attention_backend"}
30
+ if not isinstance(inference, dict) or set(inference) != required:
31
+ raise ValueError("Qwen adapter requires complete inference config")
32
+ if (type(inference["resolution"]) is not int or inference["resolution"] != 1024
33
+ or type(inference["noise_seed"]) is not int or inference["noise_seed"] != 42
34
+ or inference["attention_backend"] != "torch"
35
+ or type(inference["lora_rank"]) is not int or inference["lora_rank"] <= 0
36
+ or type(inference["lora_alpha"]) not in {int, float}
37
+ or not math.isfinite(inference["lora_alpha"]) or inference["lora_alpha"] <= 0
38
+ or any(type(inference[key]) is not bool for key in booleans)):
39
+ raise ValueError("invalid Qwen adapter inference config")
40
+ tensors = config.get("tensors")
41
+ if not isinstance(tensors, dict) or not tensors:
42
+ raise ValueError("Qwen adapter requires a nonempty tensor manifest")
43
+ with safe_open(str(checkpoint), framework="pt", device="cpu") as reader:
44
+ metadata = reader.metadata() or {}
45
+ if (metadata.get("format") != "pt" or metadata.get("adapter_format") != config["format"]
46
+ or metadata.get("schema_version") != str(config["schema_version"])
47
+ or set(reader.keys()) != set(tensors)):
48
+ raise ValueError("Qwen adapter tensor keys or metadata mismatch")
49
+ for name, specification in tensors.items():
50
+ header = reader.get_slice(name)
51
+ if (not isinstance(specification, dict)
52
+ or not isinstance(specification.get("shape"), list)
53
+ or any(type(size) is not int or size < 0 for size in specification["shape"])
54
+ or specification["shape"] != header.get_shape()
55
+ or specification.get("dtype") not in {"F64", "F32", "F16", "BF16"}
56
+ or specification["dtype"] != header.get_dtype()):
57
+ raise ValueError(f"Qwen adapter tensor shape or dtype mismatch: {name}")
58
+ return config
59
+
60
+
61
+ def validate(runtime: Path, base_model: str | Path, model_source: str | Path, *,
62
+ deps: Path | None = None, cache_dir: Path | None = None,
63
+ revision: str | None = None, local_files_only: bool = False
64
+ ) -> tuple[Path, Path, Path, Path | None]:
65
+ """Check local artifacts without importing the runtime or loading tensors."""
66
+ runtime = Path(runtime).expanduser().resolve()
67
+ for name in ("infer.py", "inference_utils.py", "inference_offload.py"):
68
+ path = runtime / "examples/qwen_image_21/pbr" / name
69
+ if not path.is_file():
70
+ raise FileNotFoundError(f"Qwen native runtime is incomplete: {path}")
71
+ if not (runtime / "diffsynth").is_dir():
72
+ raise FileNotFoundError(runtime / "diffsynth")
73
+ if deps is not None:
74
+ deps = Path(deps).expanduser().resolve()
75
+ if not deps.is_dir():
76
+ raise NotADirectoryError(deps)
77
+ root = resolve_model_dir(model_source, revision=revision, cache_dir=cache_dir,
78
+ local_files_only=local_files_only,
79
+ allow_patterns=["adapter_model.safetensors", "adapter_config.json",
80
+ "checkpoint-0100000.pt", "checkpoint-0100000.complete.json"])
81
+ checkpoint = root / "adapter_model.safetensors"
82
+ if checkpoint.exists() or (root / "adapter_config.json").exists():
83
+ validate_adapter(checkpoint)
84
+ else:
85
+ checkpoint = root / "checkpoint-0100000.pt"
86
+ marker = json.loads(checkpoint.with_suffix(".complete.json").read_text())
87
+ if (marker.get("status") != "complete" or marker.get("step") != 100000
88
+ or marker.get("bytes") != checkpoint.stat().st_size or checkpoint.stat().st_size == 0):
89
+ raise ValueError("Qwen original checkpoint requires a matching complete record at step 100000")
90
+ base = resolve_model_dir(base_model, cache_dir=cache_dir, local_files_only=local_files_only)
91
+ for name in ("preprocessor_config.json", "tokenizer_config.json", "tokenizer.json", "chat_template.jinja"):
92
+ if not (base / "processor" / name).is_file():
93
+ raise FileNotFoundError(base / "processor" / name)
94
+ for component in ("vae", "text_encoder", "transformer"):
95
+ directory = base / component
96
+ if not (directory / "config.json").is_file():
97
+ raise FileNotFoundError(directory / "config.json")
98
+ weights = list(directory.glob("*.safetensors"))
99
+ if not weights or any(path.stat().st_size == 0 for path in weights):
100
+ raise ValueError(f"Qwen base component has missing or empty weights: {directory}")
101
+ for index in directory.glob("*.safetensors.index.json"):
102
+ shards = set(json.loads(index.read_text()).get("weight_map", {}).values())
103
+ if not shards or any(not (directory / name).is_file() for name in shards):
104
+ raise ValueError(f"Qwen base component has missing indexed shards: {index}")
105
+ return runtime, base, checkpoint, deps
106
+
107
+
108
+ def run(runtime: Path, base_model: str | Path, model_source: str | Path,
109
+ condition: Path, output: Path, *, deps: Path | None = None,
110
+ cache_dir: Path | None = None, revision: str | None = None,
111
+ local_files_only: bool = False, device: str = "cuda:0",
112
+ cuda_visible_devices: str | None = None) -> tuple[Path, Path]:
113
+ """Preserve the frontend canvas and translate native MR into completion MRA."""
114
+ from PIL import Image, ImageChops
115
+
116
+ # The native CLI calls torch.cuda.set_device(), which requires an index.
117
+ device = "cuda:0" if device == "cuda" else device
118
+ output = Path(output).resolve()
119
+ targets = [output / name for name in ("native", "input_rgba.png", "inference.log",
120
+ "albedo.png", "mra.png", "qwen_receipt.json")]
121
+ if any(path.exists() for path in targets):
122
+ raise FileExistsError("Qwen material outputs already exist; select a fresh output directory")
123
+ runtime, base, checkpoint, deps = validate(
124
+ runtime, base_model, model_source, deps=deps, cache_dir=cache_dir,
125
+ revision=revision, local_files_only=local_files_only)
126
+ condition = Path(condition).resolve()
127
+ with Image.open(condition) as image:
128
+ if image.mode != "RGBA" or image.width != image.height:
129
+ raise ValueError("Qwen condition must be the frontend's square straight RGBA canvas")
130
+ source_size = image.size
131
+ square = image.copy() if image.size == (1024, 1024) else image.resize((1024, 1024), Image.Resampling.LANCZOS)
132
+ output.mkdir(parents=True, exist_ok=True)
133
+ input_path, native = output / "input_rgba.png", output / "native"
134
+ square.save(input_path)
135
+ env = os.environ.copy()
136
+ env.update(PYTHONPATH=os.pathsep.join(str(path) for path in (runtime, deps) if path is not None),
137
+ PYTHONNOUSERSITE="1", PYTHONDONTWRITEBYTECODE="1", PYTHONUNBUFFERED="1",
138
+ HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1", TOKENIZERS_PARALLELISM="false")
139
+ if cuda_visible_devices is not None:
140
+ env["CUDA_VISIBLE_DEVICES"] = cuda_visible_devices
141
+ command = [sys.executable, str(runtime / "examples/qwen_image_21/pbr/infer.py"),
142
+ "--checkpoint", str(checkpoint), "--model-root", str(base),
143
+ "--input", str(input_path), "--output-dir", str(native), "--device", device]
144
+ with (output / "inference.log").open("w", encoding="utf-8") as log:
145
+ subprocess.run(command, check=True, env=env, stdout=log, stderr=subprocess.STDOUT, cwd=runtime)
146
+ report = json.loads((native / "REPORT.json").read_text())
147
+ config = report.get("config", {})
148
+ if (report.get("status") != "complete" or report.get("checkpoint_step") != 100000
149
+ or config.get("resolution") != 1024 or config.get("noise_seed") != 42
150
+ or config.get("attention_backend") != "torch"):
151
+ raise ValueError("Qwen native report does not match the original 100k inference contract")
152
+ coverage = square.getchannel("A")
153
+ maps = []
154
+ for name in ("albedo", "mra"):
155
+ with Image.open(native / f"{name}.png") as image:
156
+ if image.mode != "RGBA" or image.size != (1024, 1024):
157
+ raise ValueError(f"Qwen native {name} must be RGBA 1024x1024")
158
+ if ImageChops.difference(image.getchannel("A"), coverage).getbbox() is not None:
159
+ raise ValueError(f"Qwen native {name} coverage differs from its input")
160
+ if name == "mra":
161
+ if image.getchannel("B").getextrema() != (255, 255):
162
+ raise ValueError("Qwen native MR.B must be the constant 255 placeholder")
163
+ maps.append(Image.merge("RGB", (image.getchannel("R"), image.getchannel("G"), coverage)))
164
+ else:
165
+ maps.append(image.convert("RGB"))
166
+ albedo, mra = output / "albedo.png", output / "mra.png"
167
+ for image, path in zip(maps, (albedo, mra)):
168
+ image.save(path)
169
+ receipt = {
170
+ "backend": "qwen-image", "checkpoint": str(checkpoint), "checkpoint_step": 100000,
171
+ "base_model": str(base), "runtime": str(runtime), "condition": str(condition),
172
+ "source_size": list(source_size), "canvas_size": [1024, 1024],
173
+ "preprocessing": "square canvas resized once with LANCZOS; straight RGBA; no crop or compositing",
174
+ "native_report": str(native / "REPORT.json"),
175
+ "albedo": "sRGB; no alpha premultiplication",
176
+ "mra": "R=linear metallic, G=linear roughness, B=input coverage; no predicted material transparency",
177
+ "noise_seed": 42, "attention_backend": "torch", "status": "complete",
178
+ }
179
+ (output / "qwen_receipt.json").write_text(json.dumps(receipt, indent=2) + "\n")
180
+ return albedo, mra
 
 
 
 
 
 
supratex/inference/_kernel_config_h800.py CHANGED
The diff for this file is too large to render. See raw diff
 
supratex/inference/geometry_cleanup.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional removal of floating geometry that the reference image does not show.
2
+
3
+ Generated geometry can contain disconnected pieces outside the object in the
4
+ image. A piece is removed when it is not the largest connected component and
5
+ most of its faces project outside the image foreground, which is dilated to
6
+ tolerate ordinary misalignment. Parts hidden behind the foreground, such as
7
+ the back of the object, still project inside it and are kept, as are parts of
8
+ any size that the image shows. Extra geometry connected to the object is not
9
+ affected.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from typing import Any, Mapping
15
+
16
+ import numpy as np
17
+ import torch
18
+
19
+ FOREGROUND_ALPHA = 128
20
+ # Misalignment tolerance as a fraction of the image width (16 px at 1024).
21
+ MARGIN = 16 / 1024
22
+ MIN_INSIDE = 0.5
23
+ # Vertices closer than this are welded, so UV-seam duplicates stay connected.
24
+ WELD_STEP = 1e-6
25
+ KEY_BITS = 21
26
+
27
+
28
+ def _weld(vertices: torch.Tensor) -> torch.Tensor:
29
+ """Map each vertex to the index of its quantized position."""
30
+ low = vertices.amin(0)
31
+ step = max(WELD_STEP, float((vertices.amax(0) - low).max()) / (2 ** KEY_BITS - 1))
32
+ q = torch.round((vertices.double() - low) / step).long()
33
+ key = (q[:, 0] << (2 * KEY_BITS)) | (q[:, 1] << KEY_BITS) | q[:, 2]
34
+ return torch.unique(key, return_inverse=True)[1]
35
+
36
+
37
+ def _components(vertices: torch.Tensor, faces: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
38
+ """Label faces by connected component with parallel union-find.
39
+
40
+ Each round hooks every root to the smallest root among its edges, then
41
+ compresses paths by pointer jumping; components halve per round.
42
+ """
43
+ welded = _weld(vertices)[faces]
44
+ count = int(welded.max()) + 1
45
+ first = torch.cat((welded[:, 0], welded[:, 1]))
46
+ second = torch.cat((welded[:, 1], welded[:, 2]))
47
+ parent = torch.arange(count, device=faces.device)
48
+ while True:
49
+ a, b = parent[first], parent[second]
50
+ differ = a != b
51
+ if not bool(differ.any()):
52
+ break
53
+ low, high = torch.minimum(a[differ], b[differ]), torch.maximum(a[differ], b[differ])
54
+ parent.scatter_reduce_(0, high, low, reduce="amin")
55
+ while True:
56
+ jumped = parent[parent]
57
+ if torch.equal(jumped, parent):
58
+ break
59
+ parent = jumped
60
+ first, second = first[differ], second[differ]
61
+ face_labels = torch.unique(parent[welded[:, 0]], return_inverse=True)[1]
62
+ return face_labels, torch.bincount(face_labels)
63
+
64
+
65
+ def _project(points: torch.Tensor, camera: Mapping[str, Any]) -> tuple[torch.Tensor, torch.Tensor]:
66
+ """Normalized image coordinates (y down) and camera depth, matching completion."""
67
+ if "extrinsic" in camera:
68
+ extrinsic = torch.as_tensor(camera["extrinsic"], dtype=points.dtype, device=points.device)
69
+ intrinsic = torch.as_tensor(camera["intrinsic"], dtype=points.dtype, device=points.device)
70
+ local = points @ extrinsic[:3, :3].T + extrinsic[:3, 3]
71
+ uvw = local @ intrinsic.T
72
+ return uvw[:, :2] / uvw[:, 2:3].clamp(min=1e-8), local[:, 2]
73
+ distance = float(camera["distance"]) * float(camera.get("mesh_scale", 1.0))
74
+ focal = 0.5 / math.tan(float(camera["camera_angle_x"]) / 2)
75
+ depth = distance - points[:, 2]
76
+ safe = depth.clamp(min=1e-8)
77
+ return torch.stack((points[:, 0] / safe * focal + 0.5, -points[:, 1] / safe * focal + 0.5), dim=1), depth
78
+
79
+
80
+ def _foreground(alpha: np.ndarray) -> np.ndarray:
81
+ """Foreground mask dilated by the misalignment margin."""
82
+ from scipy.ndimage import distance_transform_edt
83
+
84
+ radius = max(1, round(MARGIN * alpha.shape[1]))
85
+ return distance_transform_edt(alpha < FOREGROUND_ALPHA) <= radius
86
+
87
+
88
+ def remove_floaters(vertices: torch.Tensor, faces: torch.Tensor, camera: Mapping[str, Any],
89
+ alpha: np.ndarray, device: str | None = None) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]:
90
+ """Drop disconnected components that project mostly outside the image foreground."""
91
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
92
+ source_device = vertices.device
93
+ vertices, faces = vertices.to(device), faces.to(device).long()
94
+ face_labels, counts = _components(vertices, faces)
95
+ report = {"components": int(len(counts)), "removed_components": 0, "removed_faces": 0, "faces": int(len(faces))}
96
+ floating = None
97
+ if len(counts) > 1:
98
+ height, width = alpha.shape
99
+ foreground = torch.from_numpy(_foreground(alpha)).to(device)
100
+ uv, depth = _project(vertices[faces].mean(dim=1), camera)
101
+ x = torch.floor(uv[:, 0] * width).long()
102
+ y = torch.floor(uv[:, 1] * height).long()
103
+ valid = (depth > 0) & (x >= 0) & (x < width) & (y >= 0) & (y < height)
104
+ inside = torch.zeros_like(valid)
105
+ inside[valid] = foreground[y[valid], x[valid]]
106
+ inside_count = torch.bincount(face_labels, weights=inside.double(), minlength=len(counts))
107
+ floating = inside_count < MIN_INSIDE * counts
108
+ floating[torch.argmax(counts)] = False
109
+ if floating is None or not bool(floating.any()):
110
+ return vertices.to(source_device), faces.to(source_device), report
111
+ kept_faces = faces[~floating[face_labels]]
112
+ used, remap = torch.unique(kept_faces, return_inverse=True)
113
+ report.update(removed_components=int(floating.sum()), removed_faces=int(counts[floating].sum()))
114
+ return vertices[used].to(source_device), remap.reshape(kept_faces.shape).to(source_device), report
supratex/inference/native_observation.py CHANGED
@@ -1,364 +1,413 @@
1
- """Visible-surface material observations for Supra-Tex inference.
2
-
3
- This module owns the public pixel-first DDA visibility contract. It has no
4
- dataset, scheduler, or training dependency.
5
- """
6
- from __future__ import annotations
7
-
8
- import math
9
- from typing import Any
10
-
11
- import numpy as np
12
- import torch
13
-
14
- RESOLUTION = 1024
15
- LATENT_RESOLUTION = 64
16
-
17
-
18
- def build_occupancy_index(coords: torch.Tensor) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
19
- """Return an exact packed occupancy bitset and sorted row map."""
20
- values = coords.detach().cpu().numpy().astype(np.int64, copy=False)
21
- if values.ndim != 2 or values.shape[1] != 3:
22
- raise ValueError("surface coordinates must have shape [N,3]")
23
- if values.size and (values.min() < 0 or values.max() >= RESOLUTION):
24
- raise ValueError("surface coordinates are outside the material volume")
25
- keys = (values[:, 0] * RESOLUTION + values[:, 1]) * RESOLUTION + values[:, 2]
26
- order = np.argsort(keys)
27
- ordered = keys[order]
28
- if ordered.size > 1 and np.any(ordered[1:] == ordered[:-1]):
29
- raise ValueError("surface coordinates contain duplicate cells")
30
- bits = np.zeros((RESOLUTION ** 3 + 7) // 8, dtype=np.uint8)
31
- if keys.size:
32
- np.bitwise_or.at(bits, keys >> 3, np.left_shift(np.uint8(1), (keys & 7).astype(np.uint8)))
33
- return bits, ordered, order.astype(np.int64, copy=False)
34
-
35
-
36
- _DDA_BATCH_KERNEL: Any | None = None
37
-
38
- def _dda_batch_kernel() -> Any:
39
- """Lazily compile the CPU DDA kernel so mesh-depth mode needs no Numba."""
40
- global _DDA_BATCH_KERNEL
41
- if _DDA_BATCH_KERNEL is not None:
42
- return _DDA_BATCH_KERNEL
43
- try:
44
- import numba
45
- except ImportError as error:
46
- raise RuntimeError("pixel_dda visibility requires numba") from error
47
-
48
- @numba.njit(parallel=True, cache=True)
49
- def kernel(
50
- ray_origin: np.ndarray,
51
- ray_directions: np.ndarray,
52
- occupancy_bits: np.ndarray,
53
- grid_min: float,
54
- grid_max: float,
55
- resolution: int,
56
- ) -> tuple[np.ndarray, np.ndarray]:
57
- ray_count = ray_directions.shape[0]
58
- owners = np.full((ray_count, 3), -1, dtype=np.int32)
59
- reasons = np.full(ray_count, 3, dtype=np.uint8)
60
- # Per-ray count of multi-axis (diagonal) steps. Written only at
61
- # ray_index, so prange stays race-free; summed by the caller.
62
- ties = np.zeros(ray_count, dtype=np.int64)
63
- cell_size = (grid_max - grid_min) / resolution
64
- slab_epsilon = 1e-14
65
- tie_epsilon = 1e-12
66
-
67
- for ray_index in numba.prange(ray_count):
68
- direction = ray_directions[ray_index]
69
- valid_ray = True
70
- for axis in range(3):
71
- if not np.isfinite(ray_origin[axis]) or not np.isfinite(direction[axis]):
72
- valid_ray = False
73
- if not valid_ray:
74
- continue
75
- if (
76
- abs(direction[0]) < slab_epsilon
77
- and abs(direction[1]) < slab_epsilon
78
- and abs(direction[2]) < slab_epsilon
79
- ):
80
- continue
81
-
82
- t_entry = -np.inf
83
- t_exit = np.inf
84
- intersects = True
85
- for axis in range(3):
86
- component = direction[axis]
87
- if abs(component) < slab_epsilon:
88
- if ray_origin[axis] < grid_min or ray_origin[axis] > grid_max:
89
- intersects = False
90
- break
91
- else:
92
- t1 = (grid_min - ray_origin[axis]) / component
93
- t2 = (grid_max - ray_origin[axis]) / component
94
- if t1 > t2:
95
- temporary = t1
96
- t1 = t2
97
- t2 = temporary
98
- if t1 > t_entry:
99
- t_entry = t1
100
- if t2 < t_exit:
101
- t_exit = t2
102
- if not intersects or t_entry > t_exit or t_exit < 0.0:
103
- reasons[ray_index] = 2
104
- continue
105
-
106
- t_start = max(t_entry, 0.0)
107
- # Enter the half-open grid interior deterministically. This avoids
108
- # floor(1024) for a ray entering exactly through the +AABB face.
109
- entry_epsilon = max(1e-12, abs(t_start) * 1e-12)
110
- point = ray_origin + (t_start + entry_epsilon) * direction
111
- current = np.empty(3, dtype=np.int32)
112
- step = np.zeros(3, dtype=np.int32)
113
- t_delta = np.full(3, np.inf, dtype=np.float64)
114
- t_max = np.full(3, np.inf, dtype=np.float64)
115
- for axis in range(3):
116
- cell = int(math.floor((point[axis] - grid_min) / cell_size))
117
- if cell < 0:
118
- cell = 0
119
- elif cell >= resolution:
120
- cell = resolution - 1
121
- current[axis] = cell
122
- component = direction[axis]
123
- if component > slab_epsilon:
124
- step[axis] = 1
125
- boundary = grid_min + (cell + 1) * cell_size
126
- t_max[axis] = (boundary - ray_origin[axis]) / component
127
- t_delta[axis] = cell_size / component
128
- elif component < -slab_epsilon:
129
- step[axis] = -1
130
- boundary = grid_min + cell * cell_size
131
- t_max[axis] = (boundary - ray_origin[axis]) / component
132
- t_delta[axis] = -cell_size / component
133
-
134
- max_steps = 3 * resolution + 3
135
- for _ in range(max_steps):
136
- key = (
137
- (np.int64(current[0]) * resolution + current[1]) * resolution
138
- + current[2]
139
- )
140
- byte_value = occupancy_bits[key >> 3]
141
- if byte_value & (1 << (key & 7)):
142
- owners[ray_index, 0] = current[0]
143
- owners[ray_index, 1] = current[1]
144
- owners[ray_index, 2] = current[2]
145
- reasons[ray_index] = 0
146
- break
147
-
148
- next_t = min(t_max[0], t_max[1], t_max[2])
149
- if not np.isfinite(next_t) or next_t > t_exit + tie_epsilon:
150
- reasons[ray_index] = 1
151
- break
152
- tolerance = tie_epsilon * max(1.0, abs(next_t))
153
- in_bounds = True
154
- # Step all axes crossed at the same ray parameter. This is the
155
- # half-open tie rule for edge/corner hits and is deterministic.
156
- crossed = 0
157
- for axis in range(3):
158
- if abs(t_max[axis] - next_t) <= tolerance:
159
- crossed += 1
160
- current[axis] += step[axis]
161
- t_max[axis] += t_delta[axis]
162
- if current[axis] < 0 or current[axis] >= resolution:
163
- in_bounds = False
164
- # A multi-axis step is a diagonal move, so it can skip cells that
165
- # share only an edge or corner. The rule is frozen and fixture
166
- # verified; count the events so the magnitude is known over a
167
- # full corpus (~2e12 rays) instead of assumed negligible.
168
- if crossed > 1:
169
- ties[ray_index] += 1
170
- if not in_bounds:
171
- reasons[ray_index] = 1
172
- break
173
-
174
- return owners, reasons, ties
175
-
176
- _DDA_BATCH_KERNEL = kernel
177
- return _DDA_BATCH_KERNEL
178
-
179
-
180
- def pixel_first_occupied(camera: dict[str, Any], occupancy: np.ndarray, trusted: torch.Tensor, depth_edge_rtol: float = 0.04) -> torch.Tensor:
181
- """Map trusted camera pixels to their first occupied volume cell."""
182
- if "extrinsic" in camera:
183
- return pixel_first_occupied_cv(camera, occupancy, trusted, depth_edge_rtol)
184
- if trusted.shape != (RESOLUTION, RESOLUTION):
185
- raise ValueError("trusted alpha mask must be 1024 by 1024")
186
- if "camera_angle_x" not in camera or "distance" not in camera:
187
- raise ValueError("camera must supply camera_angle_x and distance")
188
- ys, xs = torch.nonzero(trusted, as_tuple=True)
189
- x, y = xs.cpu().numpy(), ys.cpu().numpy()
190
- focal = 16.0 / math.tan(float(camera["camera_angle_x"]) / 2.0) * RESOLUTION / 32.0
191
- directions = np.column_stack(((x + .5 - RESOLUTION / 2) / focal, -(y + .5 - RESOLUTION / 2) / focal, -np.ones_like(x))).astype(np.float64)
192
- origin = np.array([0.0, 0.0, float(camera["distance"]) * float(camera.get("mesh_scale", 1.0))], dtype=np.float64)
193
- owners, reasons, _ties = _dda_batch_kernel()(origin, directions, occupancy, -0.5, 0.5, RESOLUTION)
194
- hit_mask = reasons == 0
195
- hit = owners[hit_mask]
196
- if not hit.size:
197
- raise RuntimeError("no trusted pixels intersect the material volume")
198
- # Preserve the production first-hit-depth admission gate. It removes
199
- # pixels at local internal-occlusion jumps before their owner cells become
200
- # observations; applying it after unique-owner reduction would be wrong.
201
- import torch.nn.functional as F
202
- depth = np.full((RESOLUTION, RESOLUTION), np.nan, dtype=np.float32)
203
- hit_depth = origin[2] - ((hit[:, 2].astype(np.float64) + .5) / RESOLUTION - .5)
204
- depth[y[hit_mask], x[hit_mask]] = hit_depth.astype(np.float32)
205
- values = torch.from_numpy(depth)
206
- valid = torch.from_numpy(~np.isnan(depth))
207
- masked = torch.where(valid, values, torch.nan)
208
- local_max = F.max_pool2d(masked.nan_to_num(-torch.inf)[None, None], 3, 1, 1)[0, 0]
209
- local_min = -F.max_pool2d((-masked).nan_to_num(-torch.inf)[None, None], 3, 1, 1)[0, 0]
210
- relative = ((local_max - local_min) / masked).nan_to_num(nan=0., posinf=0., neginf=0.)
211
- retained = hit_mask.copy()
212
- retained[hit_mask] &= ~((relative.numpy() > depth_edge_rtol)[y[hit_mask], x[hit_mask]])
213
- hit = owners[retained]
214
- if not hit.size:
215
- raise RuntimeError("depth-edge admission removed every first-hit observation")
216
- keys = np.unique((hit[:, 0].astype(np.int64) * RESOLUTION + hit[:, 1]) * RESOLUTION + hit[:, 2])
217
- return torch.from_numpy(np.stack((keys // (RESOLUTION * RESOLUTION), (keys // RESOLUTION) % RESOLUTION, keys % RESOLUTION), axis=1).astype(np.int32))
218
-
219
-
220
- def camera_rays(camera: dict[str, Any], ys: np.ndarray, xs: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
221
- """Build pixel-center rays from a complete OpenCV world-to-camera pose."""
222
- extrinsic = np.asarray(camera["extrinsic"], dtype=np.float64)
223
- intrinsic = np.asarray(camera.get("projection_intrinsic", camera.get("intrinsic")), dtype=np.float64)
224
- if extrinsic.shape != (4, 4) or intrinsic.shape != (3, 3):
225
- raise ValueError("DDA camera requires 4x4 extrinsic and normalized 3x3 intrinsic")
226
- if not np.isfinite(extrinsic).all() or not np.isfinite(intrinsic).all():
227
- raise ValueError("DDA camera matrices must be finite")
228
- rotation = extrinsic[:3, :3]
229
- if (not np.allclose(rotation @ rotation.T, np.eye(3), atol=1e-6, rtol=0)
230
- or not np.isclose(np.linalg.det(rotation), 1, atol=1e-6)
231
- or not np.allclose(extrinsic[3], [0, 0, 0, 1], atol=1e-8, rtol=0)):
232
- raise ValueError("DDA camera must contain a rigid world-to-camera transform")
233
- if (intrinsic[0, 0] <= 0 or intrinsic[1, 1] <= 0
234
- or not np.allclose(intrinsic[[0, 1, 2, 2], [1, 0, 0, 1]], 0, atol=1e-10)
235
- or intrinsic[2, 2] != 1):
236
- raise ValueError("DDA requires positive focal lengths and an unskewed normalized intrinsic")
237
- eye = np.ascontiguousarray(-rotation.T @ extrinsic[:3, 3])
238
- x = (xs.astype(np.float64) + .5 - intrinsic[0, 2] * RESOLUTION) / (intrinsic[0, 0] * RESOLUTION)
239
- y = (ys.astype(np.float64) + .5 - intrinsic[1, 2] * RESOLUTION) / (intrinsic[1, 1] * RESOLUTION)
240
- directions = np.ascontiguousarray(np.stack((x, y, np.ones_like(x)), axis=1) @ rotation)
241
- return eye, directions, rotation
242
-
243
-
244
- def pixel_hits_cv(camera: dict[str, Any], occupancy: np.ndarray, mask: np.ndarray) -> np.ndarray:
245
- """Return the raw first-hit silhouette, without trusted-mask depth filtering."""
246
- if mask.shape != (RESOLUTION, RESOLUTION) or mask.dtype != np.bool_:
247
- raise ValueError("DDA pixel mask must be a boolean observation-resolution image")
248
- ys, xs = np.nonzero(mask)
249
- eye, directions, _ = camera_rays(camera, ys, xs)
250
- _, reasons, _ = _dda_batch_kernel()(eye, directions, occupancy, -.5, .5, RESOLUTION)
251
- hits = np.zeros_like(mask)
252
- hits[ys[reasons == 0], xs[reasons == 0]] = True
253
- return hits
254
-
255
-
256
- def pixel_first_occupied_cv(camera: dict[str, Any], occupancy: np.ndarray, trusted: torch.Tensor,
257
- depth_edge_rtol: float = .04) -> torch.Tensor:
258
- """Apply the production first-hit and depth-edge rules to a full CV camera."""
259
- import torch.nn.functional as F
260
-
261
- if trusted.shape != (RESOLUTION, RESOLUTION) or trusted.dtype != torch.bool:
262
- raise ValueError("trusted alpha must be a boolean observation-resolution image")
263
- if not math.isfinite(depth_edge_rtol) or depth_edge_rtol < 0:
264
- raise ValueError("depth-edge tolerance must be finite and nonnegative")
265
- ys, xs = np.nonzero(trusted.detach().cpu().numpy())
266
- if not ys.size:
267
- raise RuntimeError("trusted alpha gate leaves no camera rays")
268
- eye, directions, rotation = camera_rays(camera, ys, xs)
269
- owners, reasons, _ = _dda_batch_kernel()(eye, directions, occupancy, -.5, .5, RESOLUTION)
270
- hits = reasons == 0
271
- if not hits.any():
272
- raise RuntimeError("no trusted pixels intersect the material volume")
273
- centers = (owners[hits].astype(np.float64) + .5) / RESOLUTION - .5
274
- hit_depth = ((centers - eye) @ rotation.T)[:, 2]
275
- if not np.isfinite(hit_depth).all() or not (hit_depth > 0).all():
276
- raise RuntimeError("first-hit owners must lie in front of the camera")
277
- depth = np.full((RESOLUTION, RESOLUTION), np.nan, dtype=np.float32)
278
- depth[ys[hits], xs[hits]] = hit_depth.astype(np.float32)
279
- masked = torch.from_numpy(depth)
280
- local_max = F.max_pool2d(masked.nan_to_num(-torch.inf)[None, None], 3, 1, 1)[0, 0]
281
- local_min = -F.max_pool2d((-masked).nan_to_num(-torch.inf)[None, None], 3, 1, 1)[0, 0]
282
- relative = ((local_max - local_min) / masked).nan_to_num(nan=0., posinf=0., neginf=0.)
283
- retained = hits & ~(relative.numpy() > depth_edge_rtol)[ys, xs]
284
- kept = owners[retained].astype(np.int64, copy=False)
285
- if not kept.size:
286
- raise RuntimeError("depth-edge admission removed every first-hit observation")
287
- keys = np.unique((kept[:, 0] * RESOLUTION + kept[:, 1]) * RESOLUTION + kept[:, 2])
288
- return torch.from_numpy(np.stack((keys // (RESOLUTION * RESOLUTION),
289
- keys // RESOLUTION % RESOLUTION,
290
- keys % RESOLUTION), axis=1).astype(np.int32))
291
-
292
-
293
- def owner_rows(coords: torch.Tensor, owners: torch.Tensor, ordered: np.ndarray, order: np.ndarray) -> torch.Tensor:
294
- values = owners.cpu().numpy().astype(np.int64, copy=False)
295
- keys = (values[:, 0] * RESOLUTION + values[:, 1]) * RESOLUTION + values[:, 2]
296
- positions = np.searchsorted(ordered, keys)
297
- if positions.size and (np.any(positions >= ordered.size) or np.any(ordered[positions] != keys)):
298
- raise RuntimeError("visible owner is absent from the surface support")
299
- result = torch.zeros(coords.shape[0], dtype=torch.bool, device=coords.device)
300
- if positions.size:
301
- result[torch.as_tensor(order[positions], device=coords.device)] = True
302
- return result
303
-
304
-
305
- def boundary_distance_to_unobserved(surface_counts: torch.Tensor, observed_counts: torch.Tensor) -> torch.Tensor:
306
- """Exact sparse six-neighbour distance used by the observation message."""
307
- active = (surface_counts > 0).reshape(LATENT_RESOLUTION, LATENT_RESOLUTION, LATENT_RESOLUTION)
308
- observed = (observed_counts > 0).reshape_as(active)
309
- distance = torch.full(active.shape, -1, dtype=torch.int16, device=active.device)
310
- frontier = active & ~observed
311
- distance[frontier] = 0
312
- step = 0
313
- while bool(frontier.any()):
314
- expanded = torch.zeros_like(frontier)
315
- expanded[1:] |= frontier[:-1]; expanded[:-1] |= frontier[1:]
316
- expanded[:, 1:] |= frontier[:, :-1]; expanded[:, :-1] |= frontier[:, 1:]
317
- expanded[:, :, 1:] |= frontier[:, :, :-1]; expanded[:, :, :-1] |= frontier[:, :, 1:]
318
- frontier = expanded & active & (distance < 0)
319
- if not bool(frontier.any()):
320
- break
321
- step += 1
322
- distance[frontier] = step
323
- return distance.reshape(-1)
324
-
325
-
326
- def encode_partial(encoder: Any, coords: torch.Tensor, visible: torch.Tensor, rgb: torch.Tensor, mra: torch.Tensor, shape_coords: torch.Tensor, normalization: dict[str, Any], sparse: Any, device: str) -> Any:
327
- """Encode visible PBR rows into the frozen 34-channel observation message."""
328
- selected = coords[visible]
329
- features = torch.cat((rgb[visible], mra[visible]), dim=1).clamp(0, 1).float() * 2.0 - 1.0
330
- encoded = encoder(sparse.SparseTensor(features, torch.cat((torch.zeros_like(selected[:, :1]), selected), dim=1).int()), sample_posterior=False)
331
- raw = encoded.feats.float()
332
- if raw.ndim != 2 or raw.shape[1] != 32:
333
- raise RuntimeError("material encoder must emit 32 channels")
334
- blocks = coords // (RESOLUTION // LATENT_RESOLUTION)
335
- keys = (blocks[:, 0] * LATENT_RESOLUTION + blocks[:, 1]) * LATENT_RESOLUTION + blocks[:, 2]
336
- all_counts = torch.bincount(keys, minlength=LATENT_RESOLUTION ** 3)
337
- observed_counts = torch.bincount(keys[visible], minlength=LATENT_RESOLUTION ** 3)
338
- xyz = encoded.coords[:, 1:].long()
339
- latent_keys = (xyz[:, 0] * LATENT_RESOLUTION + xyz[:, 1]) * LATENT_RESOLUTION + xyz[:, 2]
340
- # The bounded grid already has exact occupancy counts. Enumerate occupied
341
- # keys instead of sorting millions of three-column surface coordinates.
342
- # Linear-key order is the same lexicographic order as unique(..., dim=0).
343
- expected_observed = torch.nonzero(observed_counts, as_tuple=False).flatten()
344
- if (bool(((xyz < 0) | (xyz >= LATENT_RESOLUTION)).any())
345
- or not torch.equal(torch.sort(latent_keys).values, expected_observed)):
346
- raise RuntimeError("encoded observation support differs from floor(visible_surface/16)")
347
- coverage = (observed_counts[latent_keys].float() / all_counts[latent_keys].float()).unsqueeze(1)
348
- expected_keys = torch.nonzero(all_counts, as_tuple=False).flatten()
349
- shape_xyz = shape_coords.long()
350
- shape_keys = (shape_xyz[:, 0] * LATENT_RESOLUTION + shape_xyz[:, 1]) * LATENT_RESOLUTION + shape_xyz[:, 2]
351
- sorted_shape = torch.sort(shape_keys).values
352
- if expected_keys.shape[0] != shape_keys.shape[0] or not torch.equal(expected_keys, sorted_shape):
353
- raise RuntimeError("pooled material support does not exactly match the shape latent support")
354
- positions = torch.searchsorted(sorted_shape, latent_keys)
355
- if bool((positions >= sorted_shape.shape[0]).any()) or not torch.equal(sorted_shape[positions], latent_keys):
356
- raise RuntimeError("encoded observation support is not a subset of full material support")
357
- boundary = boundary_distance_to_unobserved(all_counts, observed_counts)[latent_keys].to(dtype=coverage.dtype).unsqueeze(1)
358
- if bool((boundary == 0).any()) or bool((boundary < -1).any()):
359
- raise RuntimeError("invalid observed material boundary distance")
360
- mean = torch.as_tensor(normalization["mean"], dtype=raw.dtype, device=device).reshape(1, 32)
361
- std = torch.as_tensor(normalization["std"], dtype=raw.dtype, device=device).reshape(1, 32)
362
- if mean.shape[1] != 32 or std.shape[1] != 32 or not bool(torch.isfinite(std).all()) or not bool((std > 0).all()):
363
- raise ValueError("partial observation normalization must provide 32 finite positive standard deviations")
364
- return sparse.SparseTensor(torch.cat(((raw - mean) / std, coverage, boundary), dim=1), encoded.coords.int())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Visible-surface material observations for Supra-Tex inference.
2
+
3
+ This module owns the public pixel-first DDA visibility contract. It has no
4
+ dataset, scheduler, or training dependency.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+ import torch
13
+
14
+ RESOLUTION = 1024
15
+ LATENT_RESOLUTION = 64
16
+
17
+
18
+ def build_occupancy_index(coords: torch.Tensor) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
19
+ """Return an exact packed occupancy bitset and sorted row map."""
20
+ values = coords.detach().cpu().numpy().astype(np.int64, copy=False)
21
+ if values.ndim != 2 or values.shape[1] != 3:
22
+ raise ValueError("surface coordinates must have shape [N,3]")
23
+ if values.size and (values.min() < 0 or values.max() >= RESOLUTION):
24
+ raise ValueError("surface coordinates are outside the material volume")
25
+ keys = (values[:, 0] * RESOLUTION + values[:, 1]) * RESOLUTION + values[:, 2]
26
+ order = np.argsort(keys)
27
+ ordered = keys[order]
28
+ if ordered.size > 1 and np.any(ordered[1:] == ordered[:-1]):
29
+ raise ValueError("surface coordinates contain duplicate cells")
30
+ bits = np.zeros((RESOLUTION ** 3 + 7) // 8, dtype=np.uint8)
31
+ if keys.size:
32
+ np.bitwise_or.at(bits, keys >> 3, np.left_shift(np.uint8(1), (keys & 7).astype(np.uint8)))
33
+ return bits, ordered, order.astype(np.int64, copy=False)
34
+
35
+
36
+ _DDA_BATCH_KERNEL: Any | None = None
37
+
38
+ def _dda_batch_kernel() -> Any:
39
+ """Lazily compile the CPU DDA kernel so mesh-depth mode needs no Numba."""
40
+ global _DDA_BATCH_KERNEL
41
+ if _DDA_BATCH_KERNEL is not None:
42
+ return _DDA_BATCH_KERNEL
43
+ try:
44
+ import numba
45
+ except ImportError as error:
46
+ raise RuntimeError("pixel_dda visibility requires numba") from error
47
+
48
+ @numba.njit(parallel=True, cache=True)
49
+ def kernel(
50
+ ray_origin: np.ndarray,
51
+ ray_directions: np.ndarray,
52
+ occupancy_bits: np.ndarray,
53
+ grid_min: float,
54
+ grid_max: float,
55
+ resolution: int,
56
+ ) -> tuple[np.ndarray, np.ndarray]:
57
+ ray_count = ray_directions.shape[0]
58
+ owners = np.full((ray_count, 3), -1, dtype=np.int32)
59
+ reasons = np.full(ray_count, 3, dtype=np.uint8)
60
+ # Per-ray count of multi-axis (diagonal) steps. Written only at
61
+ # ray_index, so prange stays race-free; summed by the caller.
62
+ ties = np.zeros(ray_count, dtype=np.int64)
63
+ cell_size = (grid_max - grid_min) / resolution
64
+ slab_epsilon = 1e-14
65
+ tie_epsilon = 1e-12
66
+
67
+ for ray_index in numba.prange(ray_count):
68
+ direction = ray_directions[ray_index]
69
+ valid_ray = True
70
+ for axis in range(3):
71
+ if not np.isfinite(ray_origin[axis]) or not np.isfinite(direction[axis]):
72
+ valid_ray = False
73
+ if not valid_ray:
74
+ continue
75
+ if (
76
+ abs(direction[0]) < slab_epsilon
77
+ and abs(direction[1]) < slab_epsilon
78
+ and abs(direction[2]) < slab_epsilon
79
+ ):
80
+ continue
81
+
82
+ t_entry = -np.inf
83
+ t_exit = np.inf
84
+ intersects = True
85
+ for axis in range(3):
86
+ component = direction[axis]
87
+ if abs(component) < slab_epsilon:
88
+ if ray_origin[axis] < grid_min or ray_origin[axis] > grid_max:
89
+ intersects = False
90
+ break
91
+ else:
92
+ t1 = (grid_min - ray_origin[axis]) / component
93
+ t2 = (grid_max - ray_origin[axis]) / component
94
+ if t1 > t2:
95
+ temporary = t1
96
+ t1 = t2
97
+ t2 = temporary
98
+ if t1 > t_entry:
99
+ t_entry = t1
100
+ if t2 < t_exit:
101
+ t_exit = t2
102
+ if not intersects or t_entry > t_exit or t_exit < 0.0:
103
+ reasons[ray_index] = 2
104
+ continue
105
+
106
+ t_start = max(t_entry, 0.0)
107
+ # Enter the half-open grid interior deterministically. This avoids
108
+ # floor(1024) for a ray entering exactly through the +AABB face.
109
+ entry_epsilon = max(1e-12, abs(t_start) * 1e-12)
110
+ point = ray_origin + (t_start + entry_epsilon) * direction
111
+ current = np.empty(3, dtype=np.int32)
112
+ step = np.zeros(3, dtype=np.int32)
113
+ t_delta = np.full(3, np.inf, dtype=np.float64)
114
+ t_max = np.full(3, np.inf, dtype=np.float64)
115
+ for axis in range(3):
116
+ cell = int(math.floor((point[axis] - grid_min) / cell_size))
117
+ if cell < 0:
118
+ cell = 0
119
+ elif cell >= resolution:
120
+ cell = resolution - 1
121
+ current[axis] = cell
122
+ component = direction[axis]
123
+ if component > slab_epsilon:
124
+ step[axis] = 1
125
+ boundary = grid_min + (cell + 1) * cell_size
126
+ t_max[axis] = (boundary - ray_origin[axis]) / component
127
+ t_delta[axis] = cell_size / component
128
+ elif component < -slab_epsilon:
129
+ step[axis] = -1
130
+ boundary = grid_min + cell * cell_size
131
+ t_max[axis] = (boundary - ray_origin[axis]) / component
132
+ t_delta[axis] = -cell_size / component
133
+
134
+ max_steps = 3 * resolution + 3
135
+ for _ in range(max_steps):
136
+ key = (
137
+ (np.int64(current[0]) * resolution + current[1]) * resolution
138
+ + current[2]
139
+ )
140
+ byte_value = occupancy_bits[key >> 3]
141
+ if byte_value & (1 << (key & 7)):
142
+ owners[ray_index, 0] = current[0]
143
+ owners[ray_index, 1] = current[1]
144
+ owners[ray_index, 2] = current[2]
145
+ reasons[ray_index] = 0
146
+ break
147
+
148
+ next_t = min(t_max[0], t_max[1], t_max[2])
149
+ if not np.isfinite(next_t) or next_t > t_exit + tie_epsilon:
150
+ reasons[ray_index] = 1
151
+ break
152
+ tolerance = tie_epsilon * max(1.0, abs(next_t))
153
+ in_bounds = True
154
+ # Step all axes crossed at the same ray parameter. This is the
155
+ # half-open tie rule for edge/corner hits and is deterministic.
156
+ crossed = 0
157
+ for axis in range(3):
158
+ if abs(t_max[axis] - next_t) <= tolerance:
159
+ crossed += 1
160
+ current[axis] += step[axis]
161
+ t_max[axis] += t_delta[axis]
162
+ if current[axis] < 0 or current[axis] >= resolution:
163
+ in_bounds = False
164
+ # A multi-axis step is a diagonal move, so it can skip cells that
165
+ # share only an edge or corner. The rule is frozen and fixture
166
+ # verified; count the events so the magnitude is known over a
167
+ # full corpus (~2e12 rays) instead of assumed negligible.
168
+ if crossed > 1:
169
+ ties[ray_index] += 1
170
+ if not in_bounds:
171
+ reasons[ray_index] = 1
172
+ break
173
+
174
+ return owners, reasons, ties
175
+
176
+ _DDA_BATCH_KERNEL = kernel
177
+ return _DDA_BATCH_KERNEL
178
+
179
+
180
+ # Optional admission rules, off by default. On well-aligned demo inputs they
181
+ # removed 3-10% of trusted pixels without a visible change, so the released
182
+ # behavior keeps only the depth-discontinuity rule.
183
+ SILHOUETTE_MARGIN = 0
184
+ GRAZING_COS = 0.0
185
+
186
+
187
+ def _admitted_pixels(depth: np.ndarray, missed: np.ndarray, focal: tuple[float, float], depth_edge_rtol: float,
188
+ silhouette_margin: int = SILHOUETTE_MARGIN, grazing_cos: float = GRAZING_COS,
189
+ stats: dict | None = None) -> np.ndarray:
190
+ """Return pixels whose first-hit depth is a reliable observation.
191
+
192
+ ``depth`` is the first-hit view depth (NaN without a trusted hit) and
193
+ ``missed`` marks trusted rays that left the volume, i.e. the image
194
+ foreground outside the geometry silhouette. A local depth discontinuity
195
+ always rejects a pixel. Two optional rules can also reject pixels near the
196
+ geometry silhouette (``silhouette_margin`` pixels), where image/geometry
197
+ misalignment lands colors on the wrong surface, and at grazing incidence
198
+ (cosine below ``grazing_cos``, from the screen-space depth slope), where one
199
+ pixel stretches over a large surface area.
200
+ """
201
+ import torch.nn.functional as F
202
+
203
+ masked = torch.from_numpy(depth)
204
+ local_max = F.max_pool2d(masked.nan_to_num(-torch.inf)[None, None], 3, 1, 1)[0, 0]
205
+ local_min = -F.max_pool2d((-masked).nan_to_num(-torch.inf)[None, None], 3, 1, 1)[0, 0]
206
+ relative = ((local_max - local_min) / masked).nan_to_num(nan=0., posinf=0., neginf=0.)
207
+ edge = relative.numpy() > depth_edge_rtol
208
+ near_silhouette = np.zeros_like(edge)
209
+ if silhouette_margin > 0 and missed.any():
210
+ size = 2 * silhouette_margin + 1
211
+ near_silhouette = F.max_pool2d(torch.from_numpy(missed).float()[None, None], size, 1, silhouette_margin)[0, 0].numpy() > 0
212
+ grazing = np.zeros_like(edge)
213
+ if grazing_cos > 0:
214
+ # Central differences in pixels; a missing neighbour leaves NaN and is
215
+ # not rejected here. Dividing by the pixel footprint (depth / focal)
216
+ # turns the depth change into the surface slope along each image axis.
217
+ dz_dx = np.full_like(depth, np.nan)
218
+ dz_dy = np.full_like(depth, np.nan)
219
+ dz_dx[:, 1:-1] = (depth[:, 2:] - depth[:, :-2]) * .5
220
+ dz_dy[1:-1, :] = (depth[2:, :] - depth[:-2, :]) * .5
221
+ with np.errstate(invalid="ignore", divide="ignore"):
222
+ slope2 = (dz_dx * np.float32(focal[0]) / depth) ** 2 + (dz_dy * np.float32(focal[1]) / depth) ** 2
223
+ grazing = slope2 > np.float32(1 / grazing_cos ** 2 - 1)
224
+ hit = ~np.isnan(depth)
225
+ admitted = hit & ~edge & ~near_silhouette & ~grazing
226
+ if stats is not None:
227
+ stats.update(trusted_rays=int(hit.sum() + missed.sum()), first_hits=int(hit.sum()),
228
+ rejected_depth_edge=int((hit & edge).sum()),
229
+ rejected_silhouette=int((hit & ~edge & near_silhouette).sum()),
230
+ rejected_grazing=int((hit & ~edge & ~near_silhouette & grazing).sum()),
231
+ admitted=int(admitted.sum()))
232
+ return admitted
233
+
234
+
235
+ def pixel_first_occupied(camera: dict[str, Any], occupancy: np.ndarray, trusted: torch.Tensor, depth_edge_rtol: float = 0.04,
236
+ stats: dict | None = None) -> torch.Tensor:
237
+ """Map trusted camera pixels to their first occupied volume cell."""
238
+ if "extrinsic" in camera:
239
+ return pixel_first_occupied_cv(camera, occupancy, trusted, depth_edge_rtol, stats=stats)
240
+ if trusted.shape != (RESOLUTION, RESOLUTION):
241
+ raise ValueError("trusted alpha mask must be 1024 by 1024")
242
+ if "camera_angle_x" not in camera or "distance" not in camera:
243
+ raise ValueError("camera must supply camera_angle_x and distance")
244
+ ys, xs = torch.nonzero(trusted, as_tuple=True)
245
+ x, y = xs.cpu().numpy(), ys.cpu().numpy()
246
+ focal = 16.0 / math.tan(float(camera["camera_angle_x"]) / 2.0) * RESOLUTION / 32.0
247
+ directions = np.column_stack(((x + .5 - RESOLUTION / 2) / focal, -(y + .5 - RESOLUTION / 2) / focal, -np.ones_like(x))).astype(np.float64)
248
+ origin = np.array([0.0, 0.0, float(camera["distance"]) * float(camera.get("mesh_scale", 1.0))], dtype=np.float64)
249
+ owners, reasons, _ties = _dda_batch_kernel()(origin, directions, occupancy, -0.5, 0.5, RESOLUTION)
250
+ hit_mask = reasons == 0
251
+ hit = owners[hit_mask]
252
+ if not hit.size:
253
+ raise RuntimeError("no trusted pixels intersect the material volume")
254
+ # Admission runs per pixel, before owner cells are deduplicated; applying
255
+ # it after unique-owner reduction would be wrong.
256
+ depth = np.full((RESOLUTION, RESOLUTION), np.nan, dtype=np.float32)
257
+ hit_depth = origin[2] - ((hit[:, 2].astype(np.float64) + .5) / RESOLUTION - .5)
258
+ depth[y[hit_mask], x[hit_mask]] = hit_depth.astype(np.float32)
259
+ missed = np.zeros((RESOLUTION, RESOLUTION), dtype=bool)
260
+ missed[y[~hit_mask], x[~hit_mask]] = True
261
+ admitted = _admitted_pixels(depth, missed, (focal, focal), depth_edge_rtol, stats=stats)
262
+ retained = hit_mask.copy()
263
+ retained[hit_mask] &= admitted[y[hit_mask], x[hit_mask]]
264
+ hit = owners[retained]
265
+ if not hit.size:
266
+ raise RuntimeError("observation admission removed every first-hit observation")
267
+ keys = np.unique((hit[:, 0].astype(np.int64) * RESOLUTION + hit[:, 1]) * RESOLUTION + hit[:, 2])
268
+ return torch.from_numpy(np.stack((keys // (RESOLUTION * RESOLUTION), (keys // RESOLUTION) % RESOLUTION, keys % RESOLUTION), axis=1).astype(np.int32))
269
+
270
+
271
+ def camera_rays(camera: dict[str, Any], ys: np.ndarray, xs: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
272
+ """Build pixel-center rays from a complete OpenCV world-to-camera pose."""
273
+ extrinsic = np.asarray(camera["extrinsic"], dtype=np.float64)
274
+ intrinsic = np.asarray(camera.get("projection_intrinsic", camera.get("intrinsic")), dtype=np.float64)
275
+ if extrinsic.shape != (4, 4) or intrinsic.shape != (3, 3):
276
+ raise ValueError("DDA camera requires 4x4 extrinsic and normalized 3x3 intrinsic")
277
+ if not np.isfinite(extrinsic).all() or not np.isfinite(intrinsic).all():
278
+ raise ValueError("DDA camera matrices must be finite")
279
+ rotation = extrinsic[:3, :3]
280
+ if (not np.allclose(rotation @ rotation.T, np.eye(3), atol=1e-6, rtol=0)
281
+ or not np.isclose(np.linalg.det(rotation), 1, atol=1e-6)
282
+ or not np.allclose(extrinsic[3], [0, 0, 0, 1], atol=1e-8, rtol=0)):
283
+ raise ValueError("DDA camera must contain a rigid world-to-camera transform")
284
+ if (intrinsic[0, 0] <= 0 or intrinsic[1, 1] <= 0
285
+ or not np.allclose(intrinsic[[0, 1, 2, 2], [1, 0, 0, 1]], 0, atol=1e-10)
286
+ or intrinsic[2, 2] != 1):
287
+ raise ValueError("DDA requires positive focal lengths and an unskewed normalized intrinsic")
288
+ eye = np.ascontiguousarray(-rotation.T @ extrinsic[:3, 3])
289
+ x = (xs.astype(np.float64) + .5 - intrinsic[0, 2] * RESOLUTION) / (intrinsic[0, 0] * RESOLUTION)
290
+ y = (ys.astype(np.float64) + .5 - intrinsic[1, 2] * RESOLUTION) / (intrinsic[1, 1] * RESOLUTION)
291
+ directions = np.ascontiguousarray(np.stack((x, y, np.ones_like(x)), axis=1) @ rotation)
292
+ return eye, directions, rotation
293
+
294
+
295
+ def pixel_hits_cv(camera: dict[str, Any], occupancy: np.ndarray, mask: np.ndarray) -> np.ndarray:
296
+ """Return the raw first-hit silhouette, without trusted-mask depth filtering."""
297
+ if mask.shape != (RESOLUTION, RESOLUTION) or mask.dtype != np.bool_:
298
+ raise ValueError("DDA pixel mask must be a boolean observation-resolution image")
299
+ ys, xs = np.nonzero(mask)
300
+ eye, directions, _ = camera_rays(camera, ys, xs)
301
+ _, reasons, _ = _dda_batch_kernel()(eye, directions, occupancy, -.5, .5, RESOLUTION)
302
+ hits = np.zeros_like(mask)
303
+ hits[ys[reasons == 0], xs[reasons == 0]] = True
304
+ return hits
305
+
306
+
307
+ def pixel_first_occupied_cv(camera: dict[str, Any], occupancy: np.ndarray, trusted: torch.Tensor,
308
+ depth_edge_rtol: float = .04, stats: dict | None = None) -> torch.Tensor:
309
+ """Apply the production first-hit admission rules to a full CV camera."""
310
+ if trusted.shape != (RESOLUTION, RESOLUTION) or trusted.dtype != torch.bool:
311
+ raise ValueError("trusted alpha must be a boolean observation-resolution image")
312
+ if not math.isfinite(depth_edge_rtol) or depth_edge_rtol < 0:
313
+ raise ValueError("depth-edge tolerance must be finite and nonnegative")
314
+ ys, xs = np.nonzero(trusted.detach().cpu().numpy())
315
+ if not ys.size:
316
+ raise RuntimeError("trusted alpha gate leaves no camera rays")
317
+ eye, directions, rotation = camera_rays(camera, ys, xs)
318
+ owners, reasons, _ = _dda_batch_kernel()(eye, directions, occupancy, -.5, .5, RESOLUTION)
319
+ hits = reasons == 0
320
+ if not hits.any():
321
+ raise RuntimeError("no trusted pixels intersect the material volume")
322
+ centers = (owners[hits].astype(np.float64) + .5) / RESOLUTION - .5
323
+ hit_depth = ((centers - eye) @ rotation.T)[:, 2]
324
+ if not np.isfinite(hit_depth).all() or not (hit_depth > 0).all():
325
+ raise RuntimeError("first-hit owners must lie in front of the camera")
326
+ depth = np.full((RESOLUTION, RESOLUTION), np.nan, dtype=np.float32)
327
+ depth[ys[hits], xs[hits]] = hit_depth.astype(np.float32)
328
+ missed = np.zeros((RESOLUTION, RESOLUTION), dtype=bool)
329
+ missed[ys[~hits], xs[~hits]] = True
330
+ intrinsic = np.asarray(camera.get("projection_intrinsic", camera.get("intrinsic")), dtype=np.float64)
331
+ focal = (float(intrinsic[0, 0]) * RESOLUTION, float(intrinsic[1, 1]) * RESOLUTION)
332
+ retained = hits & _admitted_pixels(depth, missed, focal, depth_edge_rtol, stats=stats)[ys, xs]
333
+ kept = owners[retained].astype(np.int64, copy=False)
334
+ if not kept.size:
335
+ raise RuntimeError("observation admission removed every first-hit observation")
336
+ keys = np.unique((kept[:, 0] * RESOLUTION + kept[:, 1]) * RESOLUTION + kept[:, 2])
337
+ return torch.from_numpy(np.stack((keys // (RESOLUTION * RESOLUTION),
338
+ keys // RESOLUTION % RESOLUTION,
339
+ keys % RESOLUTION), axis=1).astype(np.int32))
340
+
341
+
342
+ def owner_rows(coords: torch.Tensor, owners: torch.Tensor, ordered: np.ndarray, order: np.ndarray) -> torch.Tensor:
343
+ values = owners.cpu().numpy().astype(np.int64, copy=False)
344
+ keys = (values[:, 0] * RESOLUTION + values[:, 1]) * RESOLUTION + values[:, 2]
345
+ positions = np.searchsorted(ordered, keys)
346
+ if positions.size and (np.any(positions >= ordered.size) or np.any(ordered[positions] != keys)):
347
+ raise RuntimeError("visible owner is absent from the surface support")
348
+ result = torch.zeros(coords.shape[0], dtype=torch.bool, device=coords.device)
349
+ if positions.size:
350
+ result[torch.as_tensor(order[positions], device=coords.device)] = True
351
+ return result
352
+
353
+
354
+ def boundary_distance_to_unobserved(surface_counts: torch.Tensor, observed_counts: torch.Tensor) -> torch.Tensor:
355
+ """Exact sparse six-neighbour distance used by the observation message."""
356
+ active = (surface_counts > 0).reshape(LATENT_RESOLUTION, LATENT_RESOLUTION, LATENT_RESOLUTION)
357
+ observed = (observed_counts > 0).reshape_as(active)
358
+ distance = torch.full(active.shape, -1, dtype=torch.int16, device=active.device)
359
+ frontier = active & ~observed
360
+ distance[frontier] = 0
361
+ step = 0
362
+ while bool(frontier.any()):
363
+ expanded = torch.zeros_like(frontier)
364
+ expanded[1:] |= frontier[:-1]; expanded[:-1] |= frontier[1:]
365
+ expanded[:, 1:] |= frontier[:, :-1]; expanded[:, :-1] |= frontier[:, 1:]
366
+ expanded[:, :, 1:] |= frontier[:, :, :-1]; expanded[:, :, :-1] |= frontier[:, :, 1:]
367
+ frontier = expanded & active & (distance < 0)
368
+ if not bool(frontier.any()):
369
+ break
370
+ step += 1
371
+ distance[frontier] = step
372
+ return distance.reshape(-1)
373
+
374
+
375
+ def encode_partial(encoder: Any, coords: torch.Tensor, visible: torch.Tensor, rgb: torch.Tensor, mra: torch.Tensor, shape_coords: torch.Tensor, normalization: dict[str, Any], sparse: Any, device: str) -> Any:
376
+ """Encode visible PBR rows into the frozen 34-channel observation message."""
377
+ selected = coords[visible]
378
+ features = torch.cat((rgb[visible], mra[visible]), dim=1).clamp(0, 1).float() * 2.0 - 1.0
379
+ encoded = encoder(sparse.SparseTensor(features, torch.cat((torch.zeros_like(selected[:, :1]), selected), dim=1).int()), sample_posterior=False)
380
+ raw = encoded.feats.float()
381
+ if raw.ndim != 2 or raw.shape[1] != 32:
382
+ raise RuntimeError("material encoder must emit 32 channels")
383
+ blocks = coords // (RESOLUTION // LATENT_RESOLUTION)
384
+ keys = (blocks[:, 0] * LATENT_RESOLUTION + blocks[:, 1]) * LATENT_RESOLUTION + blocks[:, 2]
385
+ all_counts = torch.bincount(keys, minlength=LATENT_RESOLUTION ** 3)
386
+ observed_counts = torch.bincount(keys[visible], minlength=LATENT_RESOLUTION ** 3)
387
+ xyz = encoded.coords[:, 1:].long()
388
+ latent_keys = (xyz[:, 0] * LATENT_RESOLUTION + xyz[:, 1]) * LATENT_RESOLUTION + xyz[:, 2]
389
+ # The bounded grid already has exact occupancy counts. Enumerate occupied
390
+ # keys instead of sorting millions of three-column surface coordinates.
391
+ # Linear-key order is the same lexicographic order as unique(..., dim=0).
392
+ expected_observed = torch.nonzero(observed_counts, as_tuple=False).flatten()
393
+ if (bool(((xyz < 0) | (xyz >= LATENT_RESOLUTION)).any())
394
+ or not torch.equal(torch.sort(latent_keys).values, expected_observed)):
395
+ raise RuntimeError("encoded observation support differs from floor(visible_surface/16)")
396
+ coverage = (observed_counts[latent_keys].float() / all_counts[latent_keys].float()).unsqueeze(1)
397
+ expected_keys = torch.nonzero(all_counts, as_tuple=False).flatten()
398
+ shape_xyz = shape_coords.long()
399
+ shape_keys = (shape_xyz[:, 0] * LATENT_RESOLUTION + shape_xyz[:, 1]) * LATENT_RESOLUTION + shape_xyz[:, 2]
400
+ sorted_shape = torch.sort(shape_keys).values
401
+ if expected_keys.shape[0] != shape_keys.shape[0] or not torch.equal(expected_keys, sorted_shape):
402
+ raise RuntimeError("pooled material support does not exactly match the shape latent support")
403
+ positions = torch.searchsorted(sorted_shape, latent_keys)
404
+ if bool((positions >= sorted_shape.shape[0]).any()) or not torch.equal(sorted_shape[positions], latent_keys):
405
+ raise RuntimeError("encoded observation support is not a subset of full material support")
406
+ boundary = boundary_distance_to_unobserved(all_counts, observed_counts)[latent_keys].to(dtype=coverage.dtype).unsqueeze(1)
407
+ if bool((boundary == 0).any()) or bool((boundary < -1).any()):
408
+ raise RuntimeError("invalid observed material boundary distance")
409
+ mean = torch.as_tensor(normalization["mean"], dtype=raw.dtype, device=device).reshape(1, 32)
410
+ std = torch.as_tensor(normalization["std"], dtype=raw.dtype, device=device).reshape(1, 32)
411
+ if mean.shape[1] != 32 or std.shape[1] != 32 or not bool(torch.isfinite(std).all()) or not bool((std > 0).all()):
412
+ raise ValueError("partial observation normalization must provide 32 finite positive standard deviations")
413
+ return sparse.SparseTensor(torch.cat(((raw - mean) / std, coverage, boundary), dim=1), encoded.coords.int())
supratex/inference/pipeline.py CHANGED
@@ -1,552 +1,700 @@
1
- """Observation-guided PBR completion and GLB export.
2
-
3
- The public boundary begins with a mesh/camera/RGBA observation plus albedo and
4
- MRA maps. Geometry prediction and 2D map prediction intentionally remain
5
- outside this inference-only package.
6
- """
7
- from __future__ import annotations
8
-
9
- import json
10
- import math
11
- import os
12
- from contextlib import contextmanager
13
- from pathlib import Path
14
- from typing import Any, Mapping
15
-
16
- from hf_space.timing import span
17
-
18
- from . import native_observation
19
- from .checkpoint import load_completion_state_dict, validate_completion_checkpoint
20
-
21
- RESOLUTION = native_observation.RESOLUTION
22
- LAYOUT = {"base_color": slice(0, 3), "metallic": slice(3, 4), "roughness": slice(4, 5), "alpha": slice(5, 6)}
23
-
24
-
25
- def _file(path: str | Path, label: str) -> Path:
26
- value = Path(path)
27
- if not value.is_file():
28
- raise FileNotFoundError(f"missing {label}: {value}")
29
- return value
30
-
31
-
32
- def _normalization(value: Any, label: str) -> dict[str, list[float]]:
33
- if not isinstance(value, Mapping):
34
- raise ValueError(f"{label} normalization must be an object")
35
- result: dict[str, list[float]] = {}
36
- for key in ("mean", "std"):
37
- raw = value.get(key)
38
- if not isinstance(raw, list) or len(raw) != 32:
39
- raise ValueError(f"{label} normalization requires 32-value {key}")
40
- result[key] = [float(item) for item in raw]
41
- if not all(math.isfinite(item) for item in result["mean"] + result["std"]) or any(item <= 0 for item in result["std"]):
42
- raise ValueError(f"{label} normalization standard deviations must be positive")
43
- return result
44
-
45
-
46
- def load_contract(model_config: str | Path, checkpoint: str | Path) -> tuple[dict[str, Any], dict[str, Any], Path]:
47
- """Load the public model ABI without inspecting or hashing weight bytes."""
48
- config_path, checkpoint_path = _file(model_config, "model config"), _file(checkpoint, "checkpoint")
49
- try:
50
- config = json.loads(config_path.read_text(encoding="utf-8"))
51
- dataset = config["dataset"]["args"]
52
- model = config["models"]["denoiser"]
53
- args = model["args"]
54
- except (OSError, KeyError, TypeError, json.JSONDecodeError) as error:
55
- raise ValueError("model config does not satisfy the Supra-Tex inference ABI") from error
56
- if config.get("format") == "supratex_completion" and config.get("schema_version") != 1:
57
- raise ValueError("unsupported Supra-Tex completion config schema_version")
58
- if model.get("name") != "InterleavedFlowTransformer":
59
- raise ValueError("model config must select InterleavedFlowTransformer")
60
- if int(dataset.get("resolution", -1)) != RESOLUTION or dataset.get("attrs") != ["base_color", "metallic", "roughness", "alpha"]:
61
- raise ValueError("model config must describe 1024 base-color/metallic/roughness/alpha inputs")
62
- if (args.get("in_channels"), args.get("out_channels")) != (64, 32) or args.get("cond_channels") != 0:
63
- raise ValueError("model config has an incompatible material-flow shell")
64
- if args.get("num_blocks") != 60 or args.get("base_block_policy") != "alternating_u_x":
65
- raise ValueError("model config has an incompatible interleaved-flow policy")
66
- if args.get("rope_freq") not in ([1, 10000], (1, 10000)) or args.get("dtype") != "float32":
67
- raise ValueError("model config has incompatible positional or numeric settings")
68
- if dataset.get("bridge_z_obs_args", {}).get("partial_support_mode") != "partial_messages":
69
- raise ValueError("model config requires partial observation messages")
70
- _normalization(dataset.get("shape_slat_normalization"), "shape")
71
- material = _normalization(dataset.get("pbr_slat_normalization"), "material")
72
- for key in ("pbr_mean", "pbr_std"):
73
- expected = material["mean" if key.endswith("mean") else "std"]
74
- if [float(value) for value in args.get(key, [])] != expected:
75
- raise ValueError(f"model {key} must exactly match material normalization")
76
- return config, dataset, checkpoint_path
77
-
78
-
79
- def _stem(path: str | Path, label: str) -> Path:
80
- stem = Path(path)
81
- _file(Path(f"{stem}.json"), f"{label} config")
82
- _file(Path(f"{stem}.safetensors"), f"{label} weights")
83
- return stem
84
-
85
-
86
- def load_completion_contract(model_config: str | Path, checkpoint: str | Path) -> tuple[dict[str, Any], dict[str, Any], Path, dict[str, list[float]]]:
87
- """Validate the model ABI and observation statistics without loading weights."""
88
- config, dataset, checkpoint_path = load_contract(model_config, checkpoint)
89
- validate_completion_checkpoint(checkpoint_path)
90
- path = Path(dataset["bridge_z_obs_args"]["partial_z_obs_normalization_path"])
91
- if not path.is_absolute():
92
- path = Path(model_config).expanduser().absolute().parent / path
93
- partial_stats = _normalization(json.loads(_file(path, "partial normalization").read_text(encoding="utf-8")), "partial observation")
94
- model_args = config["models"]["denoiser"]["args"]
95
- for key in ("partial_mean", "partial_std"):
96
- expected = partial_stats["mean" if key.endswith("mean") else "std"]
97
- if [float(value) for value in model_args.get(key, [])] != expected:
98
- raise ValueError(f"model {key} must exactly match partial-observation normalization")
99
- return config, dataset, checkpoint_path, partial_stats
100
-
101
-
102
- def _completion_vae_root(trellis_root: str | Path) -> Path:
103
- root = Path(trellis_root).expanduser()
104
- return root / "ckpts" if (root / "ckpts").is_dir() else root
105
-
106
-
107
- def completion_decoder_stems(config: Mapping[str, Any], dataset: Mapping[str, Any],
108
- model_config: str | Path, trellis_root: str | Path) -> dict[str, Path]:
109
- """Resolve release VAE dependencies or preserve legacy config-relative paths."""
110
- base = Path(model_config).expanduser().absolute().parent
111
- standalone = config.get("format") == "supratex_completion"
112
- if standalone:
113
- base = _completion_vae_root(trellis_root)
114
- stems = {}
115
- for key in ("pretrained_shape_slat_dec", "pretrained_pbr_slat_dec"):
116
- value = dataset.get(key)
117
- if not isinstance(value, str) or not value:
118
- raise ValueError(f"model config requires {key}")
119
- path = Path(value).expanduser()
120
- if standalone and (path.name != value or value in (".", "..") or "\\" in value):
121
- raise ValueError(f"standalone completion {key} must be a bare sparse VAE stem")
122
- stems[key] = path if path.is_absolute() else base / path
123
- return stems
124
-
125
-
126
- def validate_completion_artifacts(model_config: str | Path, checkpoint: str | Path,
127
- trellis_root: str | Path) -> tuple[dict[str, Any], dict[str, Any], Path, dict[str, list[float]]]:
128
- """Check completion metadata, safe-file headers, and all VAE files before GPU work."""
129
- contract = load_completion_contract(model_config, checkpoint)
130
- config, dataset, _, _ = contract
131
- ckpts = _completion_vae_root(trellis_root)
132
- stems = [ckpts / "shape_enc_next_dc_f16c32_fp16", ckpts / "tex_enc_next_dc_f16c32_fp16"]
133
- stems.extend(completion_decoder_stems(config, dataset, model_config, trellis_root).values())
134
- for stem in stems:
135
- _stem(stem, "completion artifact")
136
- return contract
137
-
138
-
139
- def _load_case(input_dir: str | Path, torch: Any, image: Any) -> dict[str, Any]:
140
- root = Path(input_dir)
141
- required = {name: _file(root / name, name) for name in ("geometry.pt", "camera.json", "condition_rgba.png", "albedo.png", "mra.png")}
142
- geometry = torch.load(required["geometry.pt"], map_location="cpu", weights_only=True)
143
- try:
144
- vertices, faces = geometry["verts"].float().contiguous(), geometry["faces"].long().contiguous()
145
- except (KeyError, AttributeError) as error:
146
- raise ValueError("geometry.pt must contain tensor keys verts and faces") from error
147
- if vertices.ndim != 2 or vertices.shape[1] != 3 or faces.ndim != 2 or faces.shape[1] != 3:
148
- raise ValueError("geometry.pt must contain verts[N,3] and faces[M,3]")
149
- rgba = image.open(required["condition_rgba.png"]).convert("RGBA")
150
- albedo, mra = image.open(required["albedo.png"]).convert("RGB"), image.open(required["mra.png"]).convert("RGB")
151
- if rgba.size[0] != rgba.size[1]:
152
- raise ValueError("condition_rgba.png must be square")
153
- if albedo.size != (RESOLUTION, RESOLUTION) or mra.size != (RESOLUTION, RESOLUTION):
154
- raise ValueError("albedo.png and mra.png must be 1024 by 1024 RGB maps")
155
- try:
156
- camera = json.loads(required["camera.json"].read_text(encoding="utf-8"))
157
- except json.JSONDecodeError as error:
158
- raise ValueError("camera.json must be valid JSON") from error
159
- if "camera_angle_x" not in camera or "distance" not in camera:
160
- raise ValueError("camera.json must provide canonical camera_angle_x and distance")
161
- return {"vertices": vertices, "faces": faces, "rgba": rgba, "albedo": albedo, "mra": mra, "camera": camera}
162
-
163
-
164
- def _runtime() -> dict[str, Any]:
165
- from .runtime import configure_inference_runtime
166
- configure_inference_runtime()
167
- from .kernel_config import configure_sparse_kernels
168
- kernel_config = configure_sparse_kernels()
169
- if kernel_config["enabled"]:
170
- print(f"Sparse kernels: {kernel_config['preset']}", flush=True)
171
- import numpy as np
172
- import o_voxel
173
- import torch
174
- import torch.nn.functional as F
175
- import utils3d
176
- from PIL import Image, ImageFilter
177
- from supratex import models
178
- from supratex.modules import sparse
179
- from supratex.representations import MeshWithVoxel
180
- from .mesh_export import to_glb
181
- return locals()
182
-
183
-
184
- def _camera(spec: Mapping[str, Any], device: str, torch: Any, utils3d: Any) -> tuple[Any, Any]:
185
- if "extrinsic" in spec and ("projection_intrinsic" in spec or "intrinsic" in spec):
186
- intrinsic = spec.get("projection_intrinsic", spec.get("intrinsic"))
187
- external, projection = torch.as_tensor(spec["extrinsic"], dtype=torch.float32, device=device), torch.as_tensor(intrinsic, dtype=torch.float32, device=device)
188
- if external.shape != (4, 4) or projection.shape != (3, 3): raise ValueError("camera matrices must be 4x4 and 3x3")
189
- return external, projection
190
- if "distance" not in spec or "camera_angle_x" not in spec: raise ValueError("camera must supply distance and camera_angle_x")
191
- eye = torch.tensor([0., 0., float(spec["distance"]) * float(spec.get("mesh_scale", 1.0))], device=device)
192
- return utils3d.torch.extrinsics_look_at(eye, torch.zeros(3, device=device), torch.tensor([0., 1., 0.], device=device)), utils3d.torch.intrinsics_from_fov_xy(torch.tensor(float(spec["camera_angle_x"]), device=device), torch.tensor(float(spec["camera_angle_x"]), device=device))
193
-
194
-
195
- def _shape(vertices: Any, faces: Any, encoder: Any, runtime: Mapping[str, Any], device: str) -> tuple[Any, Any]:
196
- torch, o_voxel, sparse = runtime["torch"], runtime["o_voxel"], runtime["sparse"]
197
- with span("voxelization"):
198
- indices, dual_vertices, intersected = o_voxel.convert.mesh_to_flexible_dual_grid(vertices=vertices.float().cpu().contiguous(), faces=faces.long().cpu().contiguous(), grid_size=RESOLUTION, aabb=[[-.5, -.5, -.5], [.5, .5, .5]], face_weight=1., boundary_weight=.2, regularization_weight=1e-2, timing=False)
199
- offsets = (dual_vertices.float() * RESOLUTION - indices.float()).clamp(0, 1)
200
- coords = torch.cat((torch.zeros_like(indices[:, :1]), indices), dim=1).int().to(device)
201
- vertices_sparse = sparse.SparseTensor(offsets.to(device), coords)
202
- with span("shape_vae"):
203
- with torch.no_grad(): latent = encoder(vertices_sparse, vertices_sparse.replace(intersected.bool().to(device)))
204
- return latent, sparse.SparseTensor(torch.zeros(coords.shape[0], 6, device=device), coords)
205
-
206
-
207
- def _partial(surface: Any, shape: Any, case: Mapping[str, Any], external: Any, projection: Any, encoder: Any, normalization: Mapping[str, Any], runtime: Mapping[str, Any], device: str) -> Any:
208
- torch, F, np, image, image_filter, sparse = runtime["torch"], runtime["F"], runtime["np"], runtime["Image"], runtime["ImageFilter"], runtime["sparse"]
209
- coords = surface.coords[:, 1:].long(); points = (coords.float() + .5) / RESOLUTION - .5
210
- camera = (torch.cat((points, torch.ones_like(points[:, :1])), dim=1) @ external.T)[:, :3]
211
- uvw = camera @ projection.T; uv = uvw[:, :2] / uvw[:, 2:3].clamp(min=1e-8)
212
- grid = torch.stack((uv[:, 0] * 2 - 1, uv[:, 1] * 2 - 1), dim=1).view(1, 1, -1, 2)
213
- def sampled(source: Any) -> Any:
214
- data = np.asarray(source.convert("RGB").resize((RESOLUTION, RESOLUTION), image.Resampling.LANCZOS), dtype=np.float32).copy() / 255.
215
- return F.grid_sample(torch.from_numpy(data).permute(2, 0, 1).to(device)[None], grid, mode="bilinear", align_corners=False, padding_mode="border")[0, :, 0].T.clamp(0, 1)
216
- alpha = np.asarray(case["rgba"].getchannel("A"), dtype=np.uint8)
217
- mask = image.fromarray(np.where(alpha >= 254, 255, 0).astype(np.uint8)).resize((RESOLUTION, RESOLUTION), image.Resampling.NEAREST).filter(image_filter.MinFilter(5))
218
- trusted = torch.from_numpy(np.asarray(mask, dtype=np.uint8).copy() == 255)
219
- bits, ordered, order = native_observation.build_occupancy_index(coords)
220
- owners = native_observation.pixel_first_occupied(dict(case["camera"]), bits, trusted)
221
- visible = native_observation.owner_rows(coords, owners, ordered, order)
222
- if not bool(visible.any()): raise RuntimeError("the trusted alpha gate leaves no observed material surface")
223
- with torch.no_grad():
224
- return native_observation.encode_partial(encoder, coords, visible, sampled(case["albedo"]), sampled(case["mra"]), shape.coords[:, 1:], dict(normalization), sparse, device)
225
-
226
-
227
- def _export(output: Path, representation: Any, texture_size: int, decimation_target: int, texture_format: str, runtime: Mapping[str, Any], *, gutter: bool = False, low_vram: bool = False) -> None:
228
- with span("export_prepare"):
229
- if output.suffix.lower() != ".glb": raise ValueError("output must have a .glb suffix")
230
- output.parent.mkdir(parents=True, exist_ok=True)
231
- lock = output.with_name(f".{output.name}.publish.lock")
232
- try:
233
- handle = lock.open("x")
234
- except FileExistsError as error:
235
- raise FileExistsError(f"another process is publishing {output}") from error
236
- handle.close()
237
- if output.exists():
238
- lock.unlink(missing_ok=True)
239
- raise FileExistsError(f"refusing to overwrite existing output: {output}")
240
- temporary = output.with_name(f".{output.stem}.tmp.{os.getpid()}.glb")
241
- try:
242
- scene = runtime["to_glb"](vertices=representation.vertices, faces=representation.faces, attr_volume=representation.attrs, coords=representation.coords, attr_layout=LAYOUT, grid_size=RESOLUTION, aabb=[[-.5, -.5, -.5], [.5, .5, .5]], decimation_target=decimation_target, texture_size=texture_size, remesh=True, remesh_band=1, remesh_project=0, use_tqdm=True, **({"low_vram": True} if low_vram else {}))
243
- if gutter:
244
- with span("gutter"):
245
- from .gutter import apply_gutter
246
- print(f"Gutter postprocess: {apply_gutter(scene)}", flush=True)
247
- with span("file_write"):
248
- scene.apply_transform(runtime["np"].asarray([[-1, 0, 0, 0], [0, 0, -1, 0], [0, -1, 0, 0], [0, 0, 0, 1]], dtype=runtime["np"].float64))
249
- scene.export(str(temporary), extension_webp=texture_format == "webp")
250
- if output.exists():
251
- raise FileExistsError(f"refusing to overwrite existing output: {output}")
252
- os.replace(temporary, output)
253
- finally:
254
- with span("export_cleanup"):
255
- temporary.unlink(missing_ok=True)
256
- lock.unlink(missing_ok=True)
257
-
258
-
259
- def _stage_sparse(value: Any, device: str, *, keep_topology: bool = False) -> Any:
260
- """Transfer sparse data without retaining backend caches on its old device.
261
-
262
- SparseTensor.to()/cpu() share spatial/backend caches. Encoder subdivision
263
- maps are semantic: the decoders use them to reproduce the input surface,
264
- overriding predicted subdivisions. Preserve those maps when requested;
265
- convolution and attention caches are safe to recompute on the new device.
266
- """
267
- def transfer(item: Any) -> Any:
268
- if isinstance(item, dict):
269
- return {key: transfer(child) for key, child in item.items()}
270
- if isinstance(item, (list, tuple)):
271
- return type(item)(transfer(child) for child in item)
272
- if hasattr(item, "detach") and hasattr(item, "to"):
273
- return item.detach().to(device)
274
- return item
275
- staged = type(value)(value.feats.detach().to(device), value.coords.detach().to(device),
276
- shape=value._shape, scale=value._scale)
277
- if keep_topology:
278
- staged._spatial_cache = {
279
- scale: {name: transfer(item) for name, item in cache.items()
280
- if name.startswith(("channel2spatial_", "upsample_")) or name == "shape"}
281
- for scale, cache in value._spatial_cache.items()
282
- }
283
- return staged
284
-
285
-
286
- @contextmanager
287
- def _completion_stage(model: Any, torch: Any, device: str, *, layer_offload: bool = False):
288
- """Own one model's residency, including cleanup after failed inference."""
289
- previous = getattr(model, "low_vram", False)
290
- try:
291
- if layer_offload:
292
- model.low_vram = True
293
- else:
294
- model.to(device)
295
- model.eval()
296
- with torch.no_grad():
297
- yield model
298
- finally:
299
- model.to("cpu")
300
- if layer_offload:
301
- model.low_vram = previous
302
- torch.cuda.empty_cache()
303
-
304
-
305
- def _run_completion_low_vram(*, case: Mapping[str, Any], back_case: Mapping[str, Any] | None,
306
- ckpts: Path, config: Mapping[str, Any], dataset: Mapping[str, Any],
307
- checkpoint_path: Path, partial_stats: Mapping[str, Any], resolved: Any,
308
- runtime: Mapping[str, Any], device: str, seed: int, output: str | Path,
309
- texture_size: int, decimation_target: int, texture_format: str, gutter: bool) -> dict[str, Any]:
310
- """Serialize completion stages and keep only their CPU handoffs alive."""
311
- torch, models = runtime["torch"], runtime["models"]
312
- print("Completion low VRAM: sequential models, CPU handoffs, decoder layer offload", flush=True)
313
- shape_encoder = models.from_pretrained(str(_stem(ckpts / "shape_enc_next_dc_f16c32_fp16", "shape encoder")))
314
- with _completion_stage(shape_encoder, torch, device):
315
- shape, surface = _shape(case["vertices"], case["faces"], shape_encoder, runtime, device)
316
- shape_cpu = _stage_sparse(shape, "cpu", keep_topology=True)
317
- shape = _stage_sparse(shape, device)
318
- del shape_encoder
319
- texture_encoder = models.from_pretrained(str(_stem(ckpts / "tex_enc_next_dc_f16c32_fp16", "material encoder")))
320
- dual_stats = {}
321
- with _completion_stage(texture_encoder, torch, device):
322
- if back_case is None:
323
- external, projection = _camera(case["camera"], device, torch, runtime["utils3d"])
324
- observation = _partial(surface, shape, case, external, projection, texture_encoder, partial_stats, runtime, device)
325
- del external, projection
326
- else:
327
- from .dual_view import encode_two_views
328
- observation, dual_stats = encode_two_views(surface, shape, case, back_case, texture_encoder,
329
- partial_stats, runtime, device)
330
- del texture_encoder, surface
331
- torch.cuda.empty_cache()
332
-
333
- from supratex.pipelines.observation_guided_completion import build_completion_model, sample_material_latents
334
- state = load_completion_state_dict(checkpoint_path, torch_module=torch)
335
- model = build_completion_model(config, state, assign_weights=True)
336
- del state
337
- with _completion_stage(model, torch, device):
338
- shape_condition = shape.replace(feats=(shape.feats - torch.as_tensor(dataset["shape_slat_normalization"]["mean"], device=device)[None]) / torch.as_tensor(dataset["shape_slat_normalization"]["std"], device=device)[None])
339
- noise = shape.replace(feats=torch.randn((shape.coords.shape[0], 32), generator=torch.Generator(device=device).manual_seed(int(seed)), device=device))
340
- print("Observation cache: disabled (low VRAM)", flush=True)
341
- sampled = sample_material_latents(model, noise, concat_cond=shape_condition, partial_z_obs=observation, steps=25, rescale_t=3., sigma_min=1e-5, use_observation_cache=False).samples
342
- if not torch.equal(sampled.coords, noise.coords) or sampled.feats.shape != noise.feats.shape:
343
- raise RuntimeError("material sampler changed sparse support or feature shape")
344
- generated = shape.replace(feats=sampled.feats.float() * torch.as_tensor(dataset["pbr_slat_normalization"]["std"], device=device)[None] + torch.as_tensor(dataset["pbr_slat_normalization"]["mean"], device=device)[None])
345
- generated_cpu = _stage_sparse(generated, "cpu")
346
- del model, shape, shape_condition, noise, sampled, generated, observation
347
- torch.cuda.empty_cache()
348
-
349
- # Reuse the encoder's exact support in both decoders. Do not carry GPU
350
- # convolution caches or the unused decoded mesh into material decoding.
351
- shape_decoder = models.from_pretrained(str(_stem(resolved(dataset["pretrained_shape_slat_dec"]), "shape decoder")))
352
- shape_decoder.set_resolution(RESOLUTION)
353
- with _completion_stage(shape_decoder, torch, device, layer_offload=True):
354
- shape = _stage_sparse(shape_cpu, device, keep_topology=True)
355
- decoded_shape, guide = shape_decoder(shape, return_subs=True, return_mesh=False)
356
- guide_cpu = [_stage_sparse(value, "cpu") for value in guide]
357
- del shape_decoder, shape, decoded_shape, guide
358
- generated_cpu._spatial_cache = shape_cpu._spatial_cache
359
- del shape_cpu
360
- torch.cuda.empty_cache()
361
-
362
- material_decoder = models.from_pretrained(str(_stem(resolved(dataset["pretrained_pbr_slat_dec"]), "material decoder")))
363
- with _completion_stage(material_decoder, torch, device, layer_offload=True):
364
- generated = _stage_sparse(generated_cpu, device, keep_topology=True)
365
- guide = [_stage_sparse(value, device) for value in guide_cpu]
366
- decoded = material_decoder(generated, guide_subs=guide) * .5 + .5
367
- if decoded.feats.shape[1] != 6: raise RuntimeError("material decoder must emit six PBR channels")
368
- decoded_cpu = _stage_sparse(decoded, "cpu")
369
- del material_decoder, generated, generated_cpu, guide, guide_cpu, decoded
370
- torch.cuda.empty_cache()
371
- representation = runtime["MeshWithVoxel"](case["vertices"], case["faces"], [-.5, -.5, -.5], 1. / RESOLUTION, decoded_cpu.coords[:, 1:].int(), decoded_cpu.feats.float().clamp(0, 1), torch.Size([1, 6, RESOLUTION, RESOLUTION, RESOLUTION]), layout=LAYOUT)
372
- del decoded_cpu
373
- _export(Path(output), representation, texture_size, decimation_target, texture_format, runtime, gutter=gutter, low_vram=True)
374
- return dual_stats
375
-
376
-
377
- def _run_completion(*, input_dir: str | Path, output: str | Path, model_config: str | Path, checkpoint: str | Path, trellis_root: str | Path, seed: int = 42, device: str = "cuda", texture_size: int = 4096, decimation_target: int = 500000, texture_format: str = "png", gutter: bool = False, back_case: Mapping[str, Any] | None = None, resident=None, preview_only=False) -> dict[str, Any]:
378
- """Complete PBR texture from public input maps and write one new GLB."""
379
- with span("validation"):
380
- if device != "cuda": raise ValueError("public material completion currently requires device='cuda'")
381
- if texture_size <= 0 or decimation_target <= 0 or texture_format not in {"png", "webp"}: raise ValueError("invalid GLB export settings")
382
- config, dataset, checkpoint_path, partial_stats = validate_completion_artifacts(model_config, checkpoint, trellis_root)
383
- decoder_stems = completion_decoder_stems(config, dataset, model_config, trellis_root)
384
- decoder_paths = {dataset[key]: path for key, path in decoder_stems.items()}
385
- def resolved(value: str) -> Path:
386
- return decoder_paths[value]
387
- with span("runtime"):
388
- runtime = _runtime(); torch, models = runtime["torch"], runtime["models"]
389
- if not torch.cuda.is_available(): raise RuntimeError("CUDA is unavailable")
390
- with span("case_load"):
391
- case = _load_case(input_dir, torch, runtime["Image"]); ckpts = _completion_vae_root(trellis_root)
392
- if resident is None and os.environ.get("SUPRATEX_LOW_VRAM", "0") == "1":
393
- return _run_completion_low_vram(case=case, back_case=back_case, ckpts=ckpts, config=config,
394
- dataset=dataset, checkpoint_path=checkpoint_path, partial_stats=partial_stats,
395
- resolved=resolved, runtime=runtime, device=device, seed=seed, output=output,
396
- texture_size=texture_size, decimation_target=decimation_target,
397
- texture_format=texture_format, gutter=gutter)
398
- with span("encoder_load"):
399
- if resident is not None:
400
- shape_encoder = resident.completion_model('shape_encoder').to(device).eval()
401
- texture_encoder = resident.completion_model('texture_encoder').to(device).eval()
402
- else:
403
- shape_encoder = models.from_pretrained(str(_stem(ckpts / "shape_enc_next_dc_f16c32_fp16", "shape encoder"))).to(device).eval()
404
- texture_encoder = models.from_pretrained(str(_stem(ckpts / "tex_enc_next_dc_f16c32_fp16", "material encoder"))).to(device).eval()
405
- with span("shape_encode"):
406
- external, projection = _camera(case["camera"], device, torch, runtime["utils3d"])
407
- shape, surface = _shape(case["vertices"], case["faces"], shape_encoder, runtime, device)
408
- shape_encoder.to("cpu"); torch.cuda.empty_cache()
409
- with span("observation_encode"):
410
- dual_stats = {}
411
- if back_case is None:
412
- observation = _partial(surface, shape, case, external, projection, texture_encoder, partial_stats, runtime, device)
413
- else:
414
- from .dual_view import encode_two_views
415
- observation, dual_stats = encode_two_views(surface, shape, case, back_case, texture_encoder,
416
- partial_stats, runtime, device)
417
- texture_encoder.to("cpu"); torch.cuda.empty_cache()
418
- with span("model_load"):
419
- from supratex.pipelines.observation_guided_completion import build_completion_model, sample_material_latents
420
- if resident is not None:
421
- model = resident.completion_model('transformer').to(device).eval()
422
- else:
423
- state = load_completion_state_dict(checkpoint_path, torch_module=torch)
424
- model = build_completion_model(config, state, assign_weights=True).to(device).eval()
425
- del state
426
- with span("conditioning"):
427
- shape_condition = shape.replace(feats=(shape.feats - torch.as_tensor(dataset["shape_slat_normalization"]["mean"], device=device)[None]) / torch.as_tensor(dataset["shape_slat_normalization"]["std"], device=device)[None])
428
- noise = shape.replace(feats=torch.randn((shape.coords.shape[0], 32), generator=torch.Generator(device=device).manual_seed(int(seed)), device=device))
429
- # Cache fixed observation work within this sample only. Retain ample room
430
- # for attention activations; low-memory devices keep the uncached path.
431
- cache_bytes = 2 * (len(model.blocks) + len(model.refiners)) * observation.feats.shape[0] * model.model_channels * 2
432
- free_bytes, _ = torch.cuda.mem_get_info(noise.feats.device)
433
- use_cache = free_bytes >= cache_bytes * 1.25 + 8 * 1024 ** 3
434
- print(f"Observation cache: {'enabled' if use_cache else 'disabled (memory headroom)'}", flush=True)
435
- with span("sample"):
436
- with span("sample_25_steps"):
437
- sampled = sample_material_latents(model, noise, concat_cond=shape_condition, partial_z_obs=observation, steps=25, rescale_t=3., sigma_min=1e-5, use_observation_cache=use_cache).samples
438
- if not torch.equal(sampled.coords, noise.coords) or sampled.feats.shape != noise.feats.shape:
439
- raise RuntimeError("material sampler changed sparse support or feature shape")
440
- generated = shape.replace(feats=sampled.feats.float() * torch.as_tensor(dataset["pbr_slat_normalization"]["std"], device=device)[None] + torch.as_tensor(dataset["pbr_slat_normalization"]["mean"], device=device)[None])
441
- model.to("cpu"); torch.cuda.empty_cache()
442
- with span("decoder_load"):
443
- shape_stem, material_stem = _stem(resolved(dataset["pretrained_shape_slat_dec"]), "shape decoder"), _stem(resolved(dataset["pretrained_pbr_slat_dec"]), "material decoder")
444
- if resident is not None:
445
- shape_decoder = resident.completion_model('shape_decoder').to(device).eval()
446
- material_decoder = resident.completion_model('material_decoder').to(device).eval()
447
- else:
448
- shape_decoder, material_decoder = models.from_pretrained(str(shape_stem)).to(device).eval(), models.from_pretrained(str(material_stem)).to(device).eval()
449
- shape_decoder.set_resolution(RESOLUTION)
450
- with span("decode"):
451
- with torch.no_grad(): _, guide = shape_decoder(shape, return_subs=True); decoded = material_decoder(generated, guide_subs=guide) * .5 + .5
452
- if decoded.feats.shape[1] != 6: raise RuntimeError("material decoder must emit six PBR channels")
453
- representation = runtime["MeshWithVoxel"](case["vertices"].to(device), case["faces"].to(device), [-.5, -.5, -.5], 1. / RESOLUTION, decoded.coords[:, 1:].int(), decoded.feats.float().clamp(0, 1), torch.Size([1, 6, RESOLUTION, RESOLUTION, RESOLUTION]), layout=LAYOUT)
454
- if resident is not None:
455
- shape_decoder.cpu(); material_decoder.cpu(); torch.cuda.empty_cache()
456
- if preview_only:
457
- from hf_space.ovoxel_preview import save_and_render
458
- save_and_render(Path(output), representation)
459
- else:
460
- with span("export"):
461
- _export(Path(output), representation, texture_size, decimation_target, texture_format, runtime, gutter=gutter)
462
- return dual_stats
463
-
464
-
465
- def run_completion(*, input_dir: str | Path, output: str | Path, model_config: str | Path,
466
- checkpoint: str | Path, trellis_root: str | Path, seed: int = 42,
467
- device: str = "cuda", texture_size: int = 4096,
468
- decimation_target: int = 500000, texture_format: str = "png", gutter: bool = False,
469
- back_view_dir: str | Path | None = None, work_dir: str | Path | None = None, resident=None, preview_only=False) -> None:
470
- """Complete one view by default, or validate and publish a dual-view result."""
471
- arguments = dict(input_dir=input_dir, output=output, model_config=model_config,
472
- checkpoint=checkpoint, trellis_root=trellis_root, seed=seed, device=device,
473
- texture_size=texture_size, decimation_target=decimation_target,
474
- texture_format=texture_format, gutter=gutter, resident=resident, preview_only=preview_only)
475
- if preview_only and back_view_dir is not None:
476
- raise ValueError('Preview mode currently requires single-view completion')
477
- if back_view_dir is None:
478
- if work_dir is not None:
479
- raise ValueError("completion --work-dir is reserved for dual-view inference")
480
- _run_completion(**arguments)
481
- return
482
- from .dual_view import CAMERA_PROTOCOL, PROTOCOL, digest, load_back_case, write_json
483
- import shutil
484
-
485
- output = Path(output).expanduser().resolve()
486
- work = Path(work_dir).expanduser().resolve() if work_dir else output.with_name(output.stem + "_dual_view")
487
- if output.suffix.lower() != ".glb":
488
- raise ValueError("output must have a .glb suffix")
489
- if output.exists():
490
- raise FileExistsError(f"refusing to overwrite existing output: {output}")
491
- if work.exists():
492
- raise FileExistsError(f"dual-view work directory must be new: {work}")
493
- if texture_size != 4096 or texture_format != "png" or decimation_target != 500000 or gutter:
494
- raise ValueError("dual-view reprojection requires a 4096 PNG atlas, 500000-face target, and no --gutter")
495
- front_root, back_root = Path(input_dir).expanduser().resolve(), Path(back_view_dir).expanduser().resolve()
496
- back = load_back_case(front_root, back_root)
497
- work.mkdir(parents=True)
498
- receipt = {"protocol": PROTOCOL, "camera_protocol": CAMERA_PROTOCOL, "view": "dual",
499
- "status": "running", "stage": "texture", "front_input": str(front_root),
500
- "back_input": str(back_root), "output": str(output),
501
- "checkpoint": str(Path(checkpoint).expanduser().resolve()),
502
- "model_config": str(Path(model_config).expanduser().resolve()),
503
- "seed": seed, "sampler": "Euler", "steps": 25,
504
- "geometry_sha256": digest(front_root / "geometry.pt"),
505
- "cameras_sha256": digest(back_root / "cameras.json"),
506
- "front_material_receipt_sha256": digest(front_root / "material_receipt.json"),
507
- "back_material_receipt_sha256": digest(back_root / "material_receipt.json")}
508
- write_json(work / "receipt.json", receipt)
509
- try:
510
- native = work / "preds/mesh.glb"
511
- arguments.update(input_dir=front_root, output=native, back_case=back)
512
- stats = _run_completion(**arguments)
513
- # Reprojection runs in separate CUDA workers. Release the completion
514
- # allocator's inactive blocks before those processes create contexts.
515
- import gc
516
- import torch
517
- gc.collect()
518
- torch.cuda.empty_cache()
519
- write_json(work / "receipt_texture.json", {**receipt, "status": "success",
520
- "dual_stats": stats, "sampling_calls": 1, "mesh": str(native), "mesh_sha256": digest(native)})
521
- receipt.update(stage="reprojection")
522
- write_json(work / "receipt.json", receipt)
523
- from .reprojection import run_dual_view, validate_dual_view
524
- final = run_dual_view(native, front_root, back_root, back["cameras"], work)
525
- validation = validate_dual_view(work, raise_on_invalid=True)
526
- # Model and reprojection work can be long-running. Revalidate the
527
- # original inputs and generation evidence before publishing the mesh.
528
- load_back_case(front_root, back_root)
529
- bindings = {"geometry_sha256": front_root / "geometry.pt",
530
- "cameras_sha256": back_root / "cameras.json",
531
- "front_material_receipt_sha256": front_root / "material_receipt.json",
532
- "back_material_receipt_sha256": back_root / "material_receipt.json"}
533
- for key, path in bindings.items():
534
- if receipt[key] != digest(path):
535
- raise RuntimeError(f"input changed during dual-view completion: {key}")
536
- output.parent.mkdir(parents=True, exist_ok=True)
537
- temporary = output.with_name(f".{output.name}.dual-view.{os.getpid()}.tmp")
538
- try:
539
- with temporary.open("xb") as target, final.open("rb") as source:
540
- shutil.copyfileobj(source, target)
541
- # Atomic publication that cannot replace another writer's output.
542
- os.link(temporary, output)
543
- finally:
544
- temporary.unlink(missing_ok=True)
545
- receipt.update(status="valid", stage="complete", mesh=str(final), mesh_sha256=digest(final),
546
- output_sha256=digest(output), dual_stats=stats,
547
- postprocess_validation=validation, stitched_19_phase_checked=False)
548
- write_json(work / "receipt.json", receipt)
549
- except Exception as error:
550
- receipt.update(status="failed", error=f"{type(error).__name__}: {error}")
551
- write_json(work / "receipt.json", receipt)
552
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Observation-guided PBR completion and GLB export.
2
+
3
+ The public boundary begins with a mesh/camera/RGBA observation plus albedo and
4
+ MRA maps. Geometry prediction and 2D map prediction intentionally remain
5
+ outside this inference-only package.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ import os
12
+ import sys
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+ from typing import Any, Mapping
16
+
17
+ from . import native_observation
18
+ from .checkpoint import load_completion_state_dict, validate_completion_checkpoint
19
+
20
+ RESOLUTION = native_observation.RESOLUTION
21
+ LAYOUT = {"base_color": slice(0, 3), "metallic": slice(3, 4), "roughness": slice(4, 5), "alpha": slice(5, 6)}
22
+ # Released completion-model ABI. It is checked once, when the config is loaded.
23
+ MODEL_ABI = {"in_channels": 64, "out_channels": 32, "cond_channels": 0, "model_channels": 1536,
24
+ "num_heads": 12, "num_blocks": 60, "base_block_policy": "alternating_u_x",
25
+ "v11_init_policy": "odd_copy_even_identity", "direct_modulation_broadcast": True,
26
+ "rope_freq": [1, 10000], "dtype": "float32"}
27
+
28
+
29
+ def _file(path: str | Path, label: str) -> Path:
30
+ value = Path(path)
31
+ if not value.is_file():
32
+ raise FileNotFoundError(f"missing {label}: {value}")
33
+ return value
34
+
35
+
36
+ def _normalization(value: Any, label: str) -> dict[str, list[float]]:
37
+ if not isinstance(value, Mapping):
38
+ raise ValueError(f"{label} normalization must be an object")
39
+ result: dict[str, list[float]] = {}
40
+ for key in ("mean", "std"):
41
+ raw = value.get(key)
42
+ if not isinstance(raw, list) or len(raw) != 32:
43
+ raise ValueError(f"{label} normalization requires 32-value {key}")
44
+ result[key] = [float(item) for item in raw]
45
+ if not all(math.isfinite(item) for item in result["mean"] + result["std"]) or any(item <= 0 for item in result["std"]):
46
+ raise ValueError(f"{label} normalization standard deviations must be positive")
47
+ return result
48
+
49
+
50
+ def load_contract(model_config: str | Path, checkpoint: str | Path) -> tuple[dict[str, Any], dict[str, Any], Path]:
51
+ """Load the public model ABI without inspecting or hashing weight bytes."""
52
+ config_path, checkpoint_path = _file(model_config, "model config"), _file(checkpoint, "checkpoint")
53
+ try:
54
+ config = json.loads(config_path.read_text(encoding="utf-8"))
55
+ dataset = config["dataset"]["args"]
56
+ model = config["models"]["denoiser"]
57
+ args = model["args"]
58
+ except (OSError, KeyError, TypeError, json.JSONDecodeError) as error:
59
+ raise ValueError("model config does not satisfy the Supra-Tex inference ABI") from error
60
+ if config.get("format") == "supratex_completion" and config.get("schema_version") != 1:
61
+ raise ValueError("unsupported Supra-Tex completion config schema_version")
62
+ if model.get("name") != "InterleavedFlowTransformer":
63
+ raise ValueError("model config must select InterleavedFlowTransformer")
64
+ if int(dataset.get("resolution", -1)) != RESOLUTION or dataset.get("attrs") != ["base_color", "metallic", "roughness", "alpha"]:
65
+ raise ValueError("model config must describe 1024 base-color/metallic/roughness/alpha inputs")
66
+ mismatched = {key: args.get(key) for key, expected in MODEL_ABI.items() if args.get(key) != expected}
67
+ if mismatched:
68
+ raise ValueError(f"model config does not match the released completion ABI: {mismatched}")
69
+ if dataset.get("bridge_z_obs_args", {}).get("partial_support_mode") != "partial_messages":
70
+ raise ValueError("model config requires partial observation messages")
71
+ _normalization(dataset.get("shape_slat_normalization"), "shape")
72
+ material = _normalization(dataset.get("pbr_slat_normalization"), "material")
73
+ for key in ("pbr_mean", "pbr_std"):
74
+ expected = material["mean" if key.endswith("mean") else "std"]
75
+ if [float(value) for value in args.get(key, [])] != expected:
76
+ raise ValueError(f"model {key} must exactly match material normalization")
77
+ return config, dataset, checkpoint_path
78
+
79
+
80
+ def _stem(path: str | Path, label: str) -> Path:
81
+ stem = Path(path)
82
+ _file(Path(f"{stem}.json"), f"{label} config")
83
+ _file(Path(f"{stem}.safetensors"), f"{label} weights")
84
+ return stem
85
+
86
+
87
+ def load_completion_contract(model_config: str | Path, checkpoint: str | Path) -> tuple[dict[str, Any], dict[str, Any], Path, dict[str, list[float]]]:
88
+ """Validate the model ABI and observation statistics without loading weights."""
89
+ config, dataset, checkpoint_path = load_contract(model_config, checkpoint)
90
+ validate_completion_checkpoint(checkpoint_path)
91
+ path = Path(dataset["bridge_z_obs_args"]["partial_z_obs_normalization_path"])
92
+ if not path.is_absolute():
93
+ path = Path(model_config).expanduser().absolute().parent / path
94
+ partial_stats = _normalization(json.loads(_file(path, "partial normalization").read_text(encoding="utf-8")), "partial observation")
95
+ model_args = config["models"]["denoiser"]["args"]
96
+ for key in ("partial_mean", "partial_std"):
97
+ expected = partial_stats["mean" if key.endswith("mean") else "std"]
98
+ if [float(value) for value in model_args.get(key, [])] != expected:
99
+ raise ValueError(f"model {key} must exactly match partial-observation normalization")
100
+ return config, dataset, checkpoint_path, partial_stats
101
+
102
+
103
+ def _completion_vae_root(trellis_root: str | Path) -> Path:
104
+ root = Path(trellis_root).expanduser()
105
+ return root / "ckpts" if (root / "ckpts").is_dir() else root
106
+
107
+
108
+ def completion_decoder_stems(config: Mapping[str, Any], dataset: Mapping[str, Any],
109
+ model_config: str | Path, trellis_root: str | Path) -> dict[str, Path]:
110
+ """Resolve release VAE dependencies or preserve legacy config-relative paths."""
111
+ base = Path(model_config).expanduser().absolute().parent
112
+ standalone = config.get("format") == "supratex_completion"
113
+ if standalone:
114
+ base = _completion_vae_root(trellis_root)
115
+ stems = {}
116
+ for key in ("pretrained_shape_slat_dec", "pretrained_pbr_slat_dec"):
117
+ value = dataset.get(key)
118
+ if not isinstance(value, str) or not value:
119
+ raise ValueError(f"model config requires {key}")
120
+ path = Path(value).expanduser()
121
+ if standalone and (path.name != value or value in (".", "..") or "\\" in value):
122
+ raise ValueError(f"standalone completion {key} must be a bare sparse VAE stem")
123
+ stems[key] = path if path.is_absolute() else base / path
124
+ return stems
125
+
126
+
127
+ def validate_completion_artifacts(model_config: str | Path, checkpoint: str | Path,
128
+ trellis_root: str | Path) -> tuple[dict[str, Any], dict[str, Any], Path, dict[str, list[float]]]:
129
+ """Check completion metadata, safe-file headers, and all VAE files before GPU work."""
130
+ contract = load_completion_contract(model_config, checkpoint)
131
+ config, dataset, _, _ = contract
132
+ ckpts = _completion_vae_root(trellis_root)
133
+ stems = [ckpts / "shape_enc_next_dc_f16c32_fp16", ckpts / "tex_enc_next_dc_f16c32_fp16"]
134
+ stems.extend(completion_decoder_stems(config, dataset, model_config, trellis_root).values())
135
+ for stem in stems:
136
+ _stem(stem, "completion artifact")
137
+ return contract
138
+
139
+
140
+ def _load_vae(models: Any, stem: Path) -> Any:
141
+ """Build a sparse VAE on the meta device so weights are not randomly initialized first.
142
+
143
+ Checkpoint tensors are cast to the constructor's dtypes, exactly as the
144
+ copy-based loader does. Partial checkpoints or constructor-made tensors that
145
+ a meta shell cannot materialize fall back to models.from_pretrained.
146
+ """
147
+ import torch
148
+ from safetensors.torch import load_file
149
+
150
+ config = json.loads(Path(f"{stem}.json").read_text(encoding="utf-8"))
151
+ with torch.device("meta"):
152
+ model = getattr(models, config["name"])(**config["args"])
153
+ expected = model.state_dict()
154
+ state = load_file(f"{stem}.safetensors", device="cpu")
155
+ if state.keys() != expected.keys() or any(state[name].shape != target.shape for name, target in expected.items()):
156
+ return models.from_pretrained(str(stem))
157
+ model.load_state_dict({name: state[name].to(dtype=target.dtype) for name, target in expected.items()},
158
+ strict=True, assign=True)
159
+ if any(isinstance(value, torch.Tensor) and value.is_meta
160
+ for module in model.modules() for value in (*module._parameters.values(), *module._buffers.values(), *vars(module).values())):
161
+ return models.from_pretrained(str(stem))
162
+ return model
163
+
164
+
165
+ def _load_geometry(root: Path, torch: Any) -> tuple[Any, Any]:
166
+ """Read geometry.pt, or a compressed geometry.npz with the same verts/faces arrays."""
167
+ if not (root / "geometry.pt").is_file() and (root / "geometry.npz").is_file():
168
+ import numpy as np
169
+ with np.load(root / "geometry.npz") as arrays:
170
+ try:
171
+ return torch.from_numpy(arrays["verts"]).float().contiguous(), torch.from_numpy(arrays["faces"]).long().contiguous()
172
+ except KeyError as error:
173
+ raise ValueError("geometry.npz must contain arrays verts and faces") from error
174
+ geometry = torch.load(_file(root / "geometry.pt", "geometry.pt or geometry.npz"), map_location="cpu", weights_only=True)
175
+ try:
176
+ return geometry["verts"].float().contiguous(), geometry["faces"].long().contiguous()
177
+ except (KeyError, AttributeError) as error:
178
+ raise ValueError("geometry.pt must contain tensor keys verts and faces") from error
179
+
180
+
181
+ def _load_case(input_dir: str | Path, torch: Any, image: Any, remove_floaters: bool = False) -> dict[str, Any]:
182
+ root = Path(input_dir)
183
+ required = {name: _file(root / name, name) for name in ("camera.json", "condition_rgba.png", "albedo.png", "mra.png")}
184
+ vertices, faces = _load_geometry(root, torch)
185
+ if vertices.ndim != 2 or vertices.shape[1] != 3 or faces.ndim != 2 or faces.shape[1] != 3:
186
+ raise ValueError("geometry must contain verts[N,3] and faces[M,3]")
187
+ rgba = image.open(required["condition_rgba.png"]).convert("RGBA")
188
+ albedo, mra = image.open(required["albedo.png"]).convert("RGB"), image.open(required["mra.png"]).convert("RGB")
189
+ if rgba.size[0] != rgba.size[1]:
190
+ raise ValueError("condition_rgba.png must be square")
191
+ if albedo.size != (RESOLUTION, RESOLUTION) or mra.size != (RESOLUTION, RESOLUTION):
192
+ raise ValueError("albedo.png and mra.png must be 1024 by 1024 RGB maps")
193
+ try:
194
+ camera = json.loads(required["camera.json"].read_text(encoding="utf-8"))
195
+ except json.JSONDecodeError as error:
196
+ raise ValueError("camera.json must be valid JSON") from error
197
+ if "camera_angle_x" not in camera or "distance" not in camera:
198
+ raise ValueError("camera.json must provide canonical camera_angle_x and distance")
199
+ if remove_floaters:
200
+ import numpy as np
201
+ from .geometry_cleanup import remove_floaters as clean
202
+ vertices, faces, report = clean(vertices, faces, camera, np.asarray(rgba.getchannel("A")))
203
+ vertices, faces = vertices.contiguous(), faces.contiguous()
204
+ print(f"Floaters: removed {report['removed_components']} of {report['components']} components "
205
+ f"({report['removed_faces']} of {report['faces']} faces)", flush=True)
206
+ return {"vertices": vertices, "faces": faces, "rgba": rgba, "albedo": albedo, "mra": mra, "camera": camera}
207
+
208
+
209
+ def _runtime() -> dict[str, Any]:
210
+ from .runtime import configure_inference_runtime
211
+ configure_inference_runtime()
212
+ from .kernel_config import configure_sparse_kernels
213
+ kernel_config = configure_sparse_kernels()
214
+ if kernel_config["enabled"]:
215
+ print(f"Sparse kernels: {kernel_config['preset']}", flush=True)
216
+ import numpy as np
217
+ import o_voxel
218
+ import torch
219
+ import torch.nn.functional as F
220
+ import utils3d
221
+ from PIL import Image, ImageFilter
222
+ from supratex import models
223
+ from supratex.modules import sparse
224
+ from supratex.representations import MeshWithVoxel
225
+ from .mesh_export import to_glb
226
+ return locals()
227
+
228
+
229
+ def _camera(spec: Mapping[str, Any], device: str, torch: Any, utils3d: Any) -> tuple[Any, Any]:
230
+ if "extrinsic" in spec and ("projection_intrinsic" in spec or "intrinsic" in spec):
231
+ intrinsic = spec.get("projection_intrinsic", spec.get("intrinsic"))
232
+ external, projection = torch.as_tensor(spec["extrinsic"], dtype=torch.float32, device=device), torch.as_tensor(intrinsic, dtype=torch.float32, device=device)
233
+ if external.shape != (4, 4) or projection.shape != (3, 3): raise ValueError("camera matrices must be 4x4 and 3x3")
234
+ return external, projection
235
+ if "distance" not in spec or "camera_angle_x" not in spec: raise ValueError("camera must supply distance and camera_angle_x")
236
+ eye = torch.tensor([0., 0., float(spec["distance"]) * float(spec.get("mesh_scale", 1.0))], device=device)
237
+ return utils3d.torch.extrinsics_look_at(eye, torch.zeros(3, device=device), torch.tensor([0., 1., 0.], device=device)), utils3d.torch.intrinsics_from_fov_xy(torch.tensor(float(spec["camera_angle_x"]), device=device), torch.tensor(float(spec["camera_angle_x"]), device=device))
238
+
239
+
240
+ def _shape(vertices: Any, faces: Any, encoder: Any, runtime: Mapping[str, Any], device: str) -> tuple[Any, Any]:
241
+ torch, o_voxel, sparse = runtime["torch"], runtime["o_voxel"], runtime["sparse"]
242
+ indices, dual_vertices, intersected = o_voxel.convert.mesh_to_flexible_dual_grid(vertices=vertices.float().cpu().contiguous(), faces=faces.long().cpu().contiguous(), grid_size=RESOLUTION, aabb=[[-.5, -.5, -.5], [.5, .5, .5]], face_weight=1., boundary_weight=.2, regularization_weight=1e-2, timing=False)
243
+ offsets = (dual_vertices.float() * RESOLUTION - indices.float()).clamp(0, 1)
244
+ coords = torch.cat((torch.zeros_like(indices[:, :1]), indices), dim=1).int().to(device)
245
+ vertices_sparse = sparse.SparseTensor(offsets.to(device), coords)
246
+ with torch.no_grad(): latent = encoder(vertices_sparse, vertices_sparse.replace(intersected.bool().to(device)))
247
+ return latent, sparse.SparseTensor(torch.zeros(coords.shape[0], 6, device=device), coords)
248
+
249
+
250
+ ALIGNMENT_WARNING_IOU = .9
251
+
252
+
253
+ def _alignment_report(uv: Any, alpha: Any, admission: Mapping[str, int], np: Any, image: Any, image_filter: Any) -> dict[str, float]:
254
+ """Compare the projected geometry silhouette with the image foreground and print the result."""
255
+ pixels = (uv.detach().cpu().numpy() * RESOLUTION).astype(np.int64)
256
+ inside = (pixels >= 0).all(1) & (pixels < RESOLUTION).all(1)
257
+ splat = np.zeros((RESOLUTION, RESOLUTION), dtype=np.uint8)
258
+ splat[pixels[inside, 1], pixels[inside, 0]] = 255
259
+ # Surface cells project to about one pixel each; a 3x3 closing fills the gaps.
260
+ silhouette = np.asarray(image.fromarray(splat).filter(image_filter.MaxFilter(3)).filter(image_filter.MinFilter(3))) > 0
261
+ foreground = np.asarray(image.fromarray(alpha).resize((RESOLUTION, RESOLUTION), image.Resampling.NEAREST)) > 127
262
+ overlap = float((silhouette & foreground).sum())
263
+ report = {"silhouette_iou": overlap / max(float((silhouette | foreground).sum()), 1.),
264
+ "geometry_inside_image": overlap / max(float(silhouette.sum()), 1.),
265
+ "image_covered_by_geometry": overlap / max(float(foreground.sum()), 1.), **admission}
266
+ rejected = [f"missed geometry {admission['trusted_rays'] - admission['first_hits']}",
267
+ f"depth edge {admission['rejected_depth_edge']}"]
268
+ rejected += [f"{label} {admission[key]}" for key, label in (("rejected_silhouette", "silhouette margin"),
269
+ ("rejected_grazing", "grazing")) if admission[key]]
270
+ print(f"Observation: silhouette IoU {report['silhouette_iou']:.3f} "
271
+ f"(geometry inside image {report['geometry_inside_image']:.3f}, image covered {report['image_covered_by_geometry']:.3f}); "
272
+ f"admitted {admission['admitted']} of {admission['trusted_rays']} trusted pixels ({', '.join(rejected)})", flush=True)
273
+ if report["silhouette_iou"] < ALIGNMENT_WARNING_IOU:
274
+ print(f"Warning: the image and geometry silhouettes disagree (IoU {report['silhouette_iou']:.3f} < "
275
+ f"{ALIGNMENT_WARNING_IOU}); mismatched regions rely on completion rather than the image.", flush=True)
276
+ return report
277
+
278
+
279
+ def _partial(surface: Any, shape: Any, case: Mapping[str, Any], external: Any, projection: Any, encoder: Any, normalization: Mapping[str, Any], runtime: Mapping[str, Any], device: str) -> Any:
280
+ torch, F, np, image, image_filter, sparse = runtime["torch"], runtime["F"], runtime["np"], runtime["Image"], runtime["ImageFilter"], runtime["sparse"]
281
+ coords = surface.coords[:, 1:].long(); points = (coords.float() + .5) / RESOLUTION - .5
282
+ camera = (torch.cat((points, torch.ones_like(points[:, :1])), dim=1) @ external.T)[:, :3]
283
+ uvw = camera @ projection.T; uv = uvw[:, :2] / uvw[:, 2:3].clamp(min=1e-8)
284
+ grid = torch.stack((uv[:, 0] * 2 - 1, uv[:, 1] * 2 - 1), dim=1).view(1, 1, -1, 2)
285
+ def sampled(source: Any) -> Any:
286
+ data = np.asarray(source.convert("RGB").resize((RESOLUTION, RESOLUTION), image.Resampling.LANCZOS), dtype=np.float32).copy() / 255.
287
+ return F.grid_sample(torch.from_numpy(data).permute(2, 0, 1).to(device)[None], grid, mode="bilinear", align_corners=False, padding_mode="border")[0, :, 0].T.clamp(0, 1)
288
+ alpha = np.asarray(case["rgba"].getchannel("A"), dtype=np.uint8)
289
+ mask = image.fromarray(np.where(alpha >= 254, 255, 0).astype(np.uint8)).resize((RESOLUTION, RESOLUTION), image.Resampling.NEAREST).filter(image_filter.MinFilter(5))
290
+ trusted = torch.from_numpy(np.asarray(mask, dtype=np.uint8).copy() == 255)
291
+ bits, ordered, order = native_observation.build_occupancy_index(coords)
292
+ admission: dict[str, int] = {}
293
+ owners = native_observation.pixel_first_occupied(dict(case["camera"]), bits, trusted, stats=admission)
294
+ _alignment_report(uv, alpha, admission, np, image, image_filter)
295
+ visible = native_observation.owner_rows(coords, owners, ordered, order)
296
+ if not bool(visible.any()): raise RuntimeError("the trusted alpha gate leaves no observed material surface")
297
+ with torch.no_grad():
298
+ return native_observation.encode_partial(encoder, coords, visible, sampled(case["albedo"]), sampled(case["mra"]), shape.coords[:, 1:], dict(normalization), sparse, device)
299
+
300
+
301
+ def _export(output: Path, representation: Any, texture_size: int, decimation_target: int, texture_format: str, runtime: Mapping[str, Any], *, gutter: bool = False, low_vram: bool = False) -> None:
302
+ if output.suffix.lower() != ".glb": raise ValueError("output must have a .glb suffix")
303
+ output.parent.mkdir(parents=True, exist_ok=True)
304
+ lock = output.with_name(f".{output.name}.publish.lock")
305
+ try:
306
+ handle = lock.open("x")
307
+ except FileExistsError as error:
308
+ raise FileExistsError(f"another process is publishing {output}") from error
309
+ handle.close()
310
+ if output.exists():
311
+ lock.unlink(missing_ok=True)
312
+ raise FileExistsError(f"refusing to overwrite existing output: {output}")
313
+ temporary = output.with_name(f".{output.stem}.tmp.{os.getpid()}.glb")
314
+ try:
315
+ scene = runtime["to_glb"](vertices=representation.vertices, faces=representation.faces, attr_volume=representation.attrs, coords=representation.coords, attr_layout=LAYOUT, grid_size=RESOLUTION, aabb=[[-.5, -.5, -.5], [.5, .5, .5]], decimation_target=decimation_target, texture_size=texture_size, remesh=True, remesh_band=1, remesh_project=0, use_tqdm=True, **({"low_vram": True} if low_vram else {}))
316
+ if gutter:
317
+ from .gutter import apply_gutter
318
+ print(f"Gutter postprocess: {apply_gutter(scene)}", flush=True)
319
+ scene.apply_transform(runtime["np"].asarray([[-1, 0, 0, 0], [0, 0, -1, 0], [0, -1, 0, 0], [0, 0, 0, 1]], dtype=runtime["np"].float64))
320
+ scene.export(str(temporary), extension_webp=texture_format == "webp")
321
+ if output.exists():
322
+ raise FileExistsError(f"refusing to overwrite existing output: {output}")
323
+ os.replace(temporary, output)
324
+ finally:
325
+ temporary.unlink(missing_ok=True)
326
+ lock.unlink(missing_ok=True)
327
+
328
+
329
+ def _stage_sparse(value: Any, device: str, *, keep_topology: bool = False) -> Any:
330
+ """Transfer sparse data without retaining backend caches on its old device.
331
+
332
+ SparseTensor.to()/cpu() share spatial/backend caches. Encoder subdivision
333
+ maps are semantic: the decoders use them to reproduce the input surface,
334
+ overriding predicted subdivisions. Preserve those maps when requested;
335
+ convolution and attention caches are safe to recompute on the new device.
336
+ """
337
+ def transfer(item: Any) -> Any:
338
+ if isinstance(item, dict):
339
+ return {key: transfer(child) for key, child in item.items()}
340
+ if isinstance(item, (list, tuple)):
341
+ return type(item)(transfer(child) for child in item)
342
+ if hasattr(item, "detach") and hasattr(item, "to"):
343
+ return item.detach().to(device)
344
+ return item
345
+ staged = type(value)(value.feats.detach().to(device), value.coords.detach().to(device),
346
+ shape=value._shape, scale=value._scale)
347
+ if keep_topology:
348
+ staged._spatial_cache = {
349
+ scale: {name: transfer(item) for name, item in cache.items()
350
+ if name.startswith(("channel2spatial_", "upsample_")) or name == "shape"}
351
+ for scale, cache in value._spatial_cache.items()
352
+ }
353
+ return staged
354
+
355
+
356
+ @contextmanager
357
+ def _completion_stage(model: Any, torch: Any, device: str, *, layer_offload: bool = False):
358
+ """Own one model's residency, including cleanup after failed inference."""
359
+ previous = getattr(model, "low_vram", False)
360
+ try:
361
+ if layer_offload:
362
+ model.low_vram = True
363
+ else:
364
+ model.to(device)
365
+ model.eval()
366
+ with torch.no_grad():
367
+ yield model
368
+ finally:
369
+ model.to("cpu")
370
+ if layer_offload:
371
+ model.low_vram = previous
372
+ torch.cuda.empty_cache()
373
+
374
+
375
+ def _run_completion_low_vram(*, case: Mapping[str, Any], back_case: Mapping[str, Any] | None,
376
+ ckpts: Path, config: Mapping[str, Any], dataset: Mapping[str, Any],
377
+ checkpoint_path: Path, partial_stats: Mapping[str, Any], resolved: Any,
378
+ runtime: Mapping[str, Any], device: str, seed: int, output: str | Path,
379
+ texture_size: int, decimation_target: int, texture_format: str, gutter: bool, preview_only: bool = False) -> dict[str, Any]:
380
+ """Serialize completion stages and keep only their CPU handoffs alive."""
381
+ torch, models = runtime["torch"], runtime["models"]
382
+ print("Completion low VRAM: sequential models, CPU handoffs, decoder layer offload", flush=True)
383
+ shape_encoder = _load_vae(models, _stem(ckpts / "shape_enc_next_dc_f16c32_fp16", "shape encoder"))
384
+ with _completion_stage(shape_encoder, torch, device):
385
+ shape, surface = _shape(case["vertices"], case["faces"], shape_encoder, runtime, device)
386
+ shape_cpu = _stage_sparse(shape, "cpu", keep_topology=True)
387
+ shape = _stage_sparse(shape, device)
388
+ del shape_encoder
389
+ texture_encoder = _load_vae(models, _stem(ckpts / "tex_enc_next_dc_f16c32_fp16", "material encoder"))
390
+ dual_stats = {}
391
+ with _completion_stage(texture_encoder, torch, device):
392
+ if back_case is None:
393
+ external, projection = _camera(case["camera"], device, torch, runtime["utils3d"])
394
+ observation = _partial(surface, shape, case, external, projection, texture_encoder, partial_stats, runtime, device)
395
+ del external, projection
396
+ else:
397
+ from .dual_view import encode_two_views
398
+ observation, dual_stats = encode_two_views(surface, shape, case, back_case, texture_encoder,
399
+ partial_stats, runtime, device)
400
+ del texture_encoder, surface
401
+ torch.cuda.empty_cache()
402
+
403
+ from supratex.pipelines.observation_guided_completion import build_completion_model, sample_material_latents
404
+ state = load_completion_state_dict(checkpoint_path, torch_module=torch)
405
+ model = build_completion_model(config, state, assign_weights=True)
406
+ del state
407
+ with _completion_stage(model, torch, device):
408
+ shape_condition = shape.replace(feats=(shape.feats - torch.as_tensor(dataset["shape_slat_normalization"]["mean"], device=device)[None]) / torch.as_tensor(dataset["shape_slat_normalization"]["std"], device=device)[None])
409
+ noise = shape.replace(feats=torch.randn((shape.coords.shape[0], 32), generator=torch.Generator(device=device).manual_seed(int(seed)), device=device))
410
+ print("Observation cache: disabled (low VRAM)", flush=True)
411
+ sampled = sample_material_latents(model, noise, concat_cond=shape_condition, partial_z_obs=observation, steps=25, rescale_t=3., sigma_min=1e-5, use_observation_cache=False).samples
412
+ if not torch.equal(sampled.coords, noise.coords) or sampled.feats.shape != noise.feats.shape:
413
+ raise RuntimeError("material sampler changed sparse support or feature shape")
414
+ generated = shape.replace(feats=sampled.feats.float() * torch.as_tensor(dataset["pbr_slat_normalization"]["std"], device=device)[None] + torch.as_tensor(dataset["pbr_slat_normalization"]["mean"], device=device)[None])
415
+ generated_cpu = _stage_sparse(generated, "cpu")
416
+ del model, shape, shape_condition, noise, sampled, generated, observation
417
+ torch.cuda.empty_cache()
418
+
419
+ # Reuse the encoder's exact support in both decoders. Do not carry GPU
420
+ # convolution caches or the unused decoded mesh into material decoding.
421
+ shape_decoder = _load_vae(models, _stem(resolved(dataset["pretrained_shape_slat_dec"]), "shape decoder"))
422
+ shape_decoder.set_resolution(RESOLUTION)
423
+ with _completion_stage(shape_decoder, torch, device, layer_offload=True):
424
+ shape = _stage_sparse(shape_cpu, device, keep_topology=True)
425
+ decoded_shape, guide = shape_decoder(shape, return_subs=True, return_mesh=False)
426
+ guide_cpu = [_stage_sparse(value, "cpu") for value in guide]
427
+ del shape_decoder, shape, decoded_shape, guide
428
+ generated_cpu._spatial_cache = shape_cpu._spatial_cache
429
+ del shape_cpu
430
+ torch.cuda.empty_cache()
431
+
432
+ material_decoder = _load_vae(models, _stem(resolved(dataset["pretrained_pbr_slat_dec"]), "material decoder"))
433
+ with _completion_stage(material_decoder, torch, device, layer_offload=True):
434
+ generated = _stage_sparse(generated_cpu, device, keep_topology=True)
435
+ guide = [_stage_sparse(value, device) for value in guide_cpu]
436
+ decoded = material_decoder(generated, guide_subs=guide) * .5 + .5
437
+ if decoded.feats.shape[1] != 6: raise RuntimeError("material decoder must emit six PBR channels")
438
+ decoded_cpu = _stage_sparse(decoded, "cpu")
439
+ del material_decoder, generated, generated_cpu, guide, guide_cpu, decoded
440
+ torch.cuda.empty_cache()
441
+ representation = runtime["MeshWithVoxel"](case["vertices"], case["faces"], [-.5, -.5, -.5], 1. / RESOLUTION, decoded_cpu.coords[:, 1:].int(), decoded_cpu.feats.float().clamp(0, 1), torch.Size([1, 6, RESOLUTION, RESOLUTION, RESOLUTION]), layout=LAYOUT)
442
+ del decoded_cpu
443
+ if preview_only:
444
+ from hf_space.ovoxel_preview import save_and_render
445
+ save_and_render(Path(output), representation.to(device))
446
+ else:
447
+ _export(Path(output), representation, texture_size, decimation_target, texture_format, runtime, gutter=gutter, low_vram=True)
448
+ return dual_stats
449
+
450
+
451
+ def _run_completion(*, input_dir: str | Path, output: str | Path, model_config: str | Path, checkpoint: str | Path, trellis_root: str | Path, seed: int = 42, device: str = "cuda", texture_size: int = 4096, decimation_target: int = 500000, texture_format: str = "png", gutter: bool = False, back_case: Mapping[str, Any] | None = None, remove_floaters: bool = False, preview_only: bool = False) -> dict[str, Any]:
452
+ """Complete PBR texture from public input maps and write one new GLB."""
453
+ if device != "cuda": raise ValueError("public material completion currently requires device='cuda'")
454
+ if texture_size <= 0 or decimation_target <= 0 or texture_format not in {"png", "webp"}: raise ValueError("invalid GLB export settings")
455
+ config, dataset, checkpoint_path, partial_stats = validate_completion_artifacts(model_config, checkpoint, trellis_root)
456
+ decoder_stems = completion_decoder_stems(config, dataset, model_config, trellis_root)
457
+ decoder_paths = {dataset[key]: path for key, path in decoder_stems.items()}
458
+ def resolved(value: str) -> Path:
459
+ return decoder_paths[value]
460
+ runtime = _runtime(); torch, models = runtime["torch"], runtime["models"]
461
+ if not torch.cuda.is_available(): raise RuntimeError("CUDA is unavailable")
462
+ case = _load_case(input_dir, torch, runtime["Image"], remove_floaters); ckpts = _completion_vae_root(trellis_root)
463
+ if os.environ.get("SUPRATEX_LOW_VRAM", "0") == "1":
464
+ return _run_completion_low_vram(case=case, back_case=back_case, ckpts=ckpts, config=config,
465
+ dataset=dataset, checkpoint_path=checkpoint_path, partial_stats=partial_stats,
466
+ resolved=resolved, runtime=runtime, device=device, seed=seed, output=output,
467
+ texture_size=texture_size, decimation_target=decimation_target,
468
+ texture_format=texture_format, gutter=gutter, preview_only=preview_only)
469
+ shape_encoder = _load_vae(models, _stem(ckpts / "shape_enc_next_dc_f16c32_fp16", "shape encoder")).to(device).eval()
470
+ texture_encoder = _load_vae(models, _stem(ckpts / "tex_enc_next_dc_f16c32_fp16", "material encoder")).to(device).eval()
471
+ external, projection = _camera(case["camera"], device, torch, runtime["utils3d"])
472
+ shape, surface = _shape(case["vertices"], case["faces"], shape_encoder, runtime, device)
473
+ shape_encoder.to("cpu"); torch.cuda.empty_cache()
474
+ dual_stats = {}
475
+ if back_case is None:
476
+ observation = _partial(surface, shape, case, external, projection, texture_encoder, partial_stats, runtime, device)
477
+ else:
478
+ from .dual_view import encode_two_views
479
+ observation, dual_stats = encode_two_views(surface, shape, case, back_case, texture_encoder,
480
+ partial_stats, runtime, device)
481
+ texture_encoder.to("cpu"); torch.cuda.empty_cache()
482
+ from supratex.pipelines.observation_guided_completion import build_completion_model, sample_material_latents
483
+ state = load_completion_state_dict(checkpoint_path, torch_module=torch)
484
+ model = build_completion_model(config, state, assign_weights=True).to(device).eval()
485
+ del state
486
+ shape_condition = shape.replace(feats=(shape.feats - torch.as_tensor(dataset["shape_slat_normalization"]["mean"], device=device)[None]) / torch.as_tensor(dataset["shape_slat_normalization"]["std"], device=device)[None])
487
+ noise = shape.replace(feats=torch.randn((shape.coords.shape[0], 32), generator=torch.Generator(device=device).manual_seed(int(seed)), device=device))
488
+ # Cache fixed observation work within this sample only. Retain ample room
489
+ # for attention activations; low-memory devices keep the uncached path.
490
+ cache_bytes = 2 * (len(model.blocks) + len(model.refiners)) * observation.feats.shape[0] * model.model_channels * 2
491
+ free_bytes, _ = torch.cuda.mem_get_info(noise.feats.device)
492
+ use_cache = free_bytes >= cache_bytes * 1.25 + 8 * 1024 ** 3
493
+ print(f"Observation cache: {'enabled' if use_cache else 'disabled (memory headroom)'}", flush=True)
494
+ sampled = sample_material_latents(model, noise, concat_cond=shape_condition, partial_z_obs=observation, steps=25, rescale_t=3., sigma_min=1e-5, use_observation_cache=use_cache).samples
495
+ if not torch.equal(sampled.coords, noise.coords) or sampled.feats.shape != noise.feats.shape:
496
+ raise RuntimeError("material sampler changed sparse support or feature shape")
497
+ generated = shape.replace(feats=sampled.feats.float() * torch.as_tensor(dataset["pbr_slat_normalization"]["std"], device=device)[None] + torch.as_tensor(dataset["pbr_slat_normalization"]["mean"], device=device)[None])
498
+ model.to("cpu"); torch.cuda.empty_cache()
499
+ shape_stem, material_stem = _stem(resolved(dataset["pretrained_shape_slat_dec"]), "shape decoder"), _stem(resolved(dataset["pretrained_pbr_slat_dec"]), "material decoder")
500
+ shape_decoder, material_decoder = _load_vae(models, shape_stem).to(device).eval(), _load_vae(models, material_stem).to(device).eval()
501
+ shape_decoder.set_resolution(RESOLUTION)
502
+ with torch.no_grad(): _, guide = shape_decoder(shape, return_subs=True); decoded = material_decoder(generated, guide_subs=guide) * .5 + .5
503
+ if decoded.feats.shape[1] != 6: raise RuntimeError("material decoder must emit six PBR channels")
504
+ representation = runtime["MeshWithVoxel"](case["vertices"].to(device), case["faces"].to(device), [-.5, -.5, -.5], 1. / RESOLUTION, decoded.coords[:, 1:].int(), decoded.feats.float().clamp(0, 1), torch.Size([1, 6, RESOLUTION, RESOLUTION, RESOLUTION]), layout=LAYOUT)
505
+ if preview_only:
506
+ from hf_space.ovoxel_preview import save_and_render
507
+ save_and_render(Path(output), representation.to(device))
508
+ else:
509
+ _export(Path(output), representation, texture_size, decimation_target, texture_format, runtime, gutter=gutter)
510
+ return dual_stats
511
+
512
+
513
+ def _require_reprojection_atlas(texture_size: int, texture_format: str) -> None:
514
+ from supratex.pipeline_config import REFINE_ATLAS
515
+ if {"texture_size": texture_size, "texture_format": texture_format} != REFINE_ATLAS:
516
+ raise ValueError("--refine and --dual-view require a 4096 PNG atlas")
517
+
518
+
519
+ def _publish(final: Path, output: Path, tag: str) -> None:
520
+ """Copy a finished mesh to ``output`` without replacing another writer's file."""
521
+ import shutil
522
+ output.parent.mkdir(parents=True, exist_ok=True)
523
+ temporary = output.with_name(f".{output.name}.{tag}.{os.getpid()}.tmp")
524
+ try:
525
+ with temporary.open("xb") as target, final.open("rb") as source:
526
+ shutil.copyfileobj(source, target)
527
+ os.link(temporary, output)
528
+ finally:
529
+ temporary.unlink(missing_ok=True)
530
+
531
+
532
+ def _publish_unrefined(native: Path, output: Path, gutter: bool, error: Exception) -> None:
533
+ """Publish the completed mesh without refinement after a refinement failure."""
534
+ print(f"WARNING: texture refinement failed and was skipped ({type(error).__name__}: {error}); "
535
+ f"writing the unrefined result to {output}", file=sys.stderr, flush=True)
536
+ if not gutter:
537
+ _publish(native, output, "unrefined")
538
+ return
539
+ # Refinement normally supplies the gutter; apply the export-time one instead.
540
+ import tempfile
541
+ import trimesh
542
+ from .gutter import apply_gutter
543
+ scene = trimesh.load(native, force="scene")
544
+ for mesh in scene.geometry.values():
545
+ apply_gutter(mesh)
546
+ with tempfile.TemporaryDirectory(dir=output.parent) as directory:
547
+ padded = Path(directory) / "mesh.glb"
548
+ scene.export(str(padded))
549
+ _publish(padded, output, "unrefined")
550
+
551
+
552
+ def _run_front_reprojection(arguments: dict[str, Any], work_dir: str | Path | None) -> None:
553
+ """Complete one view, then reproject its reference observation into the atlas."""
554
+ from .dual_view import digest, write_json
555
+
556
+ output = Path(arguments["output"]).expanduser().resolve()
557
+ work = Path(work_dir).expanduser().resolve() if work_dir else output.with_name(output.stem + "_refine")
558
+ if output.suffix.lower() != ".glb":
559
+ raise ValueError("output must have a .glb suffix")
560
+ if output.exists():
561
+ raise FileExistsError(f"refusing to overwrite existing output: {output}")
562
+ if work.exists():
563
+ raise FileExistsError(f"--refine work directory must be new: {work}")
564
+ _require_reprojection_atlas(arguments["texture_size"], arguments["texture_format"])
565
+ source = Path(arguments["input_dir"]).expanduser().resolve()
566
+ work.mkdir(parents=True)
567
+ # Reprojection always ends with its observation-guided one-texel gutter, so
568
+ # the export-time nearest-color gutter (--gutter) is not applied first.
569
+ receipt = {"profile": "supratex.front_refinement", "view": "front", "status": "running", "stage": "texture",
570
+ "input": str(source), "output": str(output), "seed": arguments["seed"],
571
+ "decimation_target": arguments["decimation_target"], "gutter": "continuous (reprojection)",
572
+ "checkpoint": str(Path(arguments["checkpoint"]).expanduser().resolve()),
573
+ "model_config": str(Path(arguments["model_config"]).expanduser().resolve())}
574
+ write_json(work / "receipt.json", receipt)
575
+ try:
576
+ native = work / "preds/mesh.glb"
577
+ _run_completion(**{**arguments, "input_dir": source, "output": native, "gutter": False})
578
+ # Reprojection runs in separate CUDA workers; release cached blocks first.
579
+ import gc
580
+ import torch
581
+ gc.collect()
582
+ torch.cuda.empty_cache()
583
+ receipt.update(stage="reprojection", native_mesh=str(native), native_mesh_sha256=digest(native))
584
+ write_json(work / "receipt.json", receipt)
585
+ from .reprojection import run_front_view
586
+ try:
587
+ final = run_front_view(native, source, work / "reproject")
588
+ except Exception as error:
589
+ _publish_unrefined(native, output, arguments["gutter"], error)
590
+ receipt.update(status="valid", stage="complete", refine="skipped",
591
+ refine_error=f"{type(error).__name__}: {error}", mesh=str(native),
592
+ gutter="export-time" if arguments["gutter"] else False,
593
+ output_sha256=digest(output))
594
+ write_json(work / "receipt.json", receipt)
595
+ return
596
+ _publish(final, output, "reproject")
597
+ receipt.update(status="valid", stage="complete", refine="applied", mesh=str(final),
598
+ output_sha256=digest(output))
599
+ write_json(work / "receipt.json", receipt)
600
+ except Exception as error:
601
+ receipt.update(status="failed", error=f"{type(error).__name__}: {error}")
602
+ write_json(work / "receipt.json", receipt)
603
+ raise
604
+
605
+
606
+ def run_completion(*, input_dir: str | Path, output: str | Path, model_config: str | Path,
607
+ checkpoint: str | Path, trellis_root: str | Path, seed: int = 42,
608
+ device: str = "cuda", texture_size: int = 4096,
609
+ decimation_target: int = 500000, texture_format: str = "png", gutter: bool = False,
610
+ back_view_dir: str | Path | None = None, work_dir: str | Path | None = None,
611
+ refine: bool = False, remove_floaters: bool = False, preview_only: bool = False) -> None:
612
+ """Complete one view by default, optionally refining it, or publish a refined dual-view result."""
613
+ arguments = dict(input_dir=input_dir, output=output, model_config=model_config,
614
+ checkpoint=checkpoint, trellis_root=trellis_root, seed=seed, device=device,
615
+ texture_size=texture_size, decimation_target=decimation_target,
616
+ texture_format=texture_format, gutter=gutter, remove_floaters=remove_floaters, preview_only=preview_only)
617
+ if preview_only and (back_view_dir is not None or refine):
618
+ raise ValueError("o-voxel preview must precede single-view mesh export")
619
+ # Dual-view refines both views itself, so --refine needs no separate pass there.
620
+ if refine and back_view_dir is None:
621
+ _run_front_reprojection(arguments, work_dir)
622
+ return
623
+ if back_view_dir is None:
624
+ if work_dir is not None:
625
+ raise ValueError("completion --work-dir is reserved for dual-view inference or --refine")
626
+ _run_completion(**arguments)
627
+ return
628
+ from .dual_view import CAMERA_PROTOCOL, PROTOCOL, digest, load_back_case, write_json
629
+
630
+ output = Path(output).expanduser().resolve()
631
+ work = Path(work_dir).expanduser().resolve() if work_dir else output.with_name(output.stem + "_dual_view")
632
+ if output.suffix.lower() != ".glb":
633
+ raise ValueError("output must have a .glb suffix")
634
+ if output.exists():
635
+ raise FileExistsError(f"refusing to overwrite existing output: {output}")
636
+ if work.exists():
637
+ raise FileExistsError(f"dual-view work directory must be new: {work}")
638
+ _require_reprojection_atlas(texture_size, texture_format)
639
+ front_root, back_root = Path(input_dir).expanduser().resolve(), Path(back_view_dir).expanduser().resolve()
640
+ back = load_back_case(front_root, back_root)
641
+ work.mkdir(parents=True)
642
+ receipt = {"protocol": PROTOCOL, "camera_protocol": CAMERA_PROTOCOL, "view": "dual",
643
+ "status": "running", "stage": "texture", "front_input": str(front_root),
644
+ "back_input": str(back_root), "output": str(output),
645
+ "checkpoint": str(Path(checkpoint).expanduser().resolve()),
646
+ "model_config": str(Path(model_config).expanduser().resolve()),
647
+ "seed": seed, "sampler": "Euler", "steps": 25,
648
+ "decimation_target": decimation_target, "gutter": "continuous (reprojection)",
649
+ "geometry_sha256": digest(front_root / "geometry.pt"),
650
+ "cameras_sha256": digest(back_root / "cameras.json"),
651
+ "front_material_receipt_sha256": digest(front_root / "material_receipt.json"),
652
+ "back_material_receipt_sha256": digest(back_root / "material_receipt.json")}
653
+ write_json(work / "receipt.json", receipt)
654
+ try:
655
+ native = work / "preds/mesh.glb"
656
+ # Reprojection ends with its observation-guided gutter, so skip the export-time one.
657
+ arguments.update(input_dir=front_root, output=native, back_case=back, gutter=False)
658
+ stats = _run_completion(**arguments)
659
+ # Reprojection runs in separate CUDA workers. Release the completion
660
+ # allocator's inactive blocks before those processes create contexts.
661
+ import gc
662
+ import torch
663
+ gc.collect()
664
+ torch.cuda.empty_cache()
665
+ write_json(work / "receipt_texture.json", {**receipt, "status": "success",
666
+ "dual_stats": stats, "sampling_calls": 1, "mesh": str(native), "mesh_sha256": digest(native)})
667
+ receipt.update(stage="reprojection")
668
+ write_json(work / "receipt.json", receipt)
669
+ from .reprojection import run_dual_view, validate_dual_view
670
+ try:
671
+ final = run_dual_view(native, front_root, back_root, back["cameras"], work)
672
+ validation = validate_dual_view(work, raise_on_invalid=True)
673
+ except Exception as error:
674
+ final, validation, refine_error = None, None, error
675
+ # Model and reprojection work can be long-running. Revalidate the
676
+ # original inputs and generation evidence before publishing the mesh.
677
+ load_back_case(front_root, back_root)
678
+ bindings = {"geometry_sha256": front_root / "geometry.pt",
679
+ "cameras_sha256": back_root / "cameras.json",
680
+ "front_material_receipt_sha256": front_root / "material_receipt.json",
681
+ "back_material_receipt_sha256": back_root / "material_receipt.json"}
682
+ for key, path in bindings.items():
683
+ if receipt[key] != digest(path):
684
+ raise RuntimeError(f"input changed during dual-view completion: {key}")
685
+ if final is None:
686
+ _publish_unrefined(native, output, gutter, refine_error)
687
+ receipt.update(status="valid", stage="complete", refine="skipped",
688
+ refine_error=f"{type(refine_error).__name__}: {refine_error}", mesh=str(native),
689
+ gutter="export-time" if gutter else False,
690
+ mesh_sha256=digest(native), output_sha256=digest(output), dual_stats=stats)
691
+ else:
692
+ _publish(final, output, "dual-view")
693
+ receipt.update(status="valid", stage="complete", refine="applied", mesh=str(final),
694
+ mesh_sha256=digest(final), output_sha256=digest(output), dual_stats=stats,
695
+ postprocess_validation=validation, stitched_19_phase_checked=False)
696
+ write_json(work / "receipt.json", receipt)
697
+ except Exception as error:
698
+ receipt.update(status="failed", error=f"{type(error).__name__}: {error}")
699
+ write_json(work / "receipt.json", receipt)
700
+ raise
supratex/inference/reprojection/__init__.py CHANGED
@@ -1,286 +1,329 @@
1
- """Dual-view atlas reprojection with measured candidate acceptance gates.
2
-
3
- Both view candidates undergo sixteen train and three held-out phase renders.
4
- The final atlas is stitched with exact front priority and binary preservation;
5
- the stitched result is not itself subjected to another nineteen render phases.
6
- """
7
- from __future__ import annotations
8
-
9
- import json
10
- import shutil
11
- import subprocess
12
- import sys
13
- from pathlib import Path
14
- from typing import Any
15
-
16
- from . import _glb
17
- from ._common import (
18
- bind_file, check_binding, read_json, validate_candidate, write_json,
19
- )
20
- from ._transform import (
21
- atlas, back_to_front, camera_spec, front_protection, stitch,
22
- transform_glb, transformed_payload, validate_stitch,
23
- )
24
-
25
- __all__ = ["run_dual_view", "validate_dual_view"]
26
- PROFILE = "supratex.dual_view.reproject_continuous"
27
-
28
-
29
- def _prepare_inputs(source: Path, destination: Path, camera: dict[str, Any]) -> dict[str, Any]:
30
- """Align observation alpha and RGB once, preserving native albedo/MRA maps."""
31
- from PIL import Image
32
-
33
- names = ("condition_rgb.png", "condition_rgba.png", "albedo.png", "mra.png")
34
- bindings = {name: bind_file(source / name) for name in names}
35
- if (source / "camera.json").is_file():
36
- bindings["camera.json"] = bind_file(source / "camera.json")
37
- destination.mkdir(parents=True)
38
- with Image.open(source / "condition_rgb.png") as rgb, Image.open(source / "condition_rgba.png") as rgba:
39
- if rgb.size != rgba.size:
40
- raise ValueError("condition RGB and RGBA dimensions must agree")
41
- resized = rgb.convert("RGB").resize((1024, 1024), Image.Resampling.LANCZOS)
42
- alpha = rgba.convert("RGBA").getchannel("A").point(lambda value: 255 if value >= 254 else 0)
43
- alpha = alpha.resize((1024, 1024), Image.Resampling.NEAREST)
44
- resized.save(destination / "condition_rgb.png")
45
- aligned = resized.copy()
46
- aligned.putalpha(alpha)
47
- aligned.save(destination / "condition_rgba.png")
48
- for name in ("albedo.png", "mra.png"):
49
- with Image.open(source / name) as image:
50
- if image.size != (1024, 1024):
51
- raise ValueError(f"{name} must have native dimensions 1024 by 1024")
52
- image.convert("RGB").save(destination / name)
53
- write_json(destination / "camera.json", camera)
54
- return bindings
55
-
56
-
57
- def _command(module: str, arguments: list[str], log: Path) -> None:
58
- command = [sys.executable, "-m", f"supratex.inference.reprojection.{module}", *arguments]
59
- with log.open("x", encoding="utf-8") as stream:
60
- stream.write(json.dumps(command) + "\n")
61
- stream.flush()
62
- # Share the caller's process group so cancellation reaches GPU workers.
63
- process = subprocess.Popen(command, stdout=stream, stderr=subprocess.STDOUT)
64
- try:
65
- result = process.wait()
66
- except BaseException:
67
- process.terminate()
68
- process.wait()
69
- raise
70
- if result:
71
- raise RuntimeError(f"reprojection worker failed with exit code {result}; see {log}")
72
-
73
-
74
- def _run_side(baseline: Path, source: Path, output: Path, camera: dict[str, Any]) -> Path:
75
- """Publish one view only after the B and continuous candidates pass."""
76
- output.mkdir(parents=True)
77
- baseline_binding = bind_file(baseline)
78
- code = {path.name: bind_file(path) for path in Path(__file__).parent.glob("*.py")}
79
- inputs = output / "inputs_1024"
80
- source_bindings = _prepare_inputs(source, inputs, camera)
81
- input_bindings = {path.name: bind_file(path) for path in inputs.iterdir() if path.is_file()}
82
- renderer = Path(__file__).with_name("_renderer.py").resolve()
83
- project, continuous = output / "B", output / "continuous"
84
- _command("_project", [
85
- "--arm", "B", "--baseline-glb", str(baseline),
86
- "--source-albedo", str(inputs / "albedo.png"),
87
- "--source-rgba", str(inputs / "condition_rgba.png"),
88
- "--camera", str(inputs / "camera.json"), "--evaluator", str(renderer),
89
- "--output", str(project),
90
- ], output / "B.log")
91
- validate_candidate(project, continuous=False)
92
- manifest = output / "continuous_inputs.json"
93
- write_json(manifest, [{"label": "observation", "baseline_glb": str(baseline),
94
- "work": str(inputs), "b_output": str(project)}])
95
- _command("_continuous", [
96
- "--manifest", str(manifest), "--case", "observation",
97
- "--evaluator", str(renderer), "--output", str(continuous),
98
- ], output / "continuous.log")
99
- validate_candidate(continuous, continuous=True)
100
- for name, record in source_bindings.items():
101
- check_binding(record, source / name)
102
- check_binding(baseline_binding, baseline)
103
- for name, record in code.items():
104
- check_binding(record, Path(__file__).with_name(name))
105
- final = output / "mesh.glb"
106
- shutil.copyfile(continuous / "candidate" / "mesh.glb", final)
107
- artifacts = {str(path.relative_to(output)): bind_file(path) for path in (
108
- project / "PLAN.json", project / "RECEIPT.json", project / "RESULT.json",
109
- project / "COMMON_DOMAIN.npz", project / "candidate" / "mesh.glb",
110
- continuous / "PLAN.json", continuous / "RECEIPT.json", continuous / "RESULT.json",
111
- continuous / "GUTTER_DIAGNOSTICS.npz", continuous / "candidate" / "mesh.glb",
112
- manifest, final,
113
- )}
114
- receipt = {
115
- "profile": PROFILE, "status": "valid", "baseline": baseline_binding,
116
- "source_inputs": source_bindings, "aligned_inputs": input_bindings,
117
- "code": code, "artifacts": artifacts, "mesh": str(final),
118
- "gates": {"accepted": True, "B_accepted_all_19": True, "continuous_accepted_all_19": True},
119
- }
120
- write_json(output / "receipt.json", receipt)
121
- _validate_side(output)
122
- return final
123
-
124
-
125
- def _validate_side(output: Path) -> dict[str, Any]:
126
- import numpy as np
127
-
128
- receipt = read_json(output / "receipt.json")
129
- if receipt.get("profile") != PROFILE or receipt.get("status") != "valid":
130
- raise ValueError("invalid reprojection receipt profile or status")
131
- if not all(receipt.get("gates", {}).get(key) is True for key in ("accepted", "B_accepted_all_19", "continuous_accepted_all_19")):
132
- raise ValueError("reprojection side is missing mandatory gates")
133
- baseline = check_binding(receipt["baseline"])
134
- required_inputs = {"condition_rgb.png", "condition_rgba.png", "albedo.png", "mra.png"}
135
- if not required_inputs.issubset(receipt.get("source_inputs", {})):
136
- raise ValueError("reprojection side has incomplete source bindings")
137
- if set(receipt.get("aligned_inputs", {})) != required_inputs | {"camera.json"}:
138
- raise ValueError("reprojection side has incomplete aligned input bindings")
139
- for record in receipt["source_inputs"].values():
140
- check_binding(record)
141
- for name, record in receipt["aligned_inputs"].items():
142
- check_binding(record, output / "inputs_1024" / name)
143
- expected_code = {path.name for path in Path(__file__).parent.glob("*.py")}
144
- if set(receipt.get("code", {})) != expected_code:
145
- raise ValueError("reprojection code bindings are incomplete")
146
- for name, record in receipt["code"].items():
147
- check_binding(record, Path(__file__).with_name(name))
148
- expected_artifacts = {
149
- "B/PLAN.json", "B/RECEIPT.json", "B/RESULT.json", "B/COMMON_DOMAIN.npz", "B/candidate/mesh.glb",
150
- "continuous/PLAN.json", "continuous/RECEIPT.json", "continuous/RESULT.json",
151
- "continuous/GUTTER_DIAGNOSTICS.npz", "continuous/candidate/mesh.glb", "continuous_inputs.json", "mesh.glb",
152
- }
153
- if set(receipt.get("artifacts", {})) != expected_artifacts:
154
- raise ValueError("reprojection artifact bindings are incomplete")
155
- for name, record in receipt["artifacts"].items():
156
- check_binding(record, output / name)
157
- if Path(receipt.get("mesh", "")).resolve() != (output / "mesh.glb").resolve():
158
- raise ValueError("reprojection output mesh path changed")
159
- for name, continuous in (("B", False), ("continuous", True)):
160
- validate_candidate(output / name, continuous=continuous)
161
- bplan = read_json(output / "B" / "PLAN.json")
162
- for key, path in {"baseline_glb": baseline, "source_albedo": output / "inputs_1024" / "albedo.png",
163
- "source_rgba": output / "inputs_1024" / "condition_rgba.png",
164
- "camera": output / "inputs_1024" / "camera.json"}.items():
165
- if Path(bplan.get(key, "")).resolve() != path.resolve():
166
- raise ValueError(f"B plan has an incorrect {key} binding")
167
- base_doc, base_binary, base_view, _, _, base_pixels = atlas(baseline)
168
- for name in ("B/candidate/mesh.glb", "continuous/candidate/mesh.glb", "mesh.glb"):
169
- candidate = output / name
170
- _glb._nonbase_identity_check(base_doc, base_binary, candidate, base_view)
171
- pixels = atlas(candidate)[-1]
172
- if pixels.shape != base_pixels.shape or (pixels.shape[-1] == 4 and not np.array_equal(pixels[..., 3], base_pixels[..., 3])):
173
- raise ValueError("candidate altered atlas dimensions or alpha")
174
- if bind_file(output / "mesh.glb")["sha256"] != bind_file(output / "continuous/candidate/mesh.glb")["sha256"]:
175
- raise ValueError("side output differs from its accepted continuous candidate")
176
- return receipt
177
-
178
-
179
- def run_dual_view(native_glb: Path, front_input_dir: Path, back_input_dir: Path,
180
- cameras: dict[str, Any], output_dir: Path) -> Path:
181
- """Run both measured reprojections and publish the stitched final GLB.
182
-
183
- The caller owns geometry, generated-image, and material-model provenance.
184
- This function binds the provided model-independent maps, camera pack, native
185
- mesh, operator code, candidate gates, and final atlas in its own receipt.
186
- """
187
- native, front, back = (Path(path).resolve(strict=True) for path in (native_glb, front_input_dir, back_input_dir))
188
- output = Path(output_dir).resolve()
189
- raw = cameras.get("raw", cameras)
190
- front_camera, back_camera = camera_spec(raw, "front"), camera_spec(raw, "back")
191
- for path in (output / "post", output / "post_back"):
192
- if path.exists():
193
- raise FileExistsError(f"dual-view reprojection requires a new destination: {path}")
194
- output.mkdir(parents=True, exist_ok=True)
195
- final_dir = output / "post_back"
196
- final_dir.mkdir()
197
- try:
198
- write_json(final_dir / "cameras.json", raw)
199
- front_mesh = _run_side(native, front, output / "post", front_camera)
200
- rotation, translation, distance = back_to_front(raw["back"])
201
- rotated = final_dir / "rotated_baseline.glb"
202
- transform_glb(front_mesh, rotated, rotation, translation)
203
- canonical_back = {
204
- "extrinsic": [[1, 0, 0, 0], [0, -1, 0, 0], [0, 0, -1, distance], [0, 0, 0, 1]],
205
- "projection_intrinsic": back_camera["projection_intrinsic"], "distance": distance,
206
- "camera_angle_x": back_camera["camera_angle_x"], "mesh_scale": 1.0,
207
- }
208
- back_mesh = _run_side(rotated, back, final_dir / "reproject", canonical_back)
209
- protected = front_protection(output / "post" / "B" / "COMMON_DOMAIN.npz")
210
- final = final_dir / "mesh.glb"
211
- stitch_gates = stitch(front_mesh, back_mesh, protected, final)
212
- _validate_side(output / "post")
213
- _validate_side(final_dir / "reproject")
214
- receipt = {
215
- "profile": PROFILE, "status": "valid", "view": "dual", "mesh": str(final),
216
- "gates": {"accepted": True, "front_candidates_accepted_all_19": True,
217
- "back_candidates_accepted_all_19": True, **stitch_gates},
218
- "front_post_receipt": bind_file(output / "post" / "receipt.json"),
219
- "back_post_receipt": bind_file(final_dir / "reproject" / "receipt.json"),
220
- "camera_pack": bind_file(final_dir / "cameras.json"), "final_mesh": bind_file(final),
221
- "front_protection": bind_file(output / "post" / "B" / "COMMON_DOMAIN.npz"),
222
- "back_transform": {"rotation": rotation.tolist(), "translation": translation.tolist(), "distance": distance},
223
- "validation_scope": "Both view candidates passed 19-phase gates; the stitched atlas has exact front priority and preserved non-base content.",
224
- }
225
- write_json(final_dir / "receipt.json", receipt)
226
- validate_dual_view(output, raise_on_invalid=True)
227
- return final
228
- except BaseException as error:
229
- failure = final_dir / "FAILED.json"
230
- if not failure.exists():
231
- write_json(failure, {"profile": PROFILE, "status": "failed", "error": f"{type(error).__name__}: {error}"})
232
- raise
233
-
234
-
235
- def validate_dual_view(output_dir: Path, *, raise_on_invalid: bool = False) -> dict[str, Any]:
236
- """Re-read the complete postprocess receipt chain and content bindings."""
237
- try:
238
- output = Path(output_dir).resolve()
239
- receipt = read_json(output / "post_back" / "receipt.json")
240
- if (output / "post_back" / "FAILED.json").exists():
241
- raise ValueError("dual-view reprojection has a recorded failure")
242
- if receipt.get("profile") != PROFILE or receipt.get("status") != "valid" or receipt.get("view") != "dual":
243
- raise ValueError("invalid dual-view reprojection identity or status")
244
- gates = receipt.get("gates", {})
245
- required = ("accepted", "front_candidates_accepted_all_19", "back_candidates_accepted_all_19",
246
- "front_priority_exact", "alpha_exact", "position_exact", "normal_exact", "uv_exact")
247
- if not all(gates.get(key) is True for key in required) or gates.get("final_19_phase_rendered") is not False:
248
- raise ValueError("dual-view reprojection gates or validation scope are incomplete")
249
- check_binding(receipt["front_post_receipt"], output / "post" / "receipt.json")
250
- check_binding(receipt["back_post_receipt"], output / "post_back" / "reproject" / "receipt.json")
251
- check_binding(receipt["front_protection"], output / "post" / "B" / "COMMON_DOMAIN.npz")
252
- camera_path = check_binding(receipt["camera_pack"], output / "post_back" / "cameras.json")
253
- final = check_binding(receipt["final_mesh"], output / "post_back" / "mesh.glb")
254
- if Path(receipt.get("mesh", "")).resolve() != final:
255
- raise ValueError("dual-view final mesh path is inconsistent")
256
- cameras = read_json(camera_path)
257
- front_camera = camera_spec(cameras, "front")
258
- back_camera = camera_spec(cameras, "back")
259
- rotation, translation, distance = back_to_front(cameras["back"])
260
- expected_transform = {"rotation": rotation.tolist(), "translation": translation.tolist(), "distance": distance}
261
- if receipt.get("back_transform") != expected_transform:
262
- raise ValueError("back transform differs from the camera pack")
263
- if read_json(output / "post" / "inputs_1024" / "camera.json") != front_camera:
264
- raise ValueError("front postprocess did not use the bound front camera")
265
- expected_back = {
266
- "extrinsic": [[1, 0, 0, 0], [0, -1, 0, 0], [0, 0, -1, distance], [0, 0, 0, 1]],
267
- "projection_intrinsic": back_camera["projection_intrinsic"], "distance": distance,
268
- "camera_angle_x": back_camera["camera_angle_x"], "mesh_scale": 1.0,
269
- }
270
- if read_json(output / "post_back" / "reproject" / "inputs_1024" / "camera.json") != expected_back:
271
- raise ValueError("back postprocess did not use the canonicalized back camera")
272
- expected_document, expected_binary = transformed_payload(output / "post" / "mesh.glb", rotation, translation)
273
- actual_document, actual_binary = _glb._read_glb(output / "post_back" / "rotated_baseline.glb")
274
- if actual_document != expected_document or actual_binary != expected_binary:
275
- raise ValueError("temporary back mesh differs from the required rigid transform")
276
- _validate_side(output / "post")
277
- _validate_side(output / "post_back" / "reproject")
278
- protected = front_protection(output / "post" / "B" / "COMMON_DOMAIN.npz")
279
- actual = validate_stitch(output / "post" / "mesh.glb", output / "post_back" / "reproject" / "mesh.glb", final, protected)
280
- if any(gates.get(key) != value for key, value in actual.items()):
281
- raise ValueError("stitched preservation evidence changed")
282
- return {"valid": True, "reason": "Both candidate chains and the stitched final mesh are current", "receipt": receipt}
283
- except (OSError, ValueError, KeyError, TypeError, RuntimeError) as error:
284
- if raise_on_invalid:
285
- raise
286
- return {"valid": False, "reason": str(error)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Atlas reprojection with measured candidate acceptance gates.
2
+
3
+ Each reprojected view writes observed albedo into visible atlas texels, then
4
+ repairs one-texel UV gutters from the observed albedo gradient. Candidates
5
+ are re-rendered at nineteen validation phases (a 4x4 sub-pixel grid and three
6
+ off-grid offsets) and must pass every gate. Dual-view texturing
7
+ stitches front and back with exact front priority and binary preservation; the
8
+ stitched result is not itself subjected to another nineteen render phases.
9
+ Single-view texturing can reproject its reference view alone.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from . import _glb
21
+ from ._common import (
22
+ bind_file, check_binding, read_json, validate_candidate, write_json,
23
+ )
24
+ from ._transform import (
25
+ atlas, back_to_front, camera_spec, front_protection, stitch,
26
+ transform_glb, transformed_payload, validate_stitch,
27
+ )
28
+
29
+ __all__ = ["run_dual_view", "run_front_view", "validate_dual_view"]
30
+ PROFILE = "supratex.dual_view.reproject_continuous"
31
+
32
+
33
+ def _prepare_inputs(source: Path, destination: Path, camera: dict[str, Any]) -> dict[str, Any]:
34
+ """Align observation alpha and RGB once, preserving native albedo/MRA maps."""
35
+ from PIL import Image
36
+
37
+ names = ("condition_rgb.png", "condition_rgba.png", "albedo.png", "mra.png")
38
+ bindings = {name: bind_file(source / name) for name in names}
39
+ if (source / "camera.json").is_file():
40
+ bindings["camera.json"] = bind_file(source / "camera.json")
41
+ destination.mkdir(parents=True)
42
+ with Image.open(source / "condition_rgb.png") as rgb, Image.open(source / "condition_rgba.png") as rgba:
43
+ if rgb.size != rgba.size:
44
+ raise ValueError("condition RGB and RGBA dimensions must agree")
45
+ resized = rgb.convert("RGB").resize((1024, 1024), Image.Resampling.LANCZOS)
46
+ alpha = rgba.convert("RGBA").getchannel("A").point(lambda value: 255 if value >= 254 else 0)
47
+ alpha = alpha.resize((1024, 1024), Image.Resampling.NEAREST)
48
+ resized.save(destination / "condition_rgb.png")
49
+ aligned = resized.copy()
50
+ aligned.putalpha(alpha)
51
+ aligned.save(destination / "condition_rgba.png")
52
+ for name in ("albedo.png", "mra.png"):
53
+ with Image.open(source / name) as image:
54
+ if image.size != (1024, 1024):
55
+ raise ValueError(f"{name} must have native dimensions 1024 by 1024")
56
+ image.convert("RGB").save(destination / name)
57
+ write_json(destination / "camera.json", camera)
58
+ return bindings
59
+
60
+
61
+ def _command(module: str, arguments: list[str], log: Path) -> None:
62
+ command = [sys.executable, "-m", f"supratex.inference.reprojection.{module}", *arguments]
63
+ with log.open("x", encoding="utf-8") as stream:
64
+ stream.write(json.dumps(command) + "\n")
65
+ stream.flush()
66
+ # Share the caller's process group so cancellation reaches GPU workers.
67
+ process = subprocess.Popen(command, stdout=stream, stderr=subprocess.STDOUT)
68
+ try:
69
+ result = process.wait()
70
+ except BaseException:
71
+ process.terminate()
72
+ process.wait()
73
+ raise
74
+ if result:
75
+ raise RuntimeError(f"reprojection worker failed with exit code {result}; see {log}")
76
+
77
+
78
+ def _run_side(baseline: Path, source: Path, output: Path, camera: dict[str, Any]) -> Path:
79
+ """Publish one view only after the projection and continuous candidates pass."""
80
+ output.mkdir(parents=True)
81
+ baseline_binding = bind_file(baseline)
82
+ code = {path.name: bind_file(path) for path in Path(__file__).parent.glob("*.py")}
83
+ inputs = output / "inputs_1024"
84
+ source_bindings = _prepare_inputs(source, inputs, camera)
85
+ input_bindings = {path.name: bind_file(path) for path in inputs.iterdir() if path.is_file()}
86
+ renderer = Path(__file__).with_name("_renderer.py").resolve()
87
+ project, continuous = output / "project", output / "continuous"
88
+ _command("_project", [
89
+ "--baseline-glb", str(baseline),
90
+ "--source-albedo", str(inputs / "albedo.png"),
91
+ "--source-rgba", str(inputs / "condition_rgba.png"),
92
+ "--camera", str(inputs / "camera.json"), "--renderer", str(renderer),
93
+ "--output", str(project),
94
+ ], output / "project.log")
95
+ validate_candidate(project, continuous=False)
96
+ manifest = output / "continuous_inputs.json"
97
+ write_json(manifest, [{"label": "observation", "baseline_glb": str(baseline),
98
+ "work": str(inputs), "project_output": str(project)}])
99
+ _command("_continuous", [
100
+ "--manifest", str(manifest), "--case", "observation",
101
+ "--renderer", str(renderer), "--output", str(continuous),
102
+ ], output / "continuous.log")
103
+ validate_candidate(continuous, continuous=True)
104
+ for name, record in source_bindings.items():
105
+ check_binding(record, source / name)
106
+ check_binding(baseline_binding, baseline)
107
+ for name, record in code.items():
108
+ check_binding(record, Path(__file__).with_name(name))
109
+ final = output / "mesh.glb"
110
+ shutil.copyfile(continuous / "candidate" / "mesh.glb", final)
111
+ artifacts = {str(path.relative_to(output)): bind_file(path) for path in (
112
+ project / "PLAN.json", project / "RECEIPT.json", project / "RESULT.json",
113
+ project / "COMMON_DOMAIN.npz", project / "candidate" / "mesh.glb",
114
+ continuous / "PLAN.json", continuous / "RECEIPT.json", continuous / "RESULT.json",
115
+ continuous / "GUTTER_DIAGNOSTICS.npz", continuous / "candidate" / "mesh.glb",
116
+ manifest, final,
117
+ )}
118
+ receipt = {
119
+ "profile": PROFILE, "status": "valid", "baseline": baseline_binding,
120
+ "source_inputs": source_bindings, "aligned_inputs": input_bindings,
121
+ "code": code, "artifacts": artifacts, "mesh": str(final),
122
+ "gates": {"accepted": True, "project_accepted_all_19": True, "continuous_accepted_all_19": True},
123
+ }
124
+ write_json(output / "receipt.json", receipt)
125
+ _validate_side(output)
126
+ return final
127
+
128
+
129
+ def _validate_side(output: Path) -> dict[str, Any]:
130
+ import numpy as np
131
+
132
+ receipt = read_json(output / "receipt.json")
133
+ if receipt.get("profile") != PROFILE or receipt.get("status") != "valid":
134
+ raise ValueError("invalid reprojection receipt profile or status")
135
+ if not all(receipt.get("gates", {}).get(key) is True for key in ("accepted", "project_accepted_all_19", "continuous_accepted_all_19")):
136
+ raise ValueError("reprojection side is missing mandatory gates")
137
+ baseline = check_binding(receipt["baseline"])
138
+ required_inputs = {"condition_rgb.png", "condition_rgba.png", "albedo.png", "mra.png"}
139
+ if not required_inputs.issubset(receipt.get("source_inputs", {})):
140
+ raise ValueError("reprojection side has incomplete source bindings")
141
+ if set(receipt.get("aligned_inputs", {})) != required_inputs | {"camera.json"}:
142
+ raise ValueError("reprojection side has incomplete aligned input bindings")
143
+ for record in receipt["source_inputs"].values():
144
+ check_binding(record)
145
+ for name, record in receipt["aligned_inputs"].items():
146
+ check_binding(record, output / "inputs_1024" / name)
147
+ expected_code = {path.name for path in Path(__file__).parent.glob("*.py")}
148
+ if set(receipt.get("code", {})) != expected_code:
149
+ raise ValueError("reprojection code bindings are incomplete")
150
+ for name, record in receipt["code"].items():
151
+ check_binding(record, Path(__file__).with_name(name))
152
+ expected_artifacts = {
153
+ "project/PLAN.json", "project/RECEIPT.json", "project/RESULT.json", "project/COMMON_DOMAIN.npz",
154
+ "project/candidate/mesh.glb",
155
+ "continuous/PLAN.json", "continuous/RECEIPT.json", "continuous/RESULT.json",
156
+ "continuous/GUTTER_DIAGNOSTICS.npz", "continuous/candidate/mesh.glb", "continuous_inputs.json", "mesh.glb",
157
+ }
158
+ if set(receipt.get("artifacts", {})) != expected_artifacts:
159
+ raise ValueError("reprojection artifact bindings are incomplete")
160
+ for name, record in receipt["artifacts"].items():
161
+ check_binding(record, output / name)
162
+ if Path(receipt.get("mesh", "")).resolve() != (output / "mesh.glb").resolve():
163
+ raise ValueError("reprojection output mesh path changed")
164
+ for name, continuous in (("project", False), ("continuous", True)):
165
+ validate_candidate(output / name, continuous=continuous)
166
+ project_plan = read_json(output / "project" / "PLAN.json")
167
+ expected_plan = {
168
+ "baseline_glb": baseline,
169
+ "source_albedo": output / "inputs_1024" / "albedo.png",
170
+ "source_rgba": output / "inputs_1024" / "condition_rgba.png",
171
+ "camera": output / "inputs_1024" / "camera.json",
172
+ }
173
+ for key, path in expected_plan.items():
174
+ if Path(project_plan.get(key, "")).resolve() != path.resolve():
175
+ raise ValueError(f"projection plan has an incorrect {key} binding")
176
+ base_doc, base_binary, base_view, _, _, base_pixels = atlas(baseline)
177
+ for name in ("project/candidate/mesh.glb", "continuous/candidate/mesh.glb", "mesh.glb"):
178
+ candidate = output / name
179
+ _glb._nonbase_identity_check(base_doc, base_binary, candidate, base_view)
180
+ pixels = atlas(candidate)[-1]
181
+ if pixels.shape != base_pixels.shape or (pixels.shape[-1] == 4 and not np.array_equal(pixels[..., 3], base_pixels[..., 3])):
182
+ raise ValueError("candidate altered atlas dimensions or alpha")
183
+ if bind_file(output / "mesh.glb")["sha256"] != bind_file(output / "continuous/candidate/mesh.glb")["sha256"]:
184
+ raise ValueError("side output differs from its accepted continuous candidate")
185
+ return receipt
186
+
187
+
188
+ def run_front_view(native_glb: Path, input_dir: Path, output_dir: Path) -> Path:
189
+ """Reproject the single reference view onto a single-view result.
190
+
191
+ This is the front half of dual-view texturing with the canonical reference
192
+ camera from ``camera.json``. Inputs are staged under ``output_dir`` so their
193
+ bindings stay valid; a missing ``condition_rgb.png`` is derived from the
194
+ RGBA observation with black premultiplication, as the frontend writes it.
195
+ """
196
+ import numpy as np
197
+ from PIL import Image
198
+
199
+ native, source = (Path(path).resolve(strict=True) for path in (native_glb, input_dir))
200
+ output = Path(output_dir).resolve()
201
+ if output.exists():
202
+ raise FileExistsError(f"front reprojection requires a new destination: {output}")
203
+ camera = read_json(source / "camera.json")
204
+ try:
205
+ camera = {"camera_angle_x": float(camera["camera_angle_x"]), "distance": float(camera["distance"]),
206
+ "mesh_scale": float(camera.get("mesh_scale", 1.0))}
207
+ except (KeyError, TypeError, ValueError) as error:
208
+ raise ValueError("front reprojection requires a canonical camera with camera_angle_x and distance") from error
209
+ staged = output / "source"
210
+ staged.mkdir(parents=True)
211
+ for name in ("condition_rgba.png", "albedo.png", "mra.png", "camera.json"):
212
+ shutil.copyfile(source / name, staged / name)
213
+ if (source / "condition_rgb.png").is_file():
214
+ shutil.copyfile(source / "condition_rgb.png", staged / "condition_rgb.png")
215
+ else:
216
+ with Image.open(source / "condition_rgba.png") as image:
217
+ rgba = np.asarray(image.convert("RGBA"), dtype=np.float32) / 255.
218
+ Image.fromarray((np.clip(rgba[..., :3] * rgba[..., 3:4], 0, 1) * 255).astype(np.uint8)).save(staged / "condition_rgb.png")
219
+ return _run_side(native, staged, output / "front", camera)
220
+
221
+
222
+ def run_dual_view(native_glb: Path, front_input_dir: Path, back_input_dir: Path,
223
+ cameras: dict[str, Any], output_dir: Path) -> Path:
224
+ """Run both measured reprojections and publish the stitched final GLB.
225
+
226
+ The caller owns geometry, generated-image, and material-model provenance.
227
+ This function binds the provided model-independent maps, camera pack, native
228
+ mesh, reprojection code, candidate gates, and final atlas in its own receipt.
229
+ """
230
+ native, front, back = (Path(path).resolve(strict=True) for path in (native_glb, front_input_dir, back_input_dir))
231
+ output = Path(output_dir).resolve()
232
+ raw = cameras.get("raw", cameras)
233
+ front_camera, back_camera = camera_spec(raw, "front"), camera_spec(raw, "back")
234
+ for path in (output / "post", output / "post_back"):
235
+ if path.exists():
236
+ raise FileExistsError(f"dual-view reprojection requires a new destination: {path}")
237
+ output.mkdir(parents=True, exist_ok=True)
238
+ final_dir = output / "post_back"
239
+ final_dir.mkdir()
240
+ try:
241
+ write_json(final_dir / "cameras.json", raw)
242
+ front_mesh = _run_side(native, front, output / "post", front_camera)
243
+ rotation, translation, distance = back_to_front(raw["back"])
244
+ rotated = final_dir / "rotated_baseline.glb"
245
+ transform_glb(front_mesh, rotated, rotation, translation)
246
+ canonical_back = {
247
+ "extrinsic": [[1, 0, 0, 0], [0, -1, 0, 0], [0, 0, -1, distance], [0, 0, 0, 1]],
248
+ "projection_intrinsic": back_camera["projection_intrinsic"], "distance": distance,
249
+ "camera_angle_x": back_camera["camera_angle_x"], "mesh_scale": 1.0,
250
+ }
251
+ back_mesh = _run_side(rotated, back, final_dir / "reproject", canonical_back)
252
+ protected = front_protection(output / "post" / "project" / "COMMON_DOMAIN.npz")
253
+ final = final_dir / "mesh.glb"
254
+ stitch_gates = stitch(front_mesh, back_mesh, protected, final)
255
+ _validate_side(output / "post")
256
+ _validate_side(final_dir / "reproject")
257
+ receipt = {
258
+ "profile": PROFILE, "status": "valid", "view": "dual", "mesh": str(final),
259
+ "gates": {"accepted": True, "front_candidates_accepted_all_19": True,
260
+ "back_candidates_accepted_all_19": True, **stitch_gates},
261
+ "front_post_receipt": bind_file(output / "post" / "receipt.json"),
262
+ "back_post_receipt": bind_file(final_dir / "reproject" / "receipt.json"),
263
+ "camera_pack": bind_file(final_dir / "cameras.json"), "final_mesh": bind_file(final),
264
+ "front_protection": bind_file(output / "post" / "project" / "COMMON_DOMAIN.npz"),
265
+ "back_transform": {"rotation": rotation.tolist(), "translation": translation.tolist(), "distance": distance},
266
+ "validation_scope": "Both view candidates passed 19-phase gates; the stitched atlas has exact front priority and preserved non-base content.",
267
+ }
268
+ write_json(final_dir / "receipt.json", receipt)
269
+ validate_dual_view(output, raise_on_invalid=True)
270
+ return final
271
+ except BaseException as error:
272
+ failure = final_dir / "FAILED.json"
273
+ if not failure.exists():
274
+ write_json(failure, {"profile": PROFILE, "status": "failed", "error": f"{type(error).__name__}: {error}"})
275
+ raise
276
+
277
+
278
+ def validate_dual_view(output_dir: Path, *, raise_on_invalid: bool = False) -> dict[str, Any]:
279
+ """Re-read the complete postprocess receipt chain and content bindings."""
280
+ try:
281
+ output = Path(output_dir).resolve()
282
+ receipt = read_json(output / "post_back" / "receipt.json")
283
+ if (output / "post_back" / "FAILED.json").exists():
284
+ raise ValueError("dual-view reprojection has a recorded failure")
285
+ if receipt.get("profile") != PROFILE or receipt.get("status") != "valid" or receipt.get("view") != "dual":
286
+ raise ValueError("invalid dual-view reprojection identity or status")
287
+ gates = receipt.get("gates", {})
288
+ required = ("accepted", "front_candidates_accepted_all_19", "back_candidates_accepted_all_19",
289
+ "front_priority_exact", "alpha_exact", "position_exact", "normal_exact", "uv_exact")
290
+ if not all(gates.get(key) is True for key in required) or gates.get("final_19_phase_rendered") is not False:
291
+ raise ValueError("dual-view reprojection gates or validation scope are incomplete")
292
+ check_binding(receipt["front_post_receipt"], output / "post" / "receipt.json")
293
+ check_binding(receipt["back_post_receipt"], output / "post_back" / "reproject" / "receipt.json")
294
+ check_binding(receipt["front_protection"], output / "post" / "project" / "COMMON_DOMAIN.npz")
295
+ camera_path = check_binding(receipt["camera_pack"], output / "post_back" / "cameras.json")
296
+ final = check_binding(receipt["final_mesh"], output / "post_back" / "mesh.glb")
297
+ if Path(receipt.get("mesh", "")).resolve() != final:
298
+ raise ValueError("dual-view final mesh path is inconsistent")
299
+ cameras = read_json(camera_path)
300
+ front_camera = camera_spec(cameras, "front")
301
+ back_camera = camera_spec(cameras, "back")
302
+ rotation, translation, distance = back_to_front(cameras["back"])
303
+ expected_transform = {"rotation": rotation.tolist(), "translation": translation.tolist(), "distance": distance}
304
+ if receipt.get("back_transform") != expected_transform:
305
+ raise ValueError("back transform differs from the camera pack")
306
+ if read_json(output / "post" / "inputs_1024" / "camera.json") != front_camera:
307
+ raise ValueError("front postprocess did not use the bound front camera")
308
+ expected_back = {
309
+ "extrinsic": [[1, 0, 0, 0], [0, -1, 0, 0], [0, 0, -1, distance], [0, 0, 0, 1]],
310
+ "projection_intrinsic": back_camera["projection_intrinsic"], "distance": distance,
311
+ "camera_angle_x": back_camera["camera_angle_x"], "mesh_scale": 1.0,
312
+ }
313
+ if read_json(output / "post_back" / "reproject" / "inputs_1024" / "camera.json") != expected_back:
314
+ raise ValueError("back postprocess did not use the canonicalized back camera")
315
+ expected_document, expected_binary = transformed_payload(output / "post" / "mesh.glb", rotation, translation)
316
+ actual_document, actual_binary = _glb._read_glb(output / "post_back" / "rotated_baseline.glb")
317
+ if actual_document != expected_document or actual_binary != expected_binary:
318
+ raise ValueError("temporary back mesh differs from the required rigid transform")
319
+ _validate_side(output / "post")
320
+ _validate_side(output / "post_back" / "reproject")
321
+ protected = front_protection(output / "post" / "project" / "COMMON_DOMAIN.npz")
322
+ actual = validate_stitch(output / "post" / "mesh.glb", output / "post_back" / "reproject" / "mesh.glb", final, protected)
323
+ if any(gates.get(key) != value for key, value in actual.items()):
324
+ raise ValueError("stitched preservation evidence changed")
325
+ return {"valid": True, "reason": "Both candidate chains and the stitched final mesh are current", "receipt": receipt}
326
+ except (OSError, ValueError, KeyError, TypeError, RuntimeError) as error:
327
+ if raise_on_invalid:
328
+ raise
329
+ return {"valid": False, "reason": str(error)}
supratex/inference/reprojection/_common.py CHANGED
@@ -1,115 +1,117 @@
1
- """File bindings and measured acceptance checks shared by atlas operators."""
2
- from __future__ import annotations
3
-
4
- import hashlib
5
- import json
6
- import math
7
- import os
8
- from pathlib import Path
9
- from typing import Any, Mapping
10
-
11
- OUTSIDE_COLOR_MAX_ABS = 5 / 255.0
12
- REPLAY_MAX_ABS = 1e-5
13
- TRAIN = tuple((x / 4, y / 4) for x in range(4) for y in range(4))
14
- HELD = ((.125, .375), (.375, .625), (.875, .125))
15
- IDENTITY_GATES = (
16
- "materials_exact", "mesh_structure_exact", "node_structure_exact",
17
- "nonbase_buffer_views_exact",
18
- )
19
-
20
-
21
- def outside_color_passes(value: float) -> bool:
22
- return math.isfinite(value) and 0 <= value <= OUTSIDE_COLOR_MAX_ABS
23
-
24
-
25
- def write_json(path: Path, value: Any) -> None:
26
- """Publish one new JSON artifact without overwriting an earlier result."""
27
- if path.exists():
28
- raise FileExistsError(f"refusing to overwrite {path}")
29
- path.parent.mkdir(parents=True, exist_ok=True)
30
- temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
31
- temporary.write_text(json.dumps(value, indent=2, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8")
32
- os.replace(temporary, path)
33
-
34
-
35
- def read_json(path: Path) -> dict[str, Any]:
36
- value = json.loads(path.read_text(encoding="utf-8"))
37
- if not isinstance(value, dict):
38
- raise ValueError(f"expected a JSON object: {path}")
39
- return value
40
-
41
-
42
- def bind_file(path: Path) -> dict[str, Any]:
43
- path = path.resolve(strict=True)
44
- if not path.is_file():
45
- raise ValueError(f"expected a regular file: {path}")
46
- digest = hashlib.sha256()
47
- with path.open("rb") as stream:
48
- for block in iter(lambda: stream.read(1024 * 1024), b""):
49
- digest.update(block)
50
- return {"path": str(path), "bytes": path.stat().st_size, "sha256": digest.hexdigest()}
51
-
52
-
53
- def check_binding(record: Mapping[str, Any], path: Path | None = None) -> Path:
54
- bound = Path(record["path"])
55
- if path is not None and bound.resolve() != path.resolve():
56
- raise ValueError(f"artifact path binding changed: {path}")
57
- if dict(record) != bind_file(bound):
58
- raise ValueError(f"artifact content binding changed: {bound}")
59
- return bound
60
-
61
-
62
- def load_renderer(path: Path):
63
- """Only the renderer shipped in this package may establish these gates."""
64
- from . import _renderer
65
- if path.resolve() != Path(_renderer.__file__).resolve():
66
- raise ValueError("reprojection requires the bundled SupraTex renderer")
67
- return _renderer
68
-
69
-
70
- def validate_candidate(directory: Path, *, continuous: bool) -> dict[str, Any]:
71
- """Read every phase receipt and validate numerical gates, not just status."""
72
- receipt = read_json(directory / "RECEIPT.json")
73
- if read_json(directory / "RESULT.json") != receipt:
74
- raise ValueError("candidate result and receipt disagree")
75
- if receipt.get("status") != "valid":
76
- raise ValueError(f"candidate was not accepted: {directory}")
77
- gates = receipt.get("gates", {})
78
- if gates.get("accepted") is not True or gates.get("all_19_phase_renders") is not True:
79
- raise ValueError("candidate lacks complete acceptance gates")
80
- if not all(gates.get("nonbase_identity", {}).get(key) is True for key in IDENTITY_GATES):
81
- raise ValueError("candidate lacks non-base preservation gates")
82
- if continuous:
83
- if not all(gates.get(key) is True for key in ("UV_covered_exact", "B_active_exact", "blocked_exact", "alpha_exact")):
84
- raise ValueError("continuous candidate lacks atlas preservation gates")
85
- elif receipt.get("atlas", {}).get("alpha_exact") is not True:
86
- raise ValueError("projection candidate did not preserve alpha")
87
- candidate = directory / "candidate" / "mesh.glb"
88
- if Path(receipt.get("candidate_glb", "")).resolve() != candidate.resolve() or not candidate.is_file():
89
- raise ValueError("candidate mesh binding is missing or incorrect")
90
- for split, expected in (("train", TRAIN), ("heldout", HELD)):
91
- rows = receipt.get("phase_results", {}).get(split, [])
92
- if len(rows) != len(expected):
93
- raise ValueError(f"incomplete {split} phase coverage")
94
- for phase, row in zip(expected, rows):
95
- if row.get("phase") != list(phase) or row.get("passes") is not True:
96
- raise ValueError("phase coordinates or acceptance changed")
97
- replay = float(row["quantized_atlas_replay_max"])
98
- if not math.isfinite(replay) or not 0 <= replay <= REPLAY_MAX_ABS:
99
- raise ValueError("renderer replay gate failed")
100
- if row.get("outside_source_hardtrust_max_abs_threshold") != OUTSIDE_COLOR_MAX_ABS:
101
- raise ValueError("outside-source color threshold changed")
102
- if not outside_color_passes(float(row["outside_source_hardtrust_delta_max_abs"])):
103
- raise ValueError("outside-source color gate failed")
104
- if row.get("mask_exact") is not True or row.get("mra_exact") is not True:
105
- raise ValueError("phase mask or material preservation gate is missing")
106
- folder = directory / "renders" / split / f"x{phase[0]:.3f}_y{phase[1]:.3f}"
107
- if read_json(folder / "PHASE_RECEIPT.json") != row:
108
- raise ValueError("phase detail differs from candidate receipt")
109
- evidence = row.get("evidence", {})
110
- names = ("repair", "B") if continuous else ("candidate", "baseline")
111
- if set(evidence) != set(names):
112
- raise ValueError("phase lacks its two rendered evidence arrays")
113
- for name in names:
114
- check_binding(evidence[name], folder / f"{name}.npz")
115
- return receipt
 
 
 
1
+ """File bindings and measured acceptance checks shared by atlas operators."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import math
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any, Mapping
10
+
11
+ OUTSIDE_COLOR_MAX_ABS = 5 / 255.0
12
+ REPLAY_MAX_ABS = 1e-5
13
+ # Validation render phases, as sub-pixel camera offsets: a 4x4 grid plus three
14
+ # off-grid points. Both operators render every candidate at all nineteen.
15
+ GRID_PHASES = tuple((x / 4, y / 4) for x in range(4) for y in range(4))
16
+ OFFGRID_PHASES = ((.125, .375), (.375, .625), (.875, .125))
17
+ IDENTITY_GATES = (
18
+ "materials_exact", "mesh_structure_exact", "node_structure_exact",
19
+ "nonbase_buffer_views_exact",
20
+ )
21
+
22
+
23
+ def outside_color_passes(value: float) -> bool:
24
+ return math.isfinite(value) and 0 <= value <= OUTSIDE_COLOR_MAX_ABS
25
+
26
+
27
+ def write_json(path: Path, value: Any) -> None:
28
+ """Publish one new JSON artifact without overwriting an earlier result."""
29
+ if path.exists():
30
+ raise FileExistsError(f"refusing to overwrite {path}")
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
33
+ temporary.write_text(json.dumps(value, indent=2, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8")
34
+ os.replace(temporary, path)
35
+
36
+
37
+ def read_json(path: Path) -> dict[str, Any]:
38
+ value = json.loads(path.read_text(encoding="utf-8"))
39
+ if not isinstance(value, dict):
40
+ raise ValueError(f"expected a JSON object: {path}")
41
+ return value
42
+
43
+
44
+ def bind_file(path: Path) -> dict[str, Any]:
45
+ path = path.resolve(strict=True)
46
+ if not path.is_file():
47
+ raise ValueError(f"expected a regular file: {path}")
48
+ digest = hashlib.sha256()
49
+ with path.open("rb") as stream:
50
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
51
+ digest.update(block)
52
+ return {"path": str(path), "bytes": path.stat().st_size, "sha256": digest.hexdigest()}
53
+
54
+
55
+ def check_binding(record: Mapping[str, Any], path: Path | None = None) -> Path:
56
+ bound = Path(record["path"])
57
+ if path is not None and bound.resolve() != path.resolve():
58
+ raise ValueError(f"artifact path binding changed: {path}")
59
+ if dict(record) != bind_file(bound):
60
+ raise ValueError(f"artifact content binding changed: {bound}")
61
+ return bound
62
+
63
+
64
+ def load_renderer(path: Path):
65
+ """Only the renderer shipped in this package may establish these gates."""
66
+ from . import _renderer
67
+ if path.resolve() != Path(_renderer.__file__).resolve():
68
+ raise ValueError("reprojection requires the bundled SupraTex renderer")
69
+ return _renderer
70
+
71
+
72
+ def validate_candidate(directory: Path, *, continuous: bool) -> dict[str, Any]:
73
+ """Read every phase receipt and validate numerical gates, not just status."""
74
+ receipt = read_json(directory / "RECEIPT.json")
75
+ if read_json(directory / "RESULT.json") != receipt:
76
+ raise ValueError("candidate result and receipt disagree")
77
+ if receipt.get("status") != "valid":
78
+ raise ValueError(f"candidate was not accepted: {directory}")
79
+ gates = receipt.get("gates", {})
80
+ if gates.get("accepted") is not True or gates.get("all_19_phase_renders") is not True:
81
+ raise ValueError("candidate lacks complete acceptance gates")
82
+ if not all(gates.get("nonbase_identity", {}).get(key) is True for key in IDENTITY_GATES):
83
+ raise ValueError("candidate lacks non-base preservation gates")
84
+ if continuous:
85
+ if not all(gates.get(key) is True for key in ("UV_covered_exact", "project_active_exact", "blocked_exact", "alpha_exact")):
86
+ raise ValueError("continuous candidate lacks atlas preservation gates")
87
+ elif receipt.get("atlas", {}).get("alpha_exact") is not True:
88
+ raise ValueError("projection candidate did not preserve alpha")
89
+ candidate = directory / "candidate" / "mesh.glb"
90
+ if Path(receipt.get("candidate_glb", "")).resolve() != candidate.resolve() or not candidate.is_file():
91
+ raise ValueError("candidate mesh binding is missing or incorrect")
92
+ for split, expected in (("grid", GRID_PHASES), ("offgrid", OFFGRID_PHASES)):
93
+ rows = receipt.get("phase_results", {}).get(split, [])
94
+ if len(rows) != len(expected):
95
+ raise ValueError(f"incomplete {split} phase coverage")
96
+ for phase, row in zip(expected, rows):
97
+ if row.get("phase") != list(phase) or row.get("passes") is not True:
98
+ raise ValueError("phase coordinates or acceptance changed")
99
+ replay = float(row["quantized_atlas_replay_max"])
100
+ if not math.isfinite(replay) or not 0 <= replay <= REPLAY_MAX_ABS:
101
+ raise ValueError("renderer replay gate failed")
102
+ if row.get("outside_source_hardtrust_max_abs_threshold") != OUTSIDE_COLOR_MAX_ABS:
103
+ raise ValueError("outside-source color threshold changed")
104
+ if not outside_color_passes(float(row["outside_source_hardtrust_delta_max_abs"])):
105
+ raise ValueError("outside-source color gate failed")
106
+ if row.get("mask_exact") is not True or row.get("mra_exact") is not True:
107
+ raise ValueError("phase mask or material preservation gate is missing")
108
+ folder = directory / "renders" / split / f"x{phase[0]:.3f}_y{phase[1]:.3f}"
109
+ if read_json(folder / "PHASE_RECEIPT.json") != row:
110
+ raise ValueError("phase detail differs from candidate receipt")
111
+ evidence = row.get("evidence", {})
112
+ names = ("repair", "project") if continuous else ("candidate", "baseline")
113
+ if set(evidence) != set(names):
114
+ raise ValueError("phase lacks its two rendered evidence arrays")
115
+ for name in names:
116
+ check_binding(evidence[name], folder / f"{name}.npz")
117
+ return receipt
supratex/inference/reprojection/_continuous.py CHANGED
@@ -1,389 +1,514 @@
1
- """Repair one-texel UV gutters with observed continuous albedo gradients."""
2
- from __future__ import annotations
3
- import argparse, copy, io, json, os, shutil, sys, time
4
- from pathlib import Path
5
- from ._common import OUTSIDE_COLOR_MAX_ABS, outside_color_passes, load_renderer, bind_file
6
- from . import _glb as fixed
7
- SIZE = 4096
8
- RES = 1024
9
- TRAIN = tuple(((x / 4, y / 4) for x in range(4) for y in range(4)))
10
- HELD = ((0.125, 0.375), (0.375, 0.625), (0.875, 0.125))
11
- REJECT_UNSAFE = 1
12
- REJECT_CHART = 2
13
- REJECT_DEGENERATE_UV = 4
14
- REJECT_SOURCE = 8
15
-
16
- def write_once(path, value):
17
- if path.exists():
18
- raise FileExistsError(path)
19
- path.parent.mkdir(parents=True, exist_ok=True)
20
- path.write_text(json.dumps(value, indent=2, sort_keys=True, allow_nan=False) + '\n')
21
-
22
- def require(path, label):
23
- path = Path(path).absolute()
24
- if not path.is_file():
25
- raise FileNotFoundError(f'{label}: {path}')
26
- return path
27
-
28
- def selected_case(manifest, label):
29
- found = [x for x in json.loads(require(manifest, 'manifest').read_text()) if x.get('label') == label]
30
- if len(found) != 1:
31
- raise ValueError(f'expected one manifest case for {label!r}')
32
- return found[0]
33
-
34
- def assert_skew_barycentric_oracle(np):
35
- """Catch row/column and signed-area errors on a non-axis-aligned triangle."""
36
- tri = np.asarray(((0.11, 0.17), (0.83, 0.29), (0.26, 0.91)), np.float64)
37
- expected = np.asarray((0.19, 0.34, 0.47), np.float64)
38
- point = (tri * expected[:, None]).sum(0)
39
- basis = tri[:2] - tri[2]
40
- solved = np.linalg.solve(basis.T, point - tri[2])
41
- recovered = np.asarray((solved[0], solved[1], 1 - solved.sum()))
42
- if not np.allclose(recovered, expected, rtol=0, atol=1e-12):
43
- raise RuntimeError('skew barycentric oracle failed')
44
-
45
- def main():
46
- ap = argparse.ArgumentParser(description=__doc__)
47
- ap.add_argument('--manifest', type=Path, required=True)
48
- ap.add_argument('--case', required=True)
49
- ap.add_argument('--evaluator', type=Path, required=True)
50
- ap.add_argument('--output', type=Path, required=True)
51
- ap.add_argument('--gutter-radius', type=int, choices=(1,), default=1, help='Only one-texel gutter repair is supported.')
52
- a = ap.parse_args()
53
- if a.output.exists():
54
- raise FileExistsError(a.output)
55
- case = selected_case(a.manifest, a.case)
56
- evaluator = require(a.evaluator, 'evaluator')
57
- (work, bdir) = (Path(case['work']).absolute(), Path(case['b_output']).absolute())
58
- baseline = require(case['baseline_glb'], 'baseline GLB')
59
- bglb = require(bdir / 'candidate/mesh.glb', 'accepted B GLB')
60
- camera_path = require(work / 'camera.json', 'camera')
61
- rgba_path = require(work / 'condition_rgba.png', 'condition RGBA')
62
- albedo_path = require(work / 'albedo.png', 'predicted albedo')
63
- domain_path = require(bdir / 'COMMON_DOMAIN.npz', 'B common domain')
64
- bplan = json.loads(require(bdir / 'PLAN.json', 'B PLAN').read_text())
65
- bresult = json.loads(require(bdir / 'RESULT.json', 'B RESULT').read_text())
66
- expected = {'arm': 'B', 'baseline_glb': str(baseline), 'camera': str(camera_path), 'source_albedo': str(albedo_path), 'source_rgba': str(rgba_path), 'evaluator': str(evaluator)}
67
- if [k for (k, v) in expected.items() if bplan.get(k) != v]:
68
- raise RuntimeError('B PLAN binding failed')
69
- if bresult.get('status') != 'valid' or not bresult.get('gates', {}).get('accepted'):
70
- raise RuntimeError('B source is not an accepted B result')
71
- if Path(bresult.get('candidate_glb', '')).absolute() != bglb:
72
- raise RuntimeError('B result candidate binding failed')
73
- identity = bresult.get('gates', {}).get('nonbase_identity', {})
74
- if not all((identity.get(k) for k in ('materials_exact', 'mesh_structure_exact', 'node_structure_exact', 'nonbase_buffer_views_exact'))):
75
- raise RuntimeError('B non-base identity gate failed')
76
- phases = bresult.get('phase_results', {}).get('train', []) + bresult.get('phase_results', {}).get('heldout', [])
77
- if not bresult.get('gates', {}).get('all_19_phase_renders') or not bresult.get('atlas', {}).get('alpha_exact') or len(phases) != 19 or (not all((x.get('passes') for x in phases))):
78
- raise RuntimeError('B alpha or 19-phase receipt binding failed')
79
- a.output.mkdir(parents=True)
80
- shutil.copyfile(Path(__file__), a.output / 'CODE_SNAPSHOT.py')
81
- write_once(a.output / 'PLAN.json', {'schema': 'supratex.reprojection.continuous', 'case': a.case, 'manifest': str(a.manifest.absolute()), 'accepted_b_glb': str(bglb), 'baseline_glb': str(baseline), 'camera': str(camera_path), 'evaluator': str(evaluator), 'source_albedo': str(albedo_path), 'source_rgba': str(rgba_path), 'common_domain': str(domain_path), 'train_phases': TRAIN, 'heldout_phases': HELD, 'gutter_radius': a.gutter_radius, 'operator': 'anchor each gutter to its accepted-B nearest covered donor RGB, then add the continuous predicted-albedo projection gradient from donor centre to extrapolated gutter centre', 'preservation': 'every UV-covered texel, blocked ID, alpha, mesh and all non-base GLB content remain exact', 'source_guard': 'requires positive camera depth, a finite non-degenerate UV face, bounded extrapolated barycentrics, and all four native source bilinear footprint pixels hard-trusted for both donor and extrapolated gutter samples', 'unsafe': 'all bilinear atlas taps of non-hard-trusted pixels from all 19 declared phases are excluded before writes', 'limits': 'Construction uses train and held-out phases for safety; held-out render is a validation phase, not independent selection.'})
82
- os.environ['FLEX_GEMM_USE_AUTOTUNE_CACHE'] = '0'
83
- os.environ['FLEX_GEMM_AUTOSAVE_AUTOTUNE_CACHE'] = '0'
84
- import cv2
85
- import numpy as np
86
- import torch
87
- from PIL import Image
88
- assert_skew_barycentric_oracle(np)
89
- torch.backends.cuda.matmul.allow_tf32 = False
90
- torch.manual_seed(42)
91
- started = time.monotonic()
92
- rm = load_renderer(evaluator)
93
- rt = rm._runtime_imports()
94
- dr = rt['dr']
95
- camera = json.loads(camera_path.read_text())
96
- (bdoc, bbin) = fixed._read_glb(bglb)
97
- fixed._assert_fixed_scene(bdoc)
98
- (ii, vi) = fixed._base_color_image_ref(bdoc)
99
- (bimg, _, off, n) = fixed._decode_base_image(bdoc, bbin, ii, vi)
100
- before = np.asarray(bimg).copy()
101
- (basedoc, basebin) = fixed._read_glb(baseline)
102
- fixed._assert_fixed_scene(basedoc)
103
- (baseii, basevi) = fixed._base_color_image_ref(basedoc)
104
- (baseimg, _, _, _) = fixed._decode_base_image(basedoc, basebin, baseii, basevi)
105
- base_before = np.asarray(baseimg).copy()
106
- if before.shape[:2] != (SIZE, SIZE):
107
- raise ValueError('requires 4096 atlas')
108
- if base_before.shape != before.shape:
109
- raise RuntimeError('baseline/B baseColor image shape mismatch')
110
- with np.load(domain_path, allow_pickle=False) as z:
111
- (active_ids, blocked_ids) = (z['active_texel_ids'].astype(np.int64), z['blocked_texel_ids'].astype(np.int64))
112
- if len(active_ids) != int(bresult.get('atlas', {}).get('active_texels', -1)) or len(blocked_ids) != int(bresult.get('atlas', {}).get('blocked_texels', -1)) or len(np.unique(active_ids)) != len(active_ids) or (len(np.unique(blocked_ids)) != len(blocked_ids)):
113
- raise RuntimeError('B common-domain count or uniqueness binding failed')
114
- (active, blocked) = (np.zeros(SIZE * SIZE, bool), np.zeros(SIZE * SIZE, bool))
115
- (active[active_ids], blocked[blocked_ids]) = (True, True)
116
- if np.any(blocked & ~active):
117
- raise RuntimeError('blocked IDs must be in B active domain')
118
- (vertices, faces_np, _, uv_np, _, _, factor, _, _) = rt['_load_textured_mesh'](bglb, rt)
119
- if not np.allclose(factor, 1):
120
- raise RuntimeError('unexpected baseColorFactor')
121
- exported = torch.as_tensor(vertices, dtype=torch.float32, device='cuda')
122
- native = exported * torch.tensor((-1.0, 1.0, -1.0), device='cuda')
123
- faces = torch.as_tensor(faces_np, dtype=torch.int32, device='cuda').contiguous()
124
- uv = torch.as_tensor(uv_np, dtype=torch.float32, device='cuda')
125
- uv = torch.stack((uv[:, 0], 1 - uv[:, 1]), -1).contiguous()
126
- rgba = np.asarray(Image.open(rgba_path).convert('RGBA'))
127
- hard = cv2.erode((rgba[..., 3] >= 254).astype('uint8'), np.ones((5, 5), np.uint8)).astype(bool)
128
- source = np.asarray(Image.open(albedo_path).convert('RGB'), np.float32) / 255
129
- if hard.shape != (RES, RES) or source.shape[:2] != (RES, RES):
130
- raise ValueError('source inputs must be aligned 1024')
131
- hard_t = torch.as_tensor(hard, device='cuda')
132
- src = torch.as_tensor(source, device='cuda').permute(2, 0, 1)[None]
133
- trust = np.clip(cv2.distanceTransform(hard.astype('uint8'), cv2.DIST_L2, 3) / 4.0, 0, 1)
134
- trust_t = torch.as_tensor(trust, dtype=torch.float32, device='cuda')[None, None]
135
- (E, K) = rm._native_camera(camera, 'cuda', rt)
136
- original = rm._exported_glb_camera
137
-
138
- def pc(cam, device, runtime):
139
- (ex, inn) = original(cam, device, runtime)
140
- (dx, dy) = cam.get('front_atlas_phase', (0.0, 0.0))
141
- inn = inn.clone()
142
- inn[0, 2] += dx / RES
143
- inn[1, 2] += dy / RES
144
- return (ex, inn)
145
- rm._exported_glb_camera = pc
146
-
147
- def footprint(rows, uvs):
148
- tex = uvs[0].reshape(-1, 2)[rows] * SIZE - 0.5
149
- (xy, frac) = (tex.floor().long(), tex - tex.floor())
150
- (ids, weights) = ([], [])
151
- for (dx, dy) in ((0, 0), (1, 0), (0, 1), (1, 1)):
152
- ids.append((xy[:, 1] + dy) % SIZE * SIZE + (xy[:, 0] + dx) % SIZE)
153
- weights.append((frac[:, 0] if dx else 1 - frac[:, 0]) * (frac[:, 1] if dy else 1 - frac[:, 1]))
154
- return (torch.stack(ids, 1), torch.stack(weights, 1))
155
-
156
- def operation(phase):
157
- cam = copy.deepcopy(camera)
158
- cam['front_atlas_phase'] = phase
159
- (ee, kk) = pc(cam, 'cuda', rt)
160
- dist = rm._camera_distance(cam)
161
- proj = rt['intrinsics_to_projection'](kk, max(0.2, dist - 1), dist + 1)
162
- h = torch.cat((exported, torch.ones_like(exported[:, :1])), 1)
163
- (rast, _) = dr.rasterize(dr.RasterizeCudaContext(device='cuda'), (h @ (proj @ ee).T)[None].contiguous(), faces, (RES, RES))
164
- (uvs, _) = dr.interpolate(uv[None], rast, faces)
165
- (pts, _) = dr.interpolate(native[None], rast, faces)
166
- mask = (rast[0, ..., -1] > 0).cpu().numpy()
167
- rowsall = torch.as_tensor(np.flatnonzero(mask.reshape(-1)), device='cuda')
168
- xyz = pts[0].reshape(-1, 3)[rowsall]
169
- q = torch.cat((xyz, torch.ones_like(xyz[:, :1])), 1) @ E.T
170
- q = q[:, :3] @ K.T
171
- suv = q[:, :2] / q[:, 2:3].clamp_min(1e-08)
172
- near = torch.floor(suv * RES).long()
173
- inside = (near[:, 0] >= 0) & (near[:, 0] < RES) & (near[:, 1] >= 0) & (near[:, 1] < RES) & (q[:, 2] > 0)
174
- trusted = torch.zeros(len(rowsall), dtype=torch.bool, device='cuda')
175
- trusted[inside] = hard_t[near[inside, 1], near[inside, 0]]
176
- rows = rowsall[trusted]
177
- (idx, weights) = footprint(rows, uvs)
178
- unsafe_screen = np.zeros(RES * RES, np.uint8)
179
- unsafe_screen[rowsall[~trusted].cpu().numpy()] = 1
180
- unsafe_screen = cv2.dilate(unsafe_screen.reshape(RES, RES), np.ones((5, 5), np.uint8)) & mask
181
- unsafe_rows = torch.as_tensor(np.flatnonzero(unsafe_screen.reshape(-1)), device='cuda')
182
- unsafe = footprint(unsafe_rows, uvs)[0] if len(unsafe_rows) else torch.empty((0, 4), dtype=torch.long, device='cuda')
183
- target = rt['F'].grid_sample(src, (suv[trusted] * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, :, 0].T
184
- screen = np.zeros(RES * RES, bool)
185
- screen[rows.cpu().numpy()] = True
186
- return {'phase': phase, 'idx': idx, 'w': weights, 'unsafe': unsafe, 'rows': rows, 'screen': screen.reshape(RES, RES), 'target': target}
187
- operations = [operation(p) for p in TRAIN + HELD]
188
- unsafe_ids = torch.unique(torch.cat([x['unsafe'].reshape(-1) for x in operations if x['unsafe'].numel()])) if any((x['unsafe'].numel() for x in operations)) else torch.empty(0, dtype=torch.long, device='cuda')
189
- unsafe = np.zeros(SIZE * SIZE, bool)
190
- unsafe[unsafe_ids.cpu().numpy()] = True
191
- uvclip = torch.cat((uv * 2 - 1, torch.zeros_like(uv[:, :1]), torch.ones_like(uv[:, :1])), 1)[None]
192
- (urast, _) = dr.rasterize(dr.RasterizeCudaContext(device='cuda'), uvclip, faces, (SIZE, SIZE))
193
- covered = (urast[0, ..., -1] > 0).cpu().numpy()
194
- (fcpu, uvcpu) = (faces.cpu().numpy(), uv.cpu().numpy())
195
- parent = np.arange(len(fcpu), dtype=np.int32)
196
-
197
- def root(i):
198
- while parent[i] != i:
199
- parent[i] = parent[parent[i]]
200
- i = parent[i]
201
- return i
202
-
203
- def join(i, j):
204
- (i, j) = (root(i), root(j))
205
- if i != j:
206
- parent[j] = i
207
- edges = {}
208
- for (fi, tri) in enumerate(fcpu):
209
- for (u, v) in ((tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])):
210
- key = (int(min(u, v)), int(max(u, v)))
211
- if key in edges:
212
- join(fi, edges[key])
213
- else:
214
- edges[key] = fi
215
- face_chart = np.asarray([root(i) for i in range(len(fcpu))], np.int32)
216
- (_, face_chart) = np.unique(face_chart, return_inverse=True)
217
- primitive = urast[0, ..., -1].long().cpu().numpy() - 1
218
- chart = np.full((SIZE, SIZE), -1, np.int32)
219
- chart[covered] = face_chart[primitive[covered]]
220
- core = (active & ~blocked).reshape(SIZE, SIZE) & covered
221
- if np.any(active.reshape(SIZE, SIZE) & ~covered):
222
- raise RuntimeError('B active domain includes uncovered texels')
223
- base = ~covered & ~blocked.reshape(SIZE, SIZE)
224
- rejection = np.zeros((SIZE, SIZE), np.uint8)
225
- rejection[base & unsafe.reshape(SIZE, SIZE)] |= REJECT_UNSAFE
226
- candidate = np.zeros_like(core)
227
- (donor_y, donor_x) = (np.full(core.shape, -1, np.int32), np.full(core.shape, -1, np.int32))
228
- donor_chart = np.full_like(chart, -1)
229
- for (dy, dx) in ((-1, 0), (0, -1), (0, 1), (1, 0), (-1, -1), (-1, 1), (1, -1), (1, 1)):
230
- shifted = np.zeros_like(core)
231
- (sy, sx) = (slice(max(0, -dy), SIZE - max(0, dy)), slice(max(0, -dx), SIZE - max(0, dx)))
232
- (ty, tx) = (slice(max(0, dy), SIZE - max(0, -dy)), slice(max(0, dx), SIZE - max(0, -dx)))
233
- shifted[ty, tx] = core[sy, sx]
234
- add = shifted & base & ~unsafe.reshape(SIZE, SIZE)
235
- (ay, ax) = np.nonzero(add)
236
- labels = chart[ay - dy, ax - dx]
237
- for (y, x, label) in zip(ay.tolist(), ax.tolist(), labels.tolist()):
238
- if not candidate[y, x]:
239
- candidate[y, x] = True
240
- donor_y[y, x] = y - dy
241
- donor_x[y, x] = x - dx
242
- donor_chart[y, x] = label
243
- elif donor_chart[y, x] != label:
244
- donor_chart[y, x] = -1
245
- prechart = candidate.copy()
246
- for (dy, dx) in ((-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)):
247
- neigh = np.full_like(chart, -1)
248
- neigh[max(0, dy):SIZE - max(0, -dy), max(0, dx):SIZE - max(0, -dx)] = chart[max(0, -dy):SIZE - max(0, dy), max(0, -dx):SIZE - max(0, dx)]
249
- candidate &= (neigh < 0) | (neigh == donor_chart)
250
- candidate &= donor_chart >= 0
251
- rejection[prechart & ~candidate] |= REJECT_CHART
252
- (cy, cx) = np.nonzero(candidate)
253
- did = donor_y[cy, cx].astype(np.int64) * SIZE + donor_x[cy, cx]
254
- dfi = primitive.reshape(-1)[did]
255
- tri_uv = uvcpu[fcpu[dfi]].astype(np.float64)
256
- point_uv = np.stack(((cx + 0.5) / SIZE, (cy + 0.5) / SIZE), 1).astype(np.float64)
257
- basis = tri_uv[:, :2] - tri_uv[:, 2:3]
258
- det = basis[:, 0, 0] * basis[:, 1, 1] - basis[:, 0, 1] * basis[:, 1, 0]
259
- finite_face = np.isfinite(tri_uv).all((1, 2)) & (np.abs(det) > 1e-12)
260
- bary = np.full((len(cy), 3), np.nan, np.float32)
261
- good = finite_face
262
- delta = point_uv[good] - tri_uv[good, 2]
263
- bary[good, :2] = np.linalg.solve(np.swapaxes(basis[good], 1, 2), delta[..., None])[..., 0]
264
- bary[good, 2] = 1 - bary[good, 0] - bary[good, 1]
265
- good &= np.isfinite(bary).all(1) & (np.abs(bary).max(1) <= 8.0)
266
- rejection[cy[~good], cx[~good]] |= REJECT_DEGENERATE_UV
267
- tri_native = vertices[fcpu[dfi]].astype(np.float64) * np.array((-1.0, 1.0, -1.0), np.float64)
268
- xyz = (tri_native * bary[:, :, None]).sum(1)
269
- donor_uv = np.stack(((donor_x[cy, cx] + 0.5) / SIZE, (donor_y[cy, cx] + 0.5) / SIZE), 1)
270
- donor_delta = donor_uv - tri_uv[:, 2]
271
- donor_bary = np.full((len(cy), 3), np.nan, np.float32)
272
- donor_bary[good, :2] = np.linalg.solve(np.swapaxes(basis[good], 1, 2), donor_delta[good, ..., None])[..., 0]
273
- donor_bary[good, 2] = 1 - donor_bary[good, 0] - donor_bary[good, 1]
274
- good &= np.isfinite(donor_bary).all(1) & (np.abs(donor_bary).max(1) <= 8.0)
275
- donor_contained = (donor_bary >= -1e-05).all(1) & (donor_bary <= 1 + 1e-05).all(1)
276
- good &= donor_contained
277
- rejection[cy[~donor_contained], cx[~donor_contained]] |= REJECT_DEGENERATE_UV
278
- reconstructed_donor_uv = (tri_uv * donor_bary[:, :, None]).sum(1)
279
- donor_uv_error = np.abs(reconstructed_donor_uv - donor_uv).max(1)
280
- good &= donor_uv_error <= 1e-06
281
- rejection[cy[~good], cx[~good]] |= REJECT_DEGENERATE_UV
282
- donor_xyz = (tri_native * donor_bary[:, :, None]).sum(1)
283
- (atlas_native, _) = dr.interpolate(native[None], urast, faces)
284
- donor_surface = atlas_native[0, torch.as_tensor(donor_y[cy, cx], device='cuda'), torch.as_tensor(donor_x[cy, cx], device='cuda')].cpu().numpy()
285
- del atlas_native
286
- donor_surface_error = np.abs(donor_surface - donor_xyz).max(1)
287
- surface_checked = good.copy()
288
- surface_ok = donor_surface_error <= 1e-05
289
- good &= surface_ok
290
- rejection[cy[~surface_ok], cx[~surface_ok]] |= REJECT_DEGENERATE_UV
291
-
292
- def project_native(points):
293
- points_t = torch.as_tensor(points, dtype=torch.float32, device='cuda')
294
- q = torch.cat((points_t, torch.ones_like(points_t[:, :1])), 1) @ E.T
295
- q = q[:, :3] @ K.T
296
- return ((q[:, :2] / q[:, 2:3]).cpu().numpy(), q[:, 2].cpu().numpy())
297
- (source_uv, source_depth) = project_native(xyz)
298
- (donor_source_uv, donor_depth) = project_native(donor_xyz)
299
- cell = np.floor(np.where(np.isfinite(source_uv), source_uv, -2.0) * RES - 0.5).astype(np.int64)
300
- donor_cell = np.floor(np.where(np.isfinite(donor_source_uv), donor_source_uv, -2.0) * RES - 0.5).astype(np.int64)
301
- footprint_ok = good & np.isfinite(source_uv).all(1) & np.isfinite(donor_source_uv).all(1) & (source_depth > 0) & (donor_depth > 0)
302
- footprint_ok &= (cell[:, 0] >= 0) & (cell[:, 0] + 1 < RES) & (cell[:, 1] >= 0) & (cell[:, 1] + 1 < RES)
303
- footprint_ok &= (donor_cell[:, 0] >= 0) & (donor_cell[:, 0] + 1 < RES) & (donor_cell[:, 1] >= 0) & (donor_cell[:, 1] + 1 < RES)
304
- fi = np.flatnonzero(footprint_ok)
305
- if len(fi):
306
- footprint_ok[fi] &= hard[cell[fi, 1], cell[fi, 0]] & hard[cell[fi, 1], cell[fi, 0] + 1] & hard[cell[fi, 1] + 1, cell[fi, 0]] & hard[cell[fi, 1] + 1, cell[fi, 0] + 1] & hard[donor_cell[fi, 1], donor_cell[fi, 0]] & hard[donor_cell[fi, 1], donor_cell[fi, 0] + 1] & hard[donor_cell[fi, 1] + 1, donor_cell[fi, 0]] & hard[donor_cell[fi, 1] + 1, donor_cell[fi, 0] + 1]
307
- donor_strength = rt['F'].grid_sample(trust_t, (torch.as_tensor(donor_source_uv, dtype=torch.float32, device='cuda') * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, 0, 0].cpu().numpy()
308
- endpoint_strength = rt['F'].grid_sample(trust_t, (torch.as_tensor(source_uv, dtype=torch.float32, device='cuda') * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, 0, 0].cpu().numpy()
309
- footprint_ok &= (donor_strength >= 1 - 1e-06) & (endpoint_strength >= 1 - 1e-06)
310
- sample_uv = torch.as_tensor(source_uv[footprint_ok], dtype=torch.float32, device='cuda')
311
- donor_sample_uv = torch.as_tensor(donor_source_uv[footprint_ok], dtype=torch.float32, device='cuda')
312
- sampled = rt['F'].grid_sample(src, (sample_uv * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, :, 0].T.cpu().numpy()
313
- donor_sampled = rt['F'].grid_sample(src, (donor_sample_uv * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, :, 0].T.cpu().numpy()
314
- source_rows = np.flatnonzero(footprint_ok)
315
- donor_base = base_before[donor_y[cy[source_rows], cx[source_rows]], donor_x[cy[source_rows], cx[source_rows]], :3].astype(np.float32) / 255
316
- donor_actual = before[donor_y[cy[source_rows], cx[source_rows]], donor_x[cy[source_rows], cx[source_rows]], :3].astype(np.float32) / 255
317
- donor_rebuilt = donor_base + donor_strength[source_rows, None] * (donor_sampled - donor_base)
318
- donor_b_source_byte_error = np.full(len(cy), np.inf, np.float32)
319
- donor_b_source_byte_error[source_rows] = np.abs(donor_rebuilt - donor_actual).max(1) * 255
320
- donor_b_source_ok = donor_b_source_byte_error <= 1.0
321
- rejection[cy[~footprint_ok | footprint_ok & ~donor_b_source_ok], cx[~footprint_ok | footprint_ok & ~donor_b_source_ok]] |= REJECT_SOURCE
322
- continuous_ok = footprint_ok & donor_b_source_ok
323
- eligible = np.zeros_like(base)
324
- eligible[cy[continuous_ok], cx[continuous_ok]] = True
325
- after = before.copy()
326
- after[cy, cx, :3] = before[donor_y[cy, cx], donor_x[cy, cx], :3]
327
- if not np.array_equal(after[cy, cx, :3], before[donor_y[cy, cx], donor_x[cy, cx], :3]):
328
- raise RuntimeError('accepted-B donor byte fallback failed')
329
- (ey, ex) = np.nonzero(eligible)
330
- anchor = before[donor_y[ey, ex], donor_x[ey, ex], :3].astype(np.float32) / 255
331
- selected_rows = np.flatnonzero(continuous_ok)
332
- sample_indices = np.searchsorted(source_rows, selected_rows)
333
- after[ey, ex, :3] = np.clip((anchor + sampled[sample_indices] - donor_sampled[sample_indices]) * 255 + 0.5, 0, 255).astype(np.uint8)
334
- if not np.array_equal(after[covered], before[covered]):
335
- raise RuntimeError('UV covered preservation failed')
336
- if after.shape[-1] == 4 and (not np.array_equal(after[..., 3], before[..., 3])):
337
- raise RuntimeError('alpha changed')
338
- if not np.array_equal(after.reshape(-1, after.shape[-1])[active | blocked | unsafe], before.reshape(-1, before.shape[-1])[active | blocked | unsafe]):
339
- raise RuntimeError('active, blocked, or unsafe texels changed')
340
- eligible_ids = np.flatnonzero(eligible.reshape(-1))
341
- np.savez_compressed(a.output / 'GUTTER_DIAGNOSTICS.npz', covered=covered, unsafe=unsafe.reshape(SIZE, SIZE), candidate=candidate, eligible=eligible, rejection=rejection, eligible_texel_ids=eligible_ids, donor_texel_ids=did[continuous_ok], donor_charts=donor_chart.reshape(-1)[eligible_ids], source_uv=source_uv[continuous_ok], donor_source_uv=donor_source_uv[continuous_ok], barycentric=bary[continuous_ok], donor_barycentric=donor_bary[continuous_ok], donor_uv_reconstruction_error=donor_uv_error[continuous_ok], donor_surface_error=donor_surface_error[continuous_ok], donor_strength=donor_strength[continuous_ok], endpoint_strength=endpoint_strength[continuous_ok], donor_b_source_byte_error=donor_b_source_byte_error[continuous_ok], gutter_radius=np.asarray(a.gutter_radius, dtype=np.int32))
342
- blob = io.BytesIO()
343
- Image.fromarray(after).save(blob, format='PNG')
344
- odoc = copy.deepcopy(bdoc)
345
- obin = fixed._replace_image_payload(odoc, bbin, vi, off, n, blob.getvalue())
346
- glb = a.output / 'candidate/mesh.glb'
347
- glb.parent.mkdir()
348
- fixed._write_glb(glb, odoc, obin)
349
- preservation = fixed._nonbase_identity_check(bdoc, bbin, glb, vi)
350
-
351
- def render(o, split):
352
- p = o['phase']
353
- folder = a.output / 'renders' / split / f'x{p[0]:.3f}_y{p[1]:.3f}'
354
- folder.mkdir(parents=True)
355
- cam = copy.deepcopy(camera)
356
- cam['front_atlas_phase'] = p
357
- (ca, bb) = (rm.render_glb(glb, cam, folder / 'repair.png', resolution=RES, rt=rt), rm.render_glb(bglb, cam, folder / 'B.png', resolution=RES, rt=rt))
358
- with np.load(ca['attrs_npz']) as z:
359
- (ci, cm, cmra) = (z['albedo'], z['mask'].astype(bool), z['mra'])
360
- with np.load(bb['attrs_npz']) as z:
361
- (bi, bm, bmra) = (z['albedo'], z['mask'].astype(bool), z['mra'])
362
- if not np.array_equal(cm, bm) or not np.array_equal(cmra, bmra):
363
- raise RuntimeError('mask/MRA changed')
364
- target = np.zeros_like(ci)
365
- target.reshape(-1, 3)[o['rows'].cpu().numpy()] = o['target'].cpu().numpy()
366
- sel = o['screen'] & cm
367
- quant = torch.as_tensor(after[..., :3], dtype=torch.float32, device='cuda').reshape(-1, 3) / 255
368
- replay = (quant[o['idx']] * o['w'][..., None]).sum(1).cpu().numpy()
369
- actual = ci.reshape(-1, 3)[o['rows'].cpu().numpy()]
370
- outside = cm & ~o['screen']
371
- outdelta = float(np.abs(ci - bi)[outside].max()) if outside.any() else 0.0
372
- changed = cm & (np.abs(ci - bi).max(2) > 0)
373
- r = {'phase': p, 'hardtrust_pixels': int(sel.sum()), 'repair_vs_source': rm._masked_metrics(ci, target, sel), 'B_vs_source': rm._masked_metrics(bi, target, sel), 'repair_vs_B_changed_pixels': int(changed.sum()), 'repair_vs_B': rm._masked_metrics(ci, bi, changed) if changed.any() else {'valid_pixels': 0}, 'quantized_atlas_replay_max': float(np.abs(replay - actual).max()), 'outside_source_hardtrust_max_abs_threshold': OUTSIDE_COLOR_MAX_ABS, 'outside_source_hardtrust_delta_max_abs': outdelta}
374
- r['passes'] = bool(r['quantized_atlas_replay_max'] <= 1e-05 and outside_color_passes(outdelta))
375
- r.update(mask_exact=True, mra_exact=True, evidence={'repair': bind_file(Path(ca['attrs_npz'])), 'B': bind_file(Path(bb['attrs_npz']))})
376
- write_once(folder / 'PHASE_RECEIPT.json', r)
377
- return r
378
- results = [render(x, 'train' if i < len(TRAIN) else 'heldout') for (i, x) in enumerate(operations)]
379
- changed = np.any(after[..., :3] != before[..., :3], axis=-1)
380
- accepted = all((x['passes'] for x in results))
381
- gate_ok = accepted and all((preservation.get(k) for k in ('materials_exact', 'mesh_structure_exact', 'node_structure_exact', 'nonbase_buffer_views_exact')))
382
- receipt = {'status': 'valid' if gate_ok else 'invalid', 'candidate_glb': str(glb), 'seconds': time.monotonic() - started, 'gates': {'accepted': gate_ok, 'UV_covered_exact': True, 'B_active_exact': True, 'blocked_exact': True, 'alpha_exact': True, 'nonbase_identity': preservation, 'all_19_phase_renders': len(results) == 19}, 'atlas': {'UV_covered_texels': int(covered.sum()), 'UV_face_charts': int(face_chart.max() + 1), 'B_active_texels': int(active.sum()), 'blocked_texels': int(blocked.sum()), 'unsafe_all_phase_texels': int(unsafe.sum()), 'gutter_radius': a.gutter_radius, 'nearest_donor_uncovered_gutter_texels': int(candidate.sum()), 'continuous_eligible_uncovered_gutter_texels': int(eligible.sum()), 'changed_texels': int(changed.sum()), 'rejected_unsafe': int((rejection & REJECT_UNSAFE != 0).sum()), 'rejected_chart': int((rejection & REJECT_CHART != 0).sum()), 'rejected_degenerate_uv': int((rejection & REJECT_DEGENERATE_UV != 0).sum()), 'rejected_source_footprint_or_feather': int((rejection & REJECT_SOURCE != 0).sum()), 'donor_surface_oracle': {'threshold': 1e-05, 'checked': int(surface_checked.sum()), 'rejected': int((surface_checked & ~surface_ok).sum()), 'pre_filter_max': float(donor_surface_error[surface_checked].max()) if surface_checked.any() else 0.0, 'pre_filter_p99': float(np.quantile(donor_surface_error[surface_checked], 0.99)) if surface_checked.any() else 0.0, 'accepted_max': float(donor_surface_error[footprint_ok].max()) if footprint_ok.any() else 0.0}}, 'phase_results': {'train': results[:len(TRAIN)], 'heldout': results[len(TRAIN):]}, 'diagnostics': str(a.output / 'GUTTER_DIAGNOSTICS.npz'), 'code_snapshot': str(a.output / 'CODE_SNAPSHOT.py'), 'limits': 'Fixed-shape observed-source continuous-gutter candidate. It does not establish novel-view quality or model improvement.'}
383
- write_once(a.output / 'RECEIPT.json', receipt)
384
- write_once(a.output / 'RESULT.json', receipt)
385
- if not gate_ok:
386
- raise RuntimeError('acceptance failed')
387
- print(json.dumps({'status': 'valid', 'candidate': str(glb), 'changed_texels': int(changed.sum())}))
388
- if __name__ == '__main__':
389
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Repair one-texel UV gutters with observed continuous albedo gradients.
2
+
3
+ Each uncovered gutter texel next to an unblocked projected texel is anchored
4
+ to that nearest covered donor's projected RGB, then shifted by the predicted
5
+ albedo difference between the donor centre and the gutter centre extrapolated
6
+ on the donor's UV face. A gutter is written only if both samples have positive
7
+ camera depth, a finite non-degenerate UV face, bounded barycentrics, and fully
8
+ hard-trusted bilinear source footprints, and if its bilinear atlas taps avoid
9
+ untrusted pixels in all nineteen phases. Every UV-covered texel, blocked texel,
10
+ alpha, and all non-base GLB content stay exact.
11
+ """
12
+ from __future__ import annotations
13
+ import argparse, copy, io, json, os, shutil, sys, time
14
+ from pathlib import Path
15
+ from ._common import GRID_PHASES, OFFGRID_PHASES, OUTSIDE_COLOR_MAX_ABS, IDENTITY_GATES, outside_color_passes, load_renderer, bind_file
16
+ from . import _glb as fixed
17
+ from ._project import SCHEMA as PROJECT_SCHEMA
18
+ SCHEMA = 'supratex.reprojection.continuous'
19
+ SIZE = 4096
20
+ RES = 1024
21
+ REJECT_UNSAFE = 1
22
+ REJECT_CHART = 2
23
+ REJECT_DEGENERATE_UV = 4
24
+ REJECT_SOURCE = 8
25
+
26
+ def write_once(path, value):
27
+ if path.exists():
28
+ raise FileExistsError(path)
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ path.write_text(json.dumps(value, indent=2, sort_keys=True, allow_nan=False) + '\n')
31
+
32
+ def require(path, label):
33
+ path = Path(path).absolute()
34
+ if not path.is_file():
35
+ raise FileNotFoundError(f'{label}: {path}')
36
+ return path
37
+
38
+ def selected_case(manifest, label):
39
+ found = [x for x in json.loads(require(manifest, 'manifest').read_text()) if x.get('label') == label]
40
+ if len(found) != 1:
41
+ raise ValueError(f'expected one manifest case for {label!r}')
42
+ return found[0]
43
+
44
+ def assert_skew_barycentric_oracle(np):
45
+ """Catch row/column and signed-area errors on a non-axis-aligned triangle."""
46
+ tri = np.asarray(((0.11, 0.17), (0.83, 0.29), (0.26, 0.91)), np.float64)
47
+ expected = np.asarray((0.19, 0.34, 0.47), np.float64)
48
+ point = (tri * expected[:, None]).sum(0)
49
+ basis = tri[:2] - tri[2]
50
+ solved = np.linalg.solve(basis.T, point - tri[2])
51
+ recovered = np.asarray((solved[0], solved[1], 1 - solved.sum()))
52
+ if not np.allclose(recovered, expected, rtol=0, atol=1e-12):
53
+ raise RuntimeError('skew barycentric oracle failed')
54
+
55
+ def main():
56
+ ap = argparse.ArgumentParser(description=__doc__)
57
+ ap.add_argument('--manifest', type=Path, required=True)
58
+ ap.add_argument('--case', required=True)
59
+ ap.add_argument('--renderer', type=Path, required=True)
60
+ ap.add_argument('--output', type=Path, required=True)
61
+ ap.add_argument('--gutter-radius', type=int, choices=(1,), default=1, help='Only one-texel gutter repair is supported.')
62
+ a = ap.parse_args()
63
+ if a.output.exists():
64
+ raise FileExistsError(a.output)
65
+ case = selected_case(a.manifest, a.case)
66
+ renderer = require(a.renderer, 'renderer')
67
+ (work, pdir) = (Path(case['work']).absolute(), Path(case['project_output']).absolute())
68
+ baseline = require(case['baseline_glb'], 'baseline GLB')
69
+ pglb = require(pdir / 'candidate/mesh.glb', 'accepted projection GLB')
70
+ camera_path = require(work / 'camera.json', 'camera')
71
+ rgba_path = require(work / 'condition_rgba.png', 'condition RGBA')
72
+ albedo_path = require(work / 'albedo.png', 'predicted albedo')
73
+ domain_path = require(pdir / 'COMMON_DOMAIN.npz', 'projection common domain')
74
+ pplan = json.loads(require(pdir / 'PLAN.json', 'projection PLAN').read_text())
75
+ presult = json.loads(require(pdir / 'RESULT.json', 'projection RESULT').read_text())
76
+ expected = {
77
+ 'schema': PROJECT_SCHEMA,
78
+ 'baseline_glb': str(baseline),
79
+ 'camera': str(camera_path),
80
+ 'source_albedo': str(albedo_path),
81
+ 'source_rgba': str(rgba_path),
82
+ 'renderer': str(renderer),
83
+ }
84
+ if [k for (k, v) in expected.items() if pplan.get(k) != v]:
85
+ raise RuntimeError('projection PLAN binding failed')
86
+ if presult.get('status') != 'valid' or not presult.get('gates', {}).get('accepted'):
87
+ raise RuntimeError('projection source is not an accepted projection result')
88
+ if Path(presult.get('candidate_glb', '')).absolute() != pglb:
89
+ raise RuntimeError('projection result candidate binding failed')
90
+ identity = presult.get('gates', {}).get('nonbase_identity', {})
91
+ if not all((identity.get(k) for k in IDENTITY_GATES)):
92
+ raise RuntimeError('projection non-base identity gate failed')
93
+ phase_results = presult.get('phase_results', {})
94
+ phases = phase_results.get('grid', []) + phase_results.get('offgrid', [])
95
+ if (not presult.get('gates', {}).get('all_19_phase_renders')
96
+ or not presult.get('atlas', {}).get('alpha_exact')
97
+ or len(phases) != 19
98
+ or not all((x.get('passes') for x in phases))):
99
+ raise RuntimeError('projection alpha or 19-phase receipt binding failed')
100
+ a.output.mkdir(parents=True)
101
+ shutil.copyfile(Path(__file__), a.output / 'CODE_SNAPSHOT.py')
102
+ write_once(a.output / 'PLAN.json', {
103
+ 'schema': SCHEMA,
104
+ 'case': a.case,
105
+ 'manifest': str(a.manifest.absolute()),
106
+ 'project_glb': str(pglb),
107
+ 'baseline_glb': str(baseline),
108
+ 'camera': str(camera_path),
109
+ 'renderer': str(renderer),
110
+ 'source_albedo': str(albedo_path),
111
+ 'source_rgba': str(rgba_path),
112
+ 'common_domain': str(domain_path),
113
+ 'grid_phases': GRID_PHASES,
114
+ 'offgrid_phases': OFFGRID_PHASES,
115
+ 'gutter_radius': a.gutter_radius,
116
+ })
117
+ os.environ['FLEX_GEMM_USE_AUTOTUNE_CACHE'] = '0'
118
+ os.environ['FLEX_GEMM_AUTOSAVE_AUTOTUNE_CACHE'] = '0'
119
+ import cv2
120
+ import numpy as np
121
+ import torch
122
+ from PIL import Image
123
+ assert_skew_barycentric_oracle(np)
124
+ torch.backends.cuda.matmul.allow_tf32 = False
125
+ torch.manual_seed(42)
126
+ started = time.monotonic()
127
+ rm = load_renderer(renderer)
128
+ rt = rm._runtime_imports()
129
+ dr = rt['dr']
130
+ camera = json.loads(camera_path.read_text())
131
+ (pdoc, pbin) = fixed._read_glb(pglb)
132
+ fixed._assert_fixed_scene(pdoc)
133
+ (ii, vi) = fixed._base_color_image_ref(pdoc)
134
+ (pimg, _, off, n) = fixed._decode_base_image(pdoc, pbin, ii, vi)
135
+ before = np.asarray(pimg).copy()
136
+ (basedoc, basebin) = fixed._read_glb(baseline)
137
+ fixed._assert_fixed_scene(basedoc)
138
+ (baseii, basevi) = fixed._base_color_image_ref(basedoc)
139
+ (baseimg, _, _, _) = fixed._decode_base_image(basedoc, basebin, baseii, basevi)
140
+ base_before = np.asarray(baseimg).copy()
141
+ if before.shape[:2] != (SIZE, SIZE):
142
+ raise ValueError('requires 4096 atlas')
143
+ if base_before.shape != before.shape:
144
+ raise RuntimeError('baseline/projection baseColor image shape mismatch')
145
+ with np.load(domain_path, allow_pickle=False) as z:
146
+ (active_ids, blocked_ids) = (z['active_texel_ids'].astype(np.int64), z['blocked_texel_ids'].astype(np.int64))
147
+ project_atlas = presult.get('atlas', {})
148
+ if (len(active_ids) != int(project_atlas.get('active_texels', -1))
149
+ or len(blocked_ids) != int(project_atlas.get('blocked_texels', -1))
150
+ or len(np.unique(active_ids)) != len(active_ids)
151
+ or len(np.unique(blocked_ids)) != len(blocked_ids)):
152
+ raise RuntimeError('projection common-domain count or uniqueness binding failed')
153
+ (active, blocked) = (np.zeros(SIZE * SIZE, bool), np.zeros(SIZE * SIZE, bool))
154
+ (active[active_ids], blocked[blocked_ids]) = (True, True)
155
+ if np.any(blocked & ~active):
156
+ raise RuntimeError('blocked IDs must be in the projection active domain')
157
+ (vertices, faces_np, _, uv_np, _, _, factor, _, _) = rt['_load_textured_mesh'](pglb, rt)
158
+ if not np.allclose(factor, 1):
159
+ raise RuntimeError('unexpected baseColorFactor')
160
+ exported = torch.as_tensor(vertices, dtype=torch.float32, device='cuda')
161
+ native = exported * torch.tensor((-1.0, 1.0, -1.0), device='cuda')
162
+ faces = torch.as_tensor(faces_np, dtype=torch.int32, device='cuda').contiguous()
163
+ uv = torch.as_tensor(uv_np, dtype=torch.float32, device='cuda')
164
+ uv = torch.stack((uv[:, 0], 1 - uv[:, 1]), -1).contiguous()
165
+ rgba = np.asarray(Image.open(rgba_path).convert('RGBA'))
166
+ hard = cv2.erode((rgba[..., 3] >= 254).astype('uint8'), np.ones((5, 5), np.uint8)).astype(bool)
167
+ source = np.asarray(Image.open(albedo_path).convert('RGB'), np.float32) / 255
168
+ if hard.shape != (RES, RES) or source.shape[:2] != (RES, RES):
169
+ raise ValueError('source inputs must be aligned 1024')
170
+ hard_t = torch.as_tensor(hard, device='cuda')
171
+ src = torch.as_tensor(source, device='cuda').permute(2, 0, 1)[None]
172
+ trust = np.clip(cv2.distanceTransform(hard.astype('uint8'), cv2.DIST_L2, 3) / 4.0, 0, 1)
173
+ trust_t = torch.as_tensor(trust, dtype=torch.float32, device='cuda')[None, None]
174
+ (E, K) = rm._native_camera(camera, 'cuda', rt)
175
+ original = rm._exported_glb_camera
176
+
177
+ def pc(cam, device, runtime):
178
+ (ex, inn) = original(cam, device, runtime)
179
+ (dx, dy) = cam.get('front_atlas_phase', (0.0, 0.0))
180
+ inn = inn.clone()
181
+ inn[0, 2] += dx / RES
182
+ inn[1, 2] += dy / RES
183
+ return (ex, inn)
184
+ rm._exported_glb_camera = pc
185
+
186
+ def footprint(rows, uvs):
187
+ tex = uvs[0].reshape(-1, 2)[rows] * SIZE - 0.5
188
+ (xy, frac) = (tex.floor().long(), tex - tex.floor())
189
+ (ids, weights) = ([], [])
190
+ for (dx, dy) in ((0, 0), (1, 0), (0, 1), (1, 1)):
191
+ ids.append((xy[:, 1] + dy) % SIZE * SIZE + (xy[:, 0] + dx) % SIZE)
192
+ weights.append((frac[:, 0] if dx else 1 - frac[:, 0]) * (frac[:, 1] if dy else 1 - frac[:, 1]))
193
+ return (torch.stack(ids, 1), torch.stack(weights, 1))
194
+
195
+ def operation(phase):
196
+ cam = copy.deepcopy(camera)
197
+ cam['front_atlas_phase'] = phase
198
+ (ee, kk) = pc(cam, 'cuda', rt)
199
+ dist = rm._camera_distance(cam)
200
+ proj = rt['intrinsics_to_projection'](kk, max(0.2, dist - 1), dist + 1)
201
+ h = torch.cat((exported, torch.ones_like(exported[:, :1])), 1)
202
+ (rast, _) = dr.rasterize(dr.RasterizeCudaContext(device='cuda'), (h @ (proj @ ee).T)[None].contiguous(), faces, (RES, RES))
203
+ (uvs, _) = dr.interpolate(uv[None], rast, faces)
204
+ (pts, _) = dr.interpolate(native[None], rast, faces)
205
+ mask = (rast[0, ..., -1] > 0).cpu().numpy()
206
+ rowsall = torch.as_tensor(np.flatnonzero(mask.reshape(-1)), device='cuda')
207
+ xyz = pts[0].reshape(-1, 3)[rowsall]
208
+ q = torch.cat((xyz, torch.ones_like(xyz[:, :1])), 1) @ E.T
209
+ q = q[:, :3] @ K.T
210
+ suv = q[:, :2] / q[:, 2:3].clamp_min(1e-08)
211
+ near = torch.floor(suv * RES).long()
212
+ inside = (near[:, 0] >= 0) & (near[:, 0] < RES) & (near[:, 1] >= 0) & (near[:, 1] < RES) & (q[:, 2] > 0)
213
+ trusted = torch.zeros(len(rowsall), dtype=torch.bool, device='cuda')
214
+ trusted[inside] = hard_t[near[inside, 1], near[inside, 0]]
215
+ rows = rowsall[trusted]
216
+ (idx, weights) = footprint(rows, uvs)
217
+ unsafe_screen = np.zeros(RES * RES, np.uint8)
218
+ unsafe_screen[rowsall[~trusted].cpu().numpy()] = 1
219
+ unsafe_screen = cv2.dilate(unsafe_screen.reshape(RES, RES), np.ones((5, 5), np.uint8)) & mask
220
+ unsafe_rows = torch.as_tensor(np.flatnonzero(unsafe_screen.reshape(-1)), device='cuda')
221
+ unsafe = footprint(unsafe_rows, uvs)[0] if len(unsafe_rows) else torch.empty((0, 4), dtype=torch.long, device='cuda')
222
+ target = rt['F'].grid_sample(src, (suv[trusted] * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, :, 0].T
223
+ screen = np.zeros(RES * RES, bool)
224
+ screen[rows.cpu().numpy()] = True
225
+ return {
226
+ 'phase': phase,
227
+ 'idx': idx,
228
+ 'w': weights,
229
+ 'unsafe': unsafe,
230
+ 'rows': rows,
231
+ 'screen': screen.reshape(RES, RES),
232
+ 'target': target,
233
+ }
234
+ operations = [operation(p) for p in GRID_PHASES + OFFGRID_PHASES]
235
+ if any(x['unsafe'].numel() for x in operations):
236
+ unsafe_ids = torch.unique(torch.cat([x['unsafe'].reshape(-1) for x in operations if x['unsafe'].numel()]))
237
+ else:
238
+ unsafe_ids = torch.empty(0, dtype=torch.long, device='cuda')
239
+ unsafe = np.zeros(SIZE * SIZE, bool)
240
+ unsafe[unsafe_ids.cpu().numpy()] = True
241
+ uvclip = torch.cat((uv * 2 - 1, torch.zeros_like(uv[:, :1]), torch.ones_like(uv[:, :1])), 1)[None]
242
+ (urast, _) = dr.rasterize(dr.RasterizeCudaContext(device='cuda'), uvclip, faces, (SIZE, SIZE))
243
+ covered = (urast[0, ..., -1] > 0).cpu().numpy()
244
+ (fcpu, uvcpu) = (faces.cpu().numpy(), uv.cpu().numpy())
245
+ parent = np.arange(len(fcpu), dtype=np.int32)
246
+
247
+ def root(i):
248
+ while parent[i] != i:
249
+ parent[i] = parent[parent[i]]
250
+ i = parent[i]
251
+ return i
252
+
253
+ def join(i, j):
254
+ (i, j) = (root(i), root(j))
255
+ if i != j:
256
+ parent[j] = i
257
+ edges = {}
258
+ for (fi, tri) in enumerate(fcpu):
259
+ for (u, v) in ((tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])):
260
+ key = (int(min(u, v)), int(max(u, v)))
261
+ if key in edges:
262
+ join(fi, edges[key])
263
+ else:
264
+ edges[key] = fi
265
+ face_chart = np.asarray([root(i) for i in range(len(fcpu))], np.int32)
266
+ (_, face_chart) = np.unique(face_chart, return_inverse=True)
267
+ primitive = urast[0, ..., -1].long().cpu().numpy() - 1
268
+ chart = np.full((SIZE, SIZE), -1, np.int32)
269
+ chart[covered] = face_chart[primitive[covered]]
270
+ core = (active & ~blocked).reshape(SIZE, SIZE) & covered
271
+ if np.any(active.reshape(SIZE, SIZE) & ~covered):
272
+ raise RuntimeError('projection active domain includes uncovered texels')
273
+ base = ~covered & ~blocked.reshape(SIZE, SIZE)
274
+ rejection = np.zeros((SIZE, SIZE), np.uint8)
275
+ rejection[base & unsafe.reshape(SIZE, SIZE)] |= REJECT_UNSAFE
276
+ candidate = np.zeros_like(core)
277
+ (donor_y, donor_x) = (np.full(core.shape, -1, np.int32), np.full(core.shape, -1, np.int32))
278
+ donor_chart = np.full_like(chart, -1)
279
+ for (dy, dx) in ((-1, 0), (0, -1), (0, 1), (1, 0), (-1, -1), (-1, 1), (1, -1), (1, 1)):
280
+ shifted = np.zeros_like(core)
281
+ (sy, sx) = (slice(max(0, -dy), SIZE - max(0, dy)), slice(max(0, -dx), SIZE - max(0, dx)))
282
+ (ty, tx) = (slice(max(0, dy), SIZE - max(0, -dy)), slice(max(0, dx), SIZE - max(0, -dx)))
283
+ shifted[ty, tx] = core[sy, sx]
284
+ add = shifted & base & ~unsafe.reshape(SIZE, SIZE)
285
+ (ay, ax) = np.nonzero(add)
286
+ labels = chart[ay - dy, ax - dx]
287
+ for (y, x, label) in zip(ay.tolist(), ax.tolist(), labels.tolist()):
288
+ if not candidate[y, x]:
289
+ candidate[y, x] = True
290
+ donor_y[y, x] = y - dy
291
+ donor_x[y, x] = x - dx
292
+ donor_chart[y, x] = label
293
+ elif donor_chart[y, x] != label:
294
+ donor_chart[y, x] = -1
295
+ prechart = candidate.copy()
296
+ for (dy, dx) in ((-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)):
297
+ neigh = np.full_like(chart, -1)
298
+ neigh[max(0, dy):SIZE - max(0, -dy), max(0, dx):SIZE - max(0, -dx)] = chart[max(0, -dy):SIZE - max(0, dy), max(0, -dx):SIZE - max(0, dx)]
299
+ candidate &= (neigh < 0) | (neigh == donor_chart)
300
+ candidate &= donor_chart >= 0
301
+ rejection[prechart & ~candidate] |= REJECT_CHART
302
+ (cy, cx) = np.nonzero(candidate)
303
+ did = donor_y[cy, cx].astype(np.int64) * SIZE + donor_x[cy, cx]
304
+ dfi = primitive.reshape(-1)[did]
305
+ tri_uv = uvcpu[fcpu[dfi]].astype(np.float64)
306
+ point_uv = np.stack(((cx + 0.5) / SIZE, (cy + 0.5) / SIZE), 1).astype(np.float64)
307
+ basis = tri_uv[:, :2] - tri_uv[:, 2:3]
308
+ det = basis[:, 0, 0] * basis[:, 1, 1] - basis[:, 0, 1] * basis[:, 1, 0]
309
+ finite_face = np.isfinite(tri_uv).all((1, 2)) & (np.abs(det) > 1e-12)
310
+ bary = np.full((len(cy), 3), np.nan, np.float32)
311
+ good = finite_face
312
+ delta = point_uv[good] - tri_uv[good, 2]
313
+ bary[good, :2] = np.linalg.solve(np.swapaxes(basis[good], 1, 2), delta[..., None])[..., 0]
314
+ bary[good, 2] = 1 - bary[good, 0] - bary[good, 1]
315
+ good &= np.isfinite(bary).all(1) & (np.abs(bary).max(1) <= 8.0)
316
+ rejection[cy[~good], cx[~good]] |= REJECT_DEGENERATE_UV
317
+ tri_native = vertices[fcpu[dfi]].astype(np.float64) * np.array((-1.0, 1.0, -1.0), np.float64)
318
+ xyz = (tri_native * bary[:, :, None]).sum(1)
319
+ donor_uv = np.stack(((donor_x[cy, cx] + 0.5) / SIZE, (donor_y[cy, cx] + 0.5) / SIZE), 1)
320
+ donor_delta = donor_uv - tri_uv[:, 2]
321
+ donor_bary = np.full((len(cy), 3), np.nan, np.float32)
322
+ donor_bary[good, :2] = np.linalg.solve(np.swapaxes(basis[good], 1, 2), donor_delta[good, ..., None])[..., 0]
323
+ donor_bary[good, 2] = 1 - donor_bary[good, 0] - donor_bary[good, 1]
324
+ good &= np.isfinite(donor_bary).all(1) & (np.abs(donor_bary).max(1) <= 8.0)
325
+ donor_contained = (donor_bary >= -1e-05).all(1) & (donor_bary <= 1 + 1e-05).all(1)
326
+ good &= donor_contained
327
+ rejection[cy[~donor_contained], cx[~donor_contained]] |= REJECT_DEGENERATE_UV
328
+ reconstructed_donor_uv = (tri_uv * donor_bary[:, :, None]).sum(1)
329
+ donor_uv_error = np.abs(reconstructed_donor_uv - donor_uv).max(1)
330
+ good &= donor_uv_error <= 1e-06
331
+ rejection[cy[~good], cx[~good]] |= REJECT_DEGENERATE_UV
332
+ donor_xyz = (tri_native * donor_bary[:, :, None]).sum(1)
333
+ (atlas_native, _) = dr.interpolate(native[None], urast, faces)
334
+ donor_surface = atlas_native[0, torch.as_tensor(donor_y[cy, cx], device='cuda'), torch.as_tensor(donor_x[cy, cx], device='cuda')].cpu().numpy()
335
+ del atlas_native
336
+ donor_surface_error = np.abs(donor_surface - donor_xyz).max(1)
337
+ surface_checked = good.copy()
338
+ surface_ok = donor_surface_error <= 1e-05
339
+ good &= surface_ok
340
+ rejection[cy[~surface_ok], cx[~surface_ok]] |= REJECT_DEGENERATE_UV
341
+
342
+ def project_native(points):
343
+ points_t = torch.as_tensor(points, dtype=torch.float32, device='cuda')
344
+ q = torch.cat((points_t, torch.ones_like(points_t[:, :1])), 1) @ E.T
345
+ q = q[:, :3] @ K.T
346
+ return ((q[:, :2] / q[:, 2:3]).cpu().numpy(), q[:, 2].cpu().numpy())
347
+ (source_uv, source_depth) = project_native(xyz)
348
+ (donor_source_uv, donor_depth) = project_native(donor_xyz)
349
+ cell = np.floor(np.where(np.isfinite(source_uv), source_uv, -2.0) * RES - 0.5).astype(np.int64)
350
+ donor_cell = np.floor(np.where(np.isfinite(donor_source_uv), donor_source_uv, -2.0) * RES - 0.5).astype(np.int64)
351
+ footprint_ok = good & np.isfinite(source_uv).all(1) & np.isfinite(donor_source_uv).all(1) & (source_depth > 0) & (donor_depth > 0)
352
+ footprint_ok &= (cell[:, 0] >= 0) & (cell[:, 0] + 1 < RES) & (cell[:, 1] >= 0) & (cell[:, 1] + 1 < RES)
353
+ footprint_ok &= (donor_cell[:, 0] >= 0) & (donor_cell[:, 0] + 1 < RES) & (donor_cell[:, 1] >= 0) & (donor_cell[:, 1] + 1 < RES)
354
+ fi = np.flatnonzero(footprint_ok)
355
+ if len(fi):
356
+ footprint_ok[fi] &= (hard[cell[fi, 1], cell[fi, 0]] & hard[cell[fi, 1], cell[fi, 0] + 1]
357
+ & hard[cell[fi, 1] + 1, cell[fi, 0]] & hard[cell[fi, 1] + 1, cell[fi, 0] + 1]
358
+ & hard[donor_cell[fi, 1], donor_cell[fi, 0]] & hard[donor_cell[fi, 1], donor_cell[fi, 0] + 1]
359
+ & hard[donor_cell[fi, 1] + 1, donor_cell[fi, 0]] & hard[donor_cell[fi, 1] + 1, donor_cell[fi, 0] + 1])
360
+ donor_strength = rt['F'].grid_sample(trust_t, (torch.as_tensor(donor_source_uv, dtype=torch.float32, device='cuda') * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, 0, 0].cpu().numpy()
361
+ endpoint_strength = rt['F'].grid_sample(trust_t, (torch.as_tensor(source_uv, dtype=torch.float32, device='cuda') * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, 0, 0].cpu().numpy()
362
+ footprint_ok &= (donor_strength >= 1 - 1e-06) & (endpoint_strength >= 1 - 1e-06)
363
+ sample_uv = torch.as_tensor(source_uv[footprint_ok], dtype=torch.float32, device='cuda')
364
+ donor_sample_uv = torch.as_tensor(donor_source_uv[footprint_ok], dtype=torch.float32, device='cuda')
365
+ sampled = rt['F'].grid_sample(src, (sample_uv * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, :, 0].T.cpu().numpy()
366
+ donor_sampled = rt['F'].grid_sample(src, (donor_sample_uv * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, :, 0].T.cpu().numpy()
367
+ source_rows = np.flatnonzero(footprint_ok)
368
+ donor_base = base_before[donor_y[cy[source_rows], cx[source_rows]], donor_x[cy[source_rows], cx[source_rows]], :3].astype(np.float32) / 255
369
+ donor_actual = before[donor_y[cy[source_rows], cx[source_rows]], donor_x[cy[source_rows], cx[source_rows]], :3].astype(np.float32) / 255
370
+ donor_rebuilt = donor_base + donor_strength[source_rows, None] * (donor_sampled - donor_base)
371
+ donor_project_byte_error = np.full(len(cy), np.inf, np.float32)
372
+ donor_project_byte_error[source_rows] = np.abs(donor_rebuilt - donor_actual).max(1) * 255
373
+ donor_project_ok = donor_project_byte_error <= 1.0
374
+ source_rejected = ~footprint_ok | footprint_ok & ~donor_project_ok
375
+ rejection[cy[source_rejected], cx[source_rejected]] |= REJECT_SOURCE
376
+ continuous_ok = footprint_ok & donor_project_ok
377
+ eligible = np.zeros_like(base)
378
+ eligible[cy[continuous_ok], cx[continuous_ok]] = True
379
+ after = before.copy()
380
+ after[cy, cx, :3] = before[donor_y[cy, cx], donor_x[cy, cx], :3]
381
+ if not np.array_equal(after[cy, cx, :3], before[donor_y[cy, cx], donor_x[cy, cx], :3]):
382
+ raise RuntimeError('projection donor byte fallback failed')
383
+ (ey, ex) = np.nonzero(eligible)
384
+ anchor = before[donor_y[ey, ex], donor_x[ey, ex], :3].astype(np.float32) / 255
385
+ selected_rows = np.flatnonzero(continuous_ok)
386
+ sample_indices = np.searchsorted(source_rows, selected_rows)
387
+ after[ey, ex, :3] = np.clip((anchor + sampled[sample_indices] - donor_sampled[sample_indices]) * 255 + 0.5, 0, 255).astype(np.uint8)
388
+ if not np.array_equal(after[covered], before[covered]):
389
+ raise RuntimeError('UV covered preservation failed')
390
+ if after.shape[-1] == 4 and (not np.array_equal(after[..., 3], before[..., 3])):
391
+ raise RuntimeError('alpha changed')
392
+ if not np.array_equal(after.reshape(-1, after.shape[-1])[active | blocked | unsafe], before.reshape(-1, before.shape[-1])[active | blocked | unsafe]):
393
+ raise RuntimeError('active, blocked, or unsafe texels changed')
394
+ eligible_ids = np.flatnonzero(eligible.reshape(-1))
395
+ np.savez_compressed(
396
+ a.output / 'GUTTER_DIAGNOSTICS.npz',
397
+ covered=covered,
398
+ unsafe=unsafe.reshape(SIZE, SIZE),
399
+ candidate=candidate,
400
+ eligible=eligible,
401
+ rejection=rejection,
402
+ eligible_texel_ids=eligible_ids,
403
+ donor_texel_ids=did[continuous_ok],
404
+ donor_charts=donor_chart.reshape(-1)[eligible_ids],
405
+ source_uv=source_uv[continuous_ok],
406
+ donor_source_uv=donor_source_uv[continuous_ok],
407
+ barycentric=bary[continuous_ok],
408
+ donor_barycentric=donor_bary[continuous_ok],
409
+ donor_uv_reconstruction_error=donor_uv_error[continuous_ok],
410
+ donor_surface_error=donor_surface_error[continuous_ok],
411
+ donor_strength=donor_strength[continuous_ok],
412
+ endpoint_strength=endpoint_strength[continuous_ok],
413
+ donor_project_byte_error=donor_project_byte_error[continuous_ok],
414
+ gutter_radius=np.asarray(a.gutter_radius, dtype=np.int32),
415
+ )
416
+ blob = io.BytesIO()
417
+ Image.fromarray(after).save(blob, format='PNG')
418
+ odoc = copy.deepcopy(pdoc)
419
+ obin = fixed._replace_image_payload(odoc, pbin, vi, off, n, blob.getvalue())
420
+ glb = a.output / 'candidate/mesh.glb'
421
+ glb.parent.mkdir()
422
+ fixed._write_glb(glb, odoc, obin)
423
+ preservation = fixed._nonbase_identity_check(pdoc, pbin, glb, vi)
424
+
425
+ def render(o, split):
426
+ p = o['phase']
427
+ folder = a.output / 'renders' / split / f'x{p[0]:.3f}_y{p[1]:.3f}'
428
+ folder.mkdir(parents=True)
429
+ cam = copy.deepcopy(camera)
430
+ cam['front_atlas_phase'] = p
431
+ ca = rm.render_glb(glb, cam, folder / 'repair.png', resolution=RES, rt=rt)
432
+ pa = rm.render_glb(pglb, cam, folder / 'project.png', resolution=RES, rt=rt)
433
+ with np.load(ca['attrs_npz']) as z:
434
+ (ci, cm, cmra) = (z['albedo'], z['mask'].astype(bool), z['mra'])
435
+ with np.load(pa['attrs_npz']) as z:
436
+ (bi, bm, bmra) = (z['albedo'], z['mask'].astype(bool), z['mra'])
437
+ if not np.array_equal(cm, bm) or not np.array_equal(cmra, bmra):
438
+ raise RuntimeError('mask/MRA changed')
439
+ target = np.zeros_like(ci)
440
+ target.reshape(-1, 3)[o['rows'].cpu().numpy()] = o['target'].cpu().numpy()
441
+ sel = o['screen'] & cm
442
+ quant = torch.as_tensor(after[..., :3], dtype=torch.float32, device='cuda').reshape(-1, 3) / 255
443
+ replay = (quant[o['idx']] * o['w'][..., None]).sum(1).cpu().numpy()
444
+ actual = ci.reshape(-1, 3)[o['rows'].cpu().numpy()]
445
+ outside = cm & ~o['screen']
446
+ outdelta = float(np.abs(ci - bi)[outside].max()) if outside.any() else 0.0
447
+ changed = cm & (np.abs(ci - bi).max(2) > 0)
448
+ r = {
449
+ 'phase': p,
450
+ 'hardtrust_pixels': int(sel.sum()),
451
+ 'repair_vs_source': rm._masked_metrics(ci, target, sel),
452
+ 'project_vs_source': rm._masked_metrics(bi, target, sel),
453
+ 'repair_vs_project_changed_pixels': int(changed.sum()),
454
+ 'repair_vs_project': rm._masked_metrics(ci, bi, changed) if changed.any() else {'valid_pixels': 0},
455
+ 'quantized_atlas_replay_max': float(np.abs(replay - actual).max()),
456
+ 'outside_source_hardtrust_max_abs_threshold': OUTSIDE_COLOR_MAX_ABS,
457
+ 'outside_source_hardtrust_delta_max_abs': outdelta,
458
+ }
459
+ r['passes'] = bool(r['quantized_atlas_replay_max'] <= 1e-05 and outside_color_passes(outdelta))
460
+ r.update(mask_exact=True, mra_exact=True, evidence={'repair': bind_file(Path(ca['attrs_npz'])), 'project': bind_file(Path(pa['attrs_npz']))})
461
+ write_once(folder / 'PHASE_RECEIPT.json', r)
462
+ return r
463
+ results = [render(x, 'grid' if i < len(GRID_PHASES) else 'offgrid') for (i, x) in enumerate(operations)]
464
+ changed = np.any(after[..., :3] != before[..., :3], axis=-1)
465
+ accepted = all((x['passes'] for x in results))
466
+ gate_ok = accepted and all((preservation.get(k) for k in IDENTITY_GATES))
467
+ receipt = {
468
+ 'status': 'valid' if gate_ok else 'invalid',
469
+ 'schema': SCHEMA,
470
+ 'candidate_glb': str(glb),
471
+ 'seconds': time.monotonic() - started,
472
+ 'gates': {
473
+ 'accepted': gate_ok,
474
+ 'UV_covered_exact': True,
475
+ 'project_active_exact': True,
476
+ 'blocked_exact': True,
477
+ 'alpha_exact': True,
478
+ 'nonbase_identity': preservation,
479
+ 'all_19_phase_renders': len(results) == 19,
480
+ },
481
+ 'atlas': {
482
+ 'UV_covered_texels': int(covered.sum()),
483
+ 'UV_face_charts': int(face_chart.max() + 1),
484
+ 'project_active_texels': int(active.sum()),
485
+ 'blocked_texels': int(blocked.sum()),
486
+ 'unsafe_all_phase_texels': int(unsafe.sum()),
487
+ 'gutter_radius': a.gutter_radius,
488
+ 'nearest_donor_uncovered_gutter_texels': int(candidate.sum()),
489
+ 'continuous_eligible_uncovered_gutter_texels': int(eligible.sum()),
490
+ 'changed_texels': int(changed.sum()),
491
+ 'rejected_unsafe': int((rejection & REJECT_UNSAFE != 0).sum()),
492
+ 'rejected_chart': int((rejection & REJECT_CHART != 0).sum()),
493
+ 'rejected_degenerate_uv': int((rejection & REJECT_DEGENERATE_UV != 0).sum()),
494
+ 'rejected_source_footprint_or_feather': int((rejection & REJECT_SOURCE != 0).sum()),
495
+ 'donor_surface_oracle': {
496
+ 'threshold': 1e-05,
497
+ 'checked': int(surface_checked.sum()),
498
+ 'rejected': int((surface_checked & ~surface_ok).sum()),
499
+ 'pre_filter_max': float(donor_surface_error[surface_checked].max()) if surface_checked.any() else 0.0,
500
+ 'pre_filter_p99': float(np.quantile(donor_surface_error[surface_checked], 0.99)) if surface_checked.any() else 0.0,
501
+ 'accepted_max': float(donor_surface_error[footprint_ok].max()) if footprint_ok.any() else 0.0,
502
+ },
503
+ },
504
+ 'phase_results': {'grid': results[:len(GRID_PHASES)], 'offgrid': results[len(GRID_PHASES):]},
505
+ 'diagnostics': str(a.output / 'GUTTER_DIAGNOSTICS.npz'),
506
+ 'code_snapshot': str(a.output / 'CODE_SNAPSHOT.py'),
507
+ }
508
+ write_once(a.output / 'RECEIPT.json', receipt)
509
+ write_once(a.output / 'RESULT.json', receipt)
510
+ if not gate_ok:
511
+ raise RuntimeError('acceptance failed')
512
+ print(json.dumps({'status': 'valid', 'candidate': str(glb), 'changed_texels': int(changed.sum())}))
513
+ if __name__ == '__main__':
514
+ main()
supratex/inference/reprojection/_project.py CHANGED
@@ -1,227 +1,283 @@
1
- """Project observed albedo at UV texel centres with measured 19-phase gates.
2
- """
3
- from __future__ import annotations
4
- import argparse, copy, io, json, os, sys, time
5
- from pathlib import Path
6
- from ._common import OUTSIDE_COLOR_MAX_ABS, outside_color_passes, load_renderer, bind_file
7
- from . import _glb as fixed
8
- SIZE = 4096
9
- RES = 1024
10
- TRAIN = tuple(((x / 4, y / 4) for x in range(4) for y in range(4)))
11
- HELD = ((0.125, 0.375), (0.375, 0.625), (0.875, 0.125))
12
-
13
- def put(p, x):
14
- if p.exists():
15
- raise FileExistsError(p)
16
- p.write_text(json.dumps(x, indent=2, sort_keys=True, allow_nan=False) + '\n')
17
-
18
- def main():
19
- ap = argparse.ArgumentParser(description=__doc__)
20
- ap.add_argument('--arm', choices=('B',), required=True)
21
- ap.add_argument('--baseline-glb', type=Path, required=True)
22
- ap.add_argument('--source-albedo', type=Path, required=True)
23
- ap.add_argument('--source-rgba', type=Path, required=True)
24
- ap.add_argument('--camera', type=Path, required=True)
25
- ap.add_argument('--evaluator', type=Path, required=True)
26
- ap.add_argument('--output', type=Path, required=True)
27
- a = ap.parse_args()
28
- if a.output.exists():
29
- raise FileExistsError(a.output)
30
- for p in (a.baseline_glb, a.source_albedo, a.source_rgba, a.camera, a.evaluator):
31
- if not p.is_file():
32
- raise FileNotFoundError(p)
33
- a.output.mkdir(parents=True)
34
- put(a.output / 'PLAN.json', {'arm': a.arm, 'baseline_glb': str(a.baseline_glb.absolute()), 'source_albedo': str(a.source_albedo.absolute()), 'source_rgba': str(a.source_rgba.absolute()), 'camera': str(a.camera.absolute()), 'evaluator': str(a.evaluator.absolute()), 'size': SIZE, 'resolution': RES, 'train_phases': TRAIN, 'heldout_phases': HELD, 'source': 'predicted albedo only; no GT read', 'support': 'common sixteen-phase union, intersected with covered source-hard-trusted UV-triangle centres; alpha>=254 source support eroded 2 pixels; two-pixel unsafe-surface footprint blacklist', 'B': 'atlas-centre UV-triangle final-surface projection then bilinear source albedo sample mixed as T0+s*(source-T0)', 'validation': 'actual GLB rerender at 16 train + 3 heldout phases'})
35
- os.environ['FLEX_GEMM_USE_AUTOTUNE_CACHE'] = '0'
36
- os.environ['FLEX_GEMM_AUTOSAVE_AUTOTUNE_CACHE'] = '0'
37
- import cv2, numpy as np, torch
38
- from PIL import Image
39
- torch.backends.cuda.matmul.allow_tf32 = False
40
- torch.manual_seed(42)
41
- began = time.monotonic()
42
- rm = load_renderer(a.evaluator)
43
- rt = rm._runtime_imports()
44
- dr = rt['dr']
45
- camera = json.loads(a.camera.read_text())
46
- (doc, bin0) = fixed._read_glb(a.baseline_glb)
47
- fixed._assert_fixed_scene(doc)
48
- (ii, vi) = fixed._base_color_image_ref(doc)
49
- (im, _, off, n) = fixed._decode_base_image(doc, bin0, ii, vi)
50
- pix = np.asarray(im).copy()
51
- if pix.shape[:2] != (SIZE, SIZE):
52
- raise ValueError('requires 4096 atlas')
53
- vals = rt['_load_textured_mesh'](a.baseline_glb, rt)
54
- (vertices, faces, _, uv, _, _, factor, _, _) = vals
55
- if not np.allclose(factor, 1):
56
- raise RuntimeError('baseColorFactor')
57
- exported = torch.as_tensor(vertices, dtype=torch.float32, device='cuda')
58
- native = exported * torch.tensor((-1.0, 1.0, -1.0), device='cuda')
59
- faces = torch.as_tensor(faces, dtype=torch.int32, device='cuda').contiguous()
60
- uv = torch.as_tensor(uv, dtype=torch.float32, device='cuda')
61
- uv = torch.stack((uv[:, 0], 1 - uv[:, 1]), -1).contiguous()
62
- base = torch.as_tensor(pix[..., :3].copy(), dtype=torch.float32, device='cuda').reshape(-1, 3) / 255
63
- rgba = np.asarray(Image.open(a.source_rgba).convert('RGBA'))
64
- hard = cv2.erode((rgba[..., 3] >= 254).astype('uint8'), np.ones((5, 5), np.uint8)).astype(bool)
65
- albedo = np.asarray(Image.open(a.source_albedo).convert('RGB'), np.float32) / 255
66
- if albedo.shape[:2] != (RES, RES) or hard.shape != (RES, RES):
67
- raise ValueError('aligned source must be 1024')
68
- trust = np.clip(cv2.distanceTransform(hard.astype('uint8'), cv2.DIST_L2, 3) / 4.0, 0, 1)
69
- src = torch.as_tensor(albedo, device='cuda').permute(2, 0, 1)[None]
70
- trust_t = torch.as_tensor(trust, dtype=torch.float32, device='cuda')[None, None]
71
- hard_t = torch.as_tensor(hard, device='cuda')
72
- (E, K) = rm._native_camera(camera, 'cuda', rt)
73
- original = rm._exported_glb_camera
74
-
75
- def pc(cam, device, runtime):
76
- (ex, inn) = original(cam, device, runtime)
77
- (dx, dy) = cam.get('front_atlas_phase', (0.0, 0.0))
78
- inn = inn.clone()
79
- inn[0, 2] += dx / RES
80
- inn[1, 2] += dy / RES
81
- return (ex, inn)
82
- rm._exported_glb_camera = pc
83
-
84
- def op(phase, need_all=False):
85
- cam = copy.deepcopy(camera)
86
- cam['front_atlas_phase'] = phase
87
- (ee, kk) = pc(cam, 'cuda', rt)
88
- dist = rm._camera_distance(cam)
89
- proj = rt['intrinsics_to_projection'](kk, max(0.2, dist - 1), dist + 1)
90
- h = torch.cat((exported, torch.ones_like(exported[:, :1])), 1)
91
- (rast, _) = dr.rasterize(dr.RasterizeCudaContext(device='cuda'), (h @ (proj @ ee).T)[None].contiguous(), faces, (RES, RES))
92
- (uvs, _) = dr.interpolate(uv[None], rast, faces)
93
- (points, _) = dr.interpolate(native[None], rast, faces)
94
- mask = (rast[0, ..., -1] > 0).cpu().numpy()
95
- rowsall = torch.as_tensor(np.flatnonzero(mask.reshape(-1)), device='cuda')
96
- xyz = points[0].reshape(-1, 3)[rowsall]
97
- q = torch.cat((xyz, torch.ones_like(xyz[:, :1])), 1) @ E.T
98
- q = q[:, :3] @ K.T
99
- suv = q[:, :2] / q[:, 2:3].clamp_min(1e-08)
100
- near = torch.floor(suv * RES).long()
101
- inside = (near[:, 0] >= 0) & (near[:, 0] < RES) & (near[:, 1] >= 0) & (near[:, 1] < RES) & (q[:, 2] > 0)
102
- trusted = torch.zeros(len(rowsall), dtype=torch.bool, device='cuda')
103
- trusted[inside] = hard_t[near[inside, 1], near[inside, 0]]
104
- rows = rowsall[trusted]
105
- tsuv = suv[trusted]
106
-
107
- def fp(rr):
108
- t = uvs[0].reshape(-1, 2)[rr] * SIZE - 0.5
109
- xy = t.floor().long()
110
- f = t - xy
111
- ids = []
112
- ws = []
113
- for (dx, dy) in ((0, 0), (1, 0), (0, 1), (1, 1)):
114
- ids.append((xy[:, 1] + dy) % SIZE * SIZE + (xy[:, 0] + dx) % SIZE)
115
- ws.append((f[:, 0] if dx else 1 - f[:, 0]) * (f[:, 1] if dy else 1 - f[:, 1]))
116
- return (torch.stack(ids, 1), torch.stack(ws, 1))
117
- (idx, w) = fp(rows)
118
- unsafe = np.zeros(RES * RES, np.uint8)
119
- unsafe[rowsall[~trusted].cpu().numpy()] = 1
120
- unsafe = cv2.dilate(unsafe.reshape(RES, RES), np.ones((5, 5), np.uint8)) & mask
121
- ur = torch.as_tensor(np.flatnonzero(unsafe.reshape(-1)), device='cuda')
122
- outside = fp(ur)[0] if len(ur) else torch.empty((0, 4), device='cuda', dtype=torch.long)
123
- target = rt['F'].grid_sample(src, (tsuv * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, :, 0].T
124
- strength = rt['F'].grid_sample(trust_t, (tsuv * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, 0, 0]
125
- screen = np.zeros(RES * RES, bool)
126
- screen[rows.cpu().numpy()] = True
127
- return {'phase': phase, 'idx': idx, 'w': w, 'outside': outside, 'target': target, 'strength': strength, 'rows': rows, 'screen': screen.reshape(RES, RES), 'uvs': uvs, 'points': points, 'rast': rast, 'trusted': trusted, 'rowsall': rowsall, 'suv': suv}
128
- ops = [op(p) for p in TRAIN]
129
- active = torch.unique(torch.cat([o['idx'].reshape(-1) for o in ops]))
130
- (rastuv, _) = dr.rasterize(dr.RasterizeCudaContext(device='cuda'), torch.cat((uv * 2 - 1, torch.zeros_like(uv[:, :1]), torch.ones_like(uv[:, :1])), 1)[None], faces, (SIZE, SIZE))
131
- (pxyz, _) = dr.interpolate(native[None], rastuv, faces)
132
- xy = torch.stack((active // SIZE, active % SIZE), 1)
133
- covered = rastuv[0, xy[:, 0], xy[:, 1], -1] > 0
134
- xyz = pxyz[0, xy[:, 0], xy[:, 1]]
135
- q = torch.cat((xyz, torch.ones_like(xyz[:, :1])), 1) @ E.T
136
- q = q[:, :3] @ K.T
137
- centre_suv = q[:, :2] / q[:, 2:3].clamp_min(1e-08)
138
- ne = torch.floor(centre_suv * RES).long()
139
- inside = (ne[:, 0] >= 0) & (ne[:, 0] < RES) & (ne[:, 1] >= 0) & (ne[:, 1] < RES) & (q[:, 2] > 0)
140
- centre_trust = torch.zeros(len(active), dtype=torch.bool, device='cuda')
141
- centre_trust[inside] = hard_t[ne[inside, 1], ne[inside, 0]]
142
- centre_keep = covered & centre_trust
143
- active = active[centre_keep]
144
- centre_suv = centre_suv[centre_keep]
145
- look = torch.full((SIZE * SIZE,), -1, dtype=torch.long, device='cuda')
146
- look[active] = torch.arange(len(active), device='cuda')
147
- outids = torch.unique(torch.cat([o['outside'].reshape(-1) for o in ops if o['outside'].numel()])) if any((o['outside'].numel() for o in ops)) else torch.empty(0, dtype=torch.long, device='cuda')
148
- blocked = torch.zeros(len(active), dtype=torch.bool, device='cuda')
149
- if len(outids):
150
- where = torch.searchsorted(active, outids)
151
- hit = (where < len(active)) & (active[where.clamp_max(len(active) - 1)] == outids)
152
- blocked[where[hit]] = True
153
- if active.numel() == 0 or not bool((~blocked).any()):
154
- raise RuntimeError('no unblocked trusted atlas texels remain')
155
- np.savez_compressed(a.output / 'COMMON_DOMAIN.npz', active_texel_ids=active.cpu().numpy(), blocked_texel_ids=active[blocked].cpu().numpy())
156
- for o in ops:
157
- compact = look[o['idx']]
158
- keep = (compact >= 0).all(1)
159
- for key in ('idx', 'w'):
160
- o[key] = o[key][keep]
161
- for key in ('target', 'strength', 'rows'):
162
- o[key] = o[key][keep]
163
- o['compact'] = compact[keep]
164
- o['allowed'] = (~blocked[o['compact']]).float()
165
- o['base'] = (base[o['idx']] * o['w'][..., None]).sum(1)
166
- o['screen'] = np.zeros((RES, RES), bool)
167
- o['screen'].reshape(-1)[o['rows'].cpu().numpy()] = True
168
- mode = 'uv_texel_centre_projection'
169
- corrected = base[active].clone()
170
- s = centre_suv
171
- sample = rt['F'].grid_sample(src, (s * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, :, 0].T
172
- strength = rt['F'].grid_sample(trust_t, (s * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, 0, 0]
173
- take = ~blocked
174
- corrected[take] = base[active][take] + strength[take, None] * (sample[take] - base[active][take])
175
- out = pix.copy()
176
- out.reshape(-1, out.shape[-1])[active.cpu().numpy(), :3] = (corrected.cpu().numpy() * 255 + 0.5).astype(np.uint8)
177
- blob = io.BytesIO()
178
- Image.fromarray(out).save(blob, format='PNG')
179
- od = copy.deepcopy(doc)
180
- ob = fixed._replace_image_payload(od, bin0, vi, off, n, blob.getvalue())
181
- candidate = a.output / 'candidate'
182
- candidate.mkdir()
183
- glb = candidate / 'mesh.glb'
184
- fixed._write_glb(glb, od, ob)
185
- pres = fixed._nonbase_identity_check(doc, bin0, glb, vi)
186
-
187
- def render(p, split):
188
- folder = a.output / 'renders' / split / f'x{p[0]:.3f}_y{p[1]:.3f}'
189
- folder.mkdir(parents=True)
190
- cam = copy.deepcopy(camera)
191
- cam['front_atlas_phase'] = p
192
- o = op(p)
193
- ca = rm.render_glb(glb, cam, folder / 'candidate.png', resolution=RES, rt=rt)
194
- ba = rm.render_glb(a.baseline_glb, cam, folder / 'baseline.png', resolution=RES, rt=rt)
195
- with np.load(ca['attrs_npz']) as z:
196
- (ci, cm, cmra) = (z['albedo'], z['mask'].astype(bool), z['mra'])
197
- with np.load(ba['attrs_npz']) as z:
198
- (bi, bm, bmra) = (z['albedo'], z['mask'].astype(bool), z['mra'])
199
- if not np.array_equal(cm, bm) or not np.array_equal(cmra, bmra):
200
- raise RuntimeError('mask/MRA changed')
201
- target = np.zeros_like(ci)
202
- target.reshape(-1, 3)[o['rows'].cpu().numpy()] = o['target'].cpu().numpy()
203
- sel = o['screen'] & cm
204
- Image.fromarray((target * 255 + 0.5).astype(np.uint8)).save(folder / 'target_projected.png')
205
- quant = torch.as_tensor(out[..., :3], dtype=torch.float32, device='cuda').reshape(-1, 3) / 255
206
- replay = (quant[o['idx']] * o['w'][..., None]).sum(1).cpu().numpy()
207
- actual = ci.reshape(-1, 3)[o['rows'].cpu().numpy()]
208
- mx = float(np.abs(replay - actual).max())
209
- outside = cm & ~o['screen']
210
- oo = float(np.abs(ci - bi)[outside].max()) if outside.any() else 0.0
211
- r = {'phase': p, 'pixels': int(sel.sum()), 'candidate_vs_source': rm._masked_metrics(ci, target, sel), 'baseline_vs_source': rm._masked_metrics(bi, target, sel), 'quantized_atlas_replay_max': mx, 'outside_source_hardtrust_max_abs_threshold': OUTSIDE_COLOR_MAX_ABS, 'outside_source_hardtrust_delta_max_abs': oo, 'passes': bool(mx <= 1e-05 and outside_color_passes(oo))}
212
- r.update(mask_exact=True, mra_exact=True, evidence={'candidate': bind_file(Path(ca['attrs_npz'])), 'baseline': bind_file(Path(ba['attrs_npz']))})
213
- put(folder / 'PHASE_RECEIPT.json', r)
214
- return r
215
- train = [render(x, 'train') for x in TRAIN]
216
- held = [render(x, 'heldout') for x in HELD]
217
- changed = int(np.any(out[..., :3] != pix[..., :3], axis=-1).sum())
218
- conv = True
219
- ok = conv and all((x['passes'] for x in train + held))
220
- result = {'status': 'valid' if ok else 'invalid', 'arm': a.arm, 'mode': mode, 'seconds': time.monotonic() - began, 'candidate_glb': str(glb), 'gates': {'accepted': ok, 'common_support_and_protection': True, 'nonbase_identity': pres, 'all_19_phase_renders': len(train) + len(held) == 19}, 'atlas': {'active_texels': int(len(active)), 'blocked_texels': int(blocked.sum()), 'changed_texels': changed, 'alpha_exact': bool(np.array_equal(out[..., 3], pix[..., 3])) if out.shape[-1] == 4 else True}, 'phase_results': {'train': train, 'heldout': held}, 'limits': 'Fixed-shape, observed-source front control. It does not establish novel-view quality or a model improvement.'}
221
- put(a.output / 'RECEIPT.json', result)
222
- put(a.output / 'RESULT.json', result)
223
- if not ok:
224
- raise RuntimeError('acceptance failed')
225
- print(json.dumps({'status': 'valid', 'candidate': str(glb)}))
226
- if __name__ == '__main__':
227
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Project observed albedo at UV texel centres with measured 19-phase gates.
2
+
3
+ Support is the union of atlas texels touched by the sixteen grid phases,
4
+ intersected with UV-covered texel centres whose source projection lands on
5
+ hard-trusted observation pixels (alpha >= 254, eroded by two pixels). Bilinear
6
+ atlas taps of pixels near untrusted observation regions are blocked. Each kept
7
+ texel T0 becomes T0 + s * (source - T0), where source is the bilinear predicted
8
+ albedo at the texel centre's projection and s is the observation trust. The
9
+ candidate GLB is then re-rendered at all grid and off-grid phases.
10
+ """
11
+ from __future__ import annotations
12
+ import argparse, copy, io, json, os, sys, time
13
+ from pathlib import Path
14
+ from ._common import GRID_PHASES, OFFGRID_PHASES, OUTSIDE_COLOR_MAX_ABS, outside_color_passes, load_renderer, bind_file
15
+ from . import _glb as fixed
16
+ SCHEMA = 'supratex.reprojection.project'
17
+ SIZE = 4096
18
+ RES = 1024
19
+
20
+ def put(p, x):
21
+ if p.exists():
22
+ raise FileExistsError(p)
23
+ p.write_text(json.dumps(x, indent=2, sort_keys=True, allow_nan=False) + '\n')
24
+
25
+ def main():
26
+ ap = argparse.ArgumentParser(description=__doc__)
27
+ ap.add_argument('--baseline-glb', type=Path, required=True)
28
+ ap.add_argument('--source-albedo', type=Path, required=True)
29
+ ap.add_argument('--source-rgba', type=Path, required=True)
30
+ ap.add_argument('--camera', type=Path, required=True)
31
+ ap.add_argument('--renderer', type=Path, required=True)
32
+ ap.add_argument('--output', type=Path, required=True)
33
+ a = ap.parse_args()
34
+ if a.output.exists():
35
+ raise FileExistsError(a.output)
36
+ for p in (a.baseline_glb, a.source_albedo, a.source_rgba, a.camera, a.renderer):
37
+ if not p.is_file():
38
+ raise FileNotFoundError(p)
39
+ a.output.mkdir(parents=True)
40
+ put(a.output / 'PLAN.json', {
41
+ 'schema': SCHEMA,
42
+ 'baseline_glb': str(a.baseline_glb.absolute()),
43
+ 'source_albedo': str(a.source_albedo.absolute()),
44
+ 'source_rgba': str(a.source_rgba.absolute()),
45
+ 'camera': str(a.camera.absolute()),
46
+ 'renderer': str(a.renderer.absolute()),
47
+ 'size': SIZE,
48
+ 'resolution': RES,
49
+ 'grid_phases': GRID_PHASES,
50
+ 'offgrid_phases': OFFGRID_PHASES,
51
+ })
52
+ os.environ['FLEX_GEMM_USE_AUTOTUNE_CACHE'] = '0'
53
+ os.environ['FLEX_GEMM_AUTOSAVE_AUTOTUNE_CACHE'] = '0'
54
+ import cv2, numpy as np, torch
55
+ from PIL import Image
56
+ torch.backends.cuda.matmul.allow_tf32 = False
57
+ torch.manual_seed(42)
58
+ began = time.monotonic()
59
+ rm = load_renderer(a.renderer)
60
+ rt = rm._runtime_imports()
61
+ dr = rt['dr']
62
+ camera = json.loads(a.camera.read_text())
63
+ (doc, bin0) = fixed._read_glb(a.baseline_glb)
64
+ fixed._assert_fixed_scene(doc)
65
+ (ii, vi) = fixed._base_color_image_ref(doc)
66
+ (im, _, off, n) = fixed._decode_base_image(doc, bin0, ii, vi)
67
+ pix = np.asarray(im).copy()
68
+ if pix.shape[:2] != (SIZE, SIZE):
69
+ raise ValueError('requires 4096 atlas')
70
+ vals = rt['_load_textured_mesh'](a.baseline_glb, rt)
71
+ (vertices, faces, _, uv, _, _, factor, _, _) = vals
72
+ if not np.allclose(factor, 1):
73
+ raise RuntimeError('baseColorFactor')
74
+ exported = torch.as_tensor(vertices, dtype=torch.float32, device='cuda')
75
+ native = exported * torch.tensor((-1.0, 1.0, -1.0), device='cuda')
76
+ faces = torch.as_tensor(faces, dtype=torch.int32, device='cuda').contiguous()
77
+ uv = torch.as_tensor(uv, dtype=torch.float32, device='cuda')
78
+ uv = torch.stack((uv[:, 0], 1 - uv[:, 1]), -1).contiguous()
79
+ base = torch.as_tensor(pix[..., :3].copy(), dtype=torch.float32, device='cuda').reshape(-1, 3) / 255
80
+ rgba = np.asarray(Image.open(a.source_rgba).convert('RGBA'))
81
+ hard = cv2.erode((rgba[..., 3] >= 254).astype('uint8'), np.ones((5, 5), np.uint8)).astype(bool)
82
+ albedo = np.asarray(Image.open(a.source_albedo).convert('RGB'), np.float32) / 255
83
+ if albedo.shape[:2] != (RES, RES) or hard.shape != (RES, RES):
84
+ raise ValueError('aligned source must be 1024')
85
+ trust = np.clip(cv2.distanceTransform(hard.astype('uint8'), cv2.DIST_L2, 3) / 4.0, 0, 1)
86
+ src = torch.as_tensor(albedo, device='cuda').permute(2, 0, 1)[None]
87
+ trust_t = torch.as_tensor(trust, dtype=torch.float32, device='cuda')[None, None]
88
+ hard_t = torch.as_tensor(hard, device='cuda')
89
+ (E, K) = rm._native_camera(camera, 'cuda', rt)
90
+ original = rm._exported_glb_camera
91
+
92
+ def pc(cam, device, runtime):
93
+ (ex, inn) = original(cam, device, runtime)
94
+ (dx, dy) = cam.get('front_atlas_phase', (0.0, 0.0))
95
+ inn = inn.clone()
96
+ inn[0, 2] += dx / RES
97
+ inn[1, 2] += dy / RES
98
+ return (ex, inn)
99
+ rm._exported_glb_camera = pc
100
+
101
+ def op(phase):
102
+ cam = copy.deepcopy(camera)
103
+ cam['front_atlas_phase'] = phase
104
+ (ee, kk) = pc(cam, 'cuda', rt)
105
+ dist = rm._camera_distance(cam)
106
+ proj = rt['intrinsics_to_projection'](kk, max(0.2, dist - 1), dist + 1)
107
+ h = torch.cat((exported, torch.ones_like(exported[:, :1])), 1)
108
+ (rast, _) = dr.rasterize(dr.RasterizeCudaContext(device='cuda'), (h @ (proj @ ee).T)[None].contiguous(), faces, (RES, RES))
109
+ (uvs, _) = dr.interpolate(uv[None], rast, faces)
110
+ (points, _) = dr.interpolate(native[None], rast, faces)
111
+ mask = (rast[0, ..., -1] > 0).cpu().numpy()
112
+ rowsall = torch.as_tensor(np.flatnonzero(mask.reshape(-1)), device='cuda')
113
+ xyz = points[0].reshape(-1, 3)[rowsall]
114
+ q = torch.cat((xyz, torch.ones_like(xyz[:, :1])), 1) @ E.T
115
+ q = q[:, :3] @ K.T
116
+ suv = q[:, :2] / q[:, 2:3].clamp_min(1e-08)
117
+ near = torch.floor(suv * RES).long()
118
+ inside = (near[:, 0] >= 0) & (near[:, 0] < RES) & (near[:, 1] >= 0) & (near[:, 1] < RES) & (q[:, 2] > 0)
119
+ trusted = torch.zeros(len(rowsall), dtype=torch.bool, device='cuda')
120
+ trusted[inside] = hard_t[near[inside, 1], near[inside, 0]]
121
+ rows = rowsall[trusted]
122
+ tsuv = suv[trusted]
123
+
124
+ def fp(rr):
125
+ t = uvs[0].reshape(-1, 2)[rr] * SIZE - 0.5
126
+ xy = t.floor().long()
127
+ f = t - xy
128
+ ids = []
129
+ ws = []
130
+ for (dx, dy) in ((0, 0), (1, 0), (0, 1), (1, 1)):
131
+ ids.append((xy[:, 1] + dy) % SIZE * SIZE + (xy[:, 0] + dx) % SIZE)
132
+ ws.append((f[:, 0] if dx else 1 - f[:, 0]) * (f[:, 1] if dy else 1 - f[:, 1]))
133
+ return (torch.stack(ids, 1), torch.stack(ws, 1))
134
+ (idx, w) = fp(rows)
135
+ unsafe = np.zeros(RES * RES, np.uint8)
136
+ unsafe[rowsall[~trusted].cpu().numpy()] = 1
137
+ unsafe = cv2.dilate(unsafe.reshape(RES, RES), np.ones((5, 5), np.uint8)) & mask
138
+ ur = torch.as_tensor(np.flatnonzero(unsafe.reshape(-1)), device='cuda')
139
+ outside = fp(ur)[0] if len(ur) else torch.empty((0, 4), device='cuda', dtype=torch.long)
140
+ target = rt['F'].grid_sample(src, (tsuv * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, :, 0].T
141
+ strength = rt['F'].grid_sample(trust_t, (tsuv * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, 0, 0]
142
+ screen = np.zeros(RES * RES, bool)
143
+ screen[rows.cpu().numpy()] = True
144
+ return {
145
+ 'phase': phase,
146
+ 'idx': idx,
147
+ 'w': w,
148
+ 'outside': outside,
149
+ 'target': target,
150
+ 'strength': strength,
151
+ 'rows': rows,
152
+ 'screen': screen.reshape(RES, RES),
153
+ }
154
+ ops = [op(p) for p in GRID_PHASES]
155
+ active = torch.unique(torch.cat([o['idx'].reshape(-1) for o in ops]))
156
+ (rastuv, _) = dr.rasterize(dr.RasterizeCudaContext(device='cuda'), torch.cat((uv * 2 - 1, torch.zeros_like(uv[:, :1]), torch.ones_like(uv[:, :1])), 1)[None], faces, (SIZE, SIZE))
157
+ (pxyz, _) = dr.interpolate(native[None], rastuv, faces)
158
+ xy = torch.stack((active // SIZE, active % SIZE), 1)
159
+ covered = rastuv[0, xy[:, 0], xy[:, 1], -1] > 0
160
+ xyz = pxyz[0, xy[:, 0], xy[:, 1]]
161
+ q = torch.cat((xyz, torch.ones_like(xyz[:, :1])), 1) @ E.T
162
+ q = q[:, :3] @ K.T
163
+ centre_suv = q[:, :2] / q[:, 2:3].clamp_min(1e-08)
164
+ ne = torch.floor(centre_suv * RES).long()
165
+ inside = (ne[:, 0] >= 0) & (ne[:, 0] < RES) & (ne[:, 1] >= 0) & (ne[:, 1] < RES) & (q[:, 2] > 0)
166
+ centre_trust = torch.zeros(len(active), dtype=torch.bool, device='cuda')
167
+ centre_trust[inside] = hard_t[ne[inside, 1], ne[inside, 0]]
168
+ centre_keep = covered & centre_trust
169
+ active = active[centre_keep]
170
+ centre_suv = centre_suv[centre_keep]
171
+ look = torch.full((SIZE * SIZE,), -1, dtype=torch.long, device='cuda')
172
+ look[active] = torch.arange(len(active), device='cuda')
173
+ if any(o['outside'].numel() for o in ops):
174
+ outids = torch.unique(torch.cat([o['outside'].reshape(-1) for o in ops if o['outside'].numel()]))
175
+ else:
176
+ outids = torch.empty(0, dtype=torch.long, device='cuda')
177
+ blocked = torch.zeros(len(active), dtype=torch.bool, device='cuda')
178
+ if len(outids):
179
+ where = torch.searchsorted(active, outids)
180
+ hit = (where < len(active)) & (active[where.clamp_max(len(active) - 1)] == outids)
181
+ blocked[where[hit]] = True
182
+ if active.numel() == 0 or not bool((~blocked).any()):
183
+ raise RuntimeError('no unblocked trusted atlas texels remain')
184
+ np.savez_compressed(a.output / 'COMMON_DOMAIN.npz', active_texel_ids=active.cpu().numpy(), blocked_texel_ids=active[blocked].cpu().numpy())
185
+ for o in ops:
186
+ compact = look[o['idx']]
187
+ keep = (compact >= 0).all(1)
188
+ for key in ('idx', 'w'):
189
+ o[key] = o[key][keep]
190
+ for key in ('target', 'strength', 'rows'):
191
+ o[key] = o[key][keep]
192
+ o['compact'] = compact[keep]
193
+ o['allowed'] = (~blocked[o['compact']]).float()
194
+ o['base'] = (base[o['idx']] * o['w'][..., None]).sum(1)
195
+ o['screen'] = np.zeros((RES, RES), bool)
196
+ o['screen'].reshape(-1)[o['rows'].cpu().numpy()] = True
197
+ mode = 'uv_texel_centre_projection'
198
+ corrected = base[active].clone()
199
+ s = centre_suv
200
+ sample = rt['F'].grid_sample(src, (s * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, :, 0].T
201
+ strength = rt['F'].grid_sample(trust_t, (s * 2 - 1).reshape(1, 1, -1, 2), align_corners=False, padding_mode='zeros')[0, 0, 0]
202
+ take = ~blocked
203
+ corrected[take] = base[active][take] + strength[take, None] * (sample[take] - base[active][take])
204
+ out = pix.copy()
205
+ out.reshape(-1, out.shape[-1])[active.cpu().numpy(), :3] = (corrected.cpu().numpy() * 255 + 0.5).astype(np.uint8)
206
+ blob = io.BytesIO()
207
+ Image.fromarray(out).save(blob, format='PNG')
208
+ od = copy.deepcopy(doc)
209
+ ob = fixed._replace_image_payload(od, bin0, vi, off, n, blob.getvalue())
210
+ candidate = a.output / 'candidate'
211
+ candidate.mkdir()
212
+ glb = candidate / 'mesh.glb'
213
+ fixed._write_glb(glb, od, ob)
214
+ pres = fixed._nonbase_identity_check(doc, bin0, glb, vi)
215
+
216
+ def render(p, split):
217
+ folder = a.output / 'renders' / split / f'x{p[0]:.3f}_y{p[1]:.3f}'
218
+ folder.mkdir(parents=True)
219
+ cam = copy.deepcopy(camera)
220
+ cam['front_atlas_phase'] = p
221
+ o = op(p)
222
+ ca = rm.render_glb(glb, cam, folder / 'candidate.png', resolution=RES, rt=rt)
223
+ ba = rm.render_glb(a.baseline_glb, cam, folder / 'baseline.png', resolution=RES, rt=rt)
224
+ with np.load(ca['attrs_npz']) as z:
225
+ (ci, cm, cmra) = (z['albedo'], z['mask'].astype(bool), z['mra'])
226
+ with np.load(ba['attrs_npz']) as z:
227
+ (bi, bm, bmra) = (z['albedo'], z['mask'].astype(bool), z['mra'])
228
+ if not np.array_equal(cm, bm) or not np.array_equal(cmra, bmra):
229
+ raise RuntimeError('mask/MRA changed')
230
+ target = np.zeros_like(ci)
231
+ target.reshape(-1, 3)[o['rows'].cpu().numpy()] = o['target'].cpu().numpy()
232
+ sel = o['screen'] & cm
233
+ Image.fromarray((target * 255 + 0.5).astype(np.uint8)).save(folder / 'target_projected.png', compress_level=1)
234
+ quant = torch.as_tensor(out[..., :3], dtype=torch.float32, device='cuda').reshape(-1, 3) / 255
235
+ replay = (quant[o['idx']] * o['w'][..., None]).sum(1).cpu().numpy()
236
+ actual = ci.reshape(-1, 3)[o['rows'].cpu().numpy()]
237
+ mx = float(np.abs(replay - actual).max())
238
+ outside = cm & ~o['screen']
239
+ oo = float(np.abs(ci - bi)[outside].max()) if outside.any() else 0.0
240
+ r = {
241
+ 'phase': p,
242
+ 'pixels': int(sel.sum()),
243
+ 'candidate_vs_source': rm._masked_metrics(ci, target, sel),
244
+ 'baseline_vs_source': rm._masked_metrics(bi, target, sel),
245
+ 'quantized_atlas_replay_max': mx,
246
+ 'outside_source_hardtrust_max_abs_threshold': OUTSIDE_COLOR_MAX_ABS,
247
+ 'outside_source_hardtrust_delta_max_abs': oo,
248
+ 'passes': bool(mx <= 1e-05 and outside_color_passes(oo)),
249
+ }
250
+ r.update(mask_exact=True, mra_exact=True, evidence={'candidate': bind_file(Path(ca['attrs_npz'])), 'baseline': bind_file(Path(ba['attrs_npz']))})
251
+ put(folder / 'PHASE_RECEIPT.json', r)
252
+ return r
253
+ grid = [render(x, 'grid') for x in GRID_PHASES]
254
+ offgrid = [render(x, 'offgrid') for x in OFFGRID_PHASES]
255
+ changed = int(np.any(out[..., :3] != pix[..., :3], axis=-1).sum())
256
+ ok = all((x['passes'] for x in grid + offgrid))
257
+ result = {
258
+ 'status': 'valid' if ok else 'invalid',
259
+ 'schema': SCHEMA,
260
+ 'mode': mode,
261
+ 'seconds': time.monotonic() - began,
262
+ 'candidate_glb': str(glb),
263
+ 'gates': {
264
+ 'accepted': ok,
265
+ 'common_support_and_protection': True,
266
+ 'nonbase_identity': pres,
267
+ 'all_19_phase_renders': len(grid) + len(offgrid) == 19,
268
+ },
269
+ 'atlas': {
270
+ 'active_texels': int(len(active)),
271
+ 'blocked_texels': int(blocked.sum()),
272
+ 'changed_texels': changed,
273
+ 'alpha_exact': bool(np.array_equal(out[..., 3], pix[..., 3])) if out.shape[-1] == 4 else True,
274
+ },
275
+ 'phase_results': {'grid': grid, 'offgrid': offgrid},
276
+ }
277
+ put(a.output / 'RECEIPT.json', result)
278
+ put(a.output / 'RESULT.json', result)
279
+ if not ok:
280
+ raise RuntimeError('acceptance failed')
281
+ print(json.dumps({'status': 'valid', 'candidate': str(glb)}))
282
+ if __name__ == '__main__':
283
+ main()
supratex/inference/reprojection/_renderer.py CHANGED
@@ -1,170 +1,207 @@
1
- """Unlit full-camera rendering for measured atlas preservation gates."""
2
- from __future__ import annotations
3
- import math
4
- from pathlib import Path
5
- from typing import Any, Mapping
6
- DEFAULT_RESOLUTION = 1024
7
-
8
- def _load_textured_mesh(glb: Path, rt: dict[str, Any]) -> tuple[Any, Any, Any, Any, Any, Any, Any, float, float]:
9
- (np, trimesh) = (rt['np'], rt['trimesh'])
10
- loaded = trimesh.load(glb, process=False)
11
- mesh = next(iter(loaded.geometry.values())) if isinstance(loaded, trimesh.Scene) else loaded
12
- vertices = np.asarray(mesh.vertices, dtype=np.float32)
13
- faces = np.asarray(mesh.faces, dtype=np.int32)
14
- normals = np.asarray(mesh.vertex_normals, dtype=np.float32)
15
- visual = mesh.visual
16
- if vertices.size == 0 or faces.size == 0 or normals.shape != vertices.shape:
17
- raise ValueError('GLB mesh is empty or lacks usable vertex normals')
18
- if not hasattr(visual, 'uv') or visual.uv is None:
19
- raise ValueError('GLB mesh lacks UV coordinates')
20
- material = visual.material
21
- image = getattr(material, 'baseColorTexture', None)
22
- if image is None:
23
- raise ValueError('GLB mesh lacks a baseColorTexture')
24
- metallic_roughness_image = getattr(material, 'metallicRoughnessTexture', None)
25
- if metallic_roughness_image is None:
26
- raise ValueError('GLB mesh lacks metallicRoughnessTexture; refusing to label textured render as material-aware')
27
- texture = np.asarray(image.convert('RGB'), dtype=np.float32) / 255.0
28
- metallic_roughness = np.asarray(metallic_roughness_image.convert('RGB'), dtype=np.float32) / 255.0
29
- raw_factor = getattr(material, 'baseColorFactor', None)
30
- factor = np.asarray([1, 1, 1, 1] if raw_factor is None else raw_factor, dtype=np.float32)[:3]
31
- if factor.max(initial=1.0) > 1.0:
32
- factor /= 255.0
33
- metallic = getattr(material, 'metallicFactor', None)
34
- roughness = getattr(material, 'roughnessFactor', None)
35
- return (vertices, faces, normals, np.asarray(visual.uv, dtype=np.float32), texture.astype(np.float32), metallic_roughness.astype(np.float32), factor.astype(np.float32), float(1 if metallic is None else metallic), float(1 if roughness is None else roughness))
36
-
37
- def _srgb_factor_torch(srgb: Any, factor: Any, torch: Any) -> Any:
38
- """Apply a glTF baseColorFactor in linear light and return sRGB samples."""
39
- linear = torch.where(srgb <= 0.04045, srgb / 12.92, ((srgb + 0.055) / 1.055).pow(2.4))
40
- linear = linear * torch.as_tensor(factor, dtype=srgb.dtype, device=srgb.device).view(1, 1, 1, 3)
41
- return torch.where(linear <= 0.0031308, linear * 12.92, 1.055 * linear.clamp_min(0).pow(1.0 / 2.4) - 0.055).clamp(0.0, 1.0)
42
-
43
- def _runtime_imports() -> dict[str, Any]:
44
- """Import CUDA dependencies only when rendering is actually requested."""
45
- import numpy as np
46
- import torch
47
- import torch.nn.functional as F
48
- import trimesh
49
- import nvdiffrast.torch as dr
50
- import utils3d
51
- from PIL import Image
52
- from supratex.renderers.mesh_renderer import intrinsics_to_projection
53
- from ._renderer import _load_textured_mesh
54
- return locals()
55
-
56
- def _check_resolution(resolution: int) -> None:
57
- if resolution <= 0:
58
- raise ValueError('resolution must be positive')
59
-
60
- def _camera_distance(camera: Mapping[str, Any]) -> float:
61
- try:
62
- distance = float(camera['distance']) * float(camera.get('mesh_scale', 1.0))
63
- except (KeyError, TypeError, ValueError) as error:
64
- raise ValueError('camera requires canonical distance and optional mesh_scale') from error
65
- if not math.isfinite(distance) or distance <= 0:
66
- raise ValueError('camera distance * mesh_scale must be finite and positive')
67
- return distance
68
-
69
- def _native_camera(camera: Mapping[str, Any], device: str, rt: Mapping[str, Any]) -> tuple[Any, Any]:
70
- """Return the baseline camera, accepting a full CV camera when supplied."""
71
- if 'extrinsic' in camera and ('projection_intrinsic' in camera or 'intrinsic' in camera):
72
- intrinsic_key = 'projection_intrinsic' if 'projection_intrinsic' in camera else 'intrinsic'
73
- extrinsic = rt['torch'].as_tensor(camera['extrinsic'], dtype=rt['torch'].float32, device=device)
74
- intrinsic = rt['torch'].as_tensor(camera[intrinsic_key], dtype=rt['torch'].float32, device=device)
75
- if extrinsic.shape != (4, 4) or intrinsic.shape != (3, 3):
76
- raise ValueError('full camera requires 4x4 extrinsic and 3x3 intrinsic')
77
- return (extrinsic, intrinsic)
78
- distance = _camera_distance(camera)
79
- try:
80
- fov = float(camera['camera_angle_x'])
81
- except (KeyError, TypeError, ValueError) as error:
82
- raise ValueError('canonical camera requires camera_angle_x') from error
83
- if not 0 < fov < math.pi:
84
- raise ValueError('camera_angle_x must be in (0, pi)')
85
- (torch, utils3d) = (rt['torch'], rt['utils3d'])
86
- eye = torch.tensor((0.0, 0.0, distance), dtype=torch.float32, device=device)
87
- target = torch.zeros(3, dtype=torch.float32, device=device)
88
- up = torch.tensor((0.0, 1.0, 0.0), dtype=torch.float32, device=device)
89
- extrinsic = utils3d.torch.extrinsics_look_at(eye, target, up)
90
- fov_t = torch.tensor(fov, dtype=torch.float32, device=device)
91
- return (extrinsic, utils3d.torch.intrinsics_from_fov_xy(fov_t, fov_t))
92
-
93
- def _exported_glb_camera(camera: Mapping[str, Any], device: str, rt: Mapping[str, Any]):
94
- """Map exported vertices into the native camera, including off-axis poses."""
95
- (extrinsic, intrinsic) = _native_camera(camera, device, rt)
96
- sign = rt['torch'].diag(rt['torch'].tensor([-1.0, 1.0, -1.0, 1.0], device=device))
97
- return (extrinsic @ sign, intrinsic)
98
-
99
- def _write_outputs(out: Path, albedo: Any, mra: Any, mask: Any, rt: Mapping[str, Any], save_attrs: bool) -> dict[str, str]:
100
- (np, Image) = (rt['np'], rt['Image'])
101
- out.parent.mkdir(parents=True, exist_ok=True)
102
- albedo_u8 = (np.clip(albedo, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
103
- mask_u8 = (np.asarray(mask, dtype=np.float32) >= 0.5).astype(np.uint8) * 255
104
- Image.fromarray(albedo_u8, mode='RGB').save(out)
105
- mask_path = out.with_name(out.stem + '_mask.png')
106
- Image.fromarray(mask_u8, mode='L').save(mask_path)
107
- result = {'png': str(out), 'mask_png': str(mask_path)}
108
- if save_attrs:
109
- attrs_path = out.with_suffix('.npz')
110
- np.savez_compressed(attrs_path, albedo=np.asarray(albedo, dtype=np.float32), mra=np.asarray(mra, dtype=np.float32), mask=np.asarray(mask, dtype=np.uint8))
111
- result['attrs_npz'] = str(attrs_path)
112
- return result
113
-
114
- def render_glb(glb: str | Path, camera: Mapping[str, Any], out: str | Path, resolution: int=DEFAULT_RESOLUTION, *, device: str='cuda', rt: Mapping[str, Any] | None=None, save_attrs: bool=True) -> dict[str, str]:
115
- """Render an exported GLB through the supplied native-frame camera.
116
-
117
- Sample base color and MRA directly with nvdiffrast, without lighting.
118
- """
119
- _check_resolution(resolution)
120
- runtime = _runtime_imports() if rt is None else dict(rt)
121
- distance = _camera_distance(camera)
122
- (extrinsic, intrinsic) = _exported_glb_camera(camera, device, runtime)
123
- values = runtime['_load_textured_mesh'](Path(glb), runtime)
124
- (vertices, faces, _normals, uv, texture, mr_texture, factor, metallic_factor, roughness_factor) = values
125
- (torch, dr) = (runtime['torch'], runtime['dr'])
126
- projection = runtime['intrinsics_to_projection'](intrinsic, max(0.2, distance - 1.0), distance + 1.0)
127
- vertices_t = torch.as_tensor(vertices, dtype=torch.float32, device=device)
128
- faces_t = torch.as_tensor(faces, dtype=torch.int32, device=device).contiguous()
129
- uv_t = torch.as_tensor(uv, dtype=torch.float32, device=device)
130
- uv_t = torch.stack((uv_t[:, 0], 1.0 - uv_t[:, 1]), dim=-1).contiguous()
131
- vertices_h = torch.cat((vertices_t, torch.ones_like(vertices_t[:, :1])), dim=-1)
132
- clip = vertices_h @ (projection @ extrinsic).T
133
- glctx = dr.RasterizeCudaContext(device=device)
134
- (raster, _) = dr.rasterize(glctx, clip[None].contiguous(), faces_t, (resolution, resolution))
135
- mask = raster[..., -1] > 0
136
- (uv_pixel, _) = dr.interpolate(uv_t[None], raster, faces_t)
137
- base = dr.texture(torch.as_tensor(texture, dtype=torch.float32, device=device)[None].contiguous(), uv_pixel, filter_mode='linear')
138
- base = _srgb_factor_torch(base, factor, torch)
139
- mra = dr.texture(torch.as_tensor(mr_texture, dtype=torch.float32, device=device)[None].contiguous(), uv_pixel, filter_mode='linear')
140
- mra = torch.stack((mra[..., 2] * metallic_factor, mra[..., 1] * roughness_factor, torch.ones_like(mra[..., 0])), dim=-1).clamp(0.0, 1.0)
141
- base = torch.where(mask[..., None], base, torch.zeros_like(base))
142
- mra = torch.where(mask[..., None], mra, torch.zeros_like(mra))
143
- return _write_outputs(Path(out), base[0].detach().cpu().numpy(), mra[0].detach().cpu().numpy(), mask[0].detach().cpu().numpy(), runtime, save_attrs)
144
-
145
- def _as_rgb(value: Any, name: str):
146
- import numpy as np
147
- array = np.asarray(value, dtype=np.float32)
148
- if array.ndim != 3 or array.shape[-1] != 3 or (not np.isfinite(array).all()) or (array.min() < 0) or (array.max() > 1):
149
- raise ValueError(f'{name} must be finite HxWx3 in [0,1]')
150
- return array
151
-
152
- def _masked_metrics(prediction: Any, target: Any, mask: Any) -> dict[str, float | int | None]:
153
- import numpy as np
154
- (pred, truth, valid) = (_as_rgb(prediction, 'prediction'), _as_rgb(target, 'target'), np.asarray(mask, dtype=bool))
155
- if pred.shape != truth.shape or valid.shape != pred.shape[:2]:
156
- raise ValueError('prediction, target, and mask shapes must agree')
157
- count = int(valid.sum())
158
- if count == 0:
159
- return {'valid_pixels': 0, 'masked_mae': None, 'psnr_db': None, 'gradient_vector_mae': None}
160
- delta = pred[valid] - truth[valid]
161
- mse = float(np.mean(delta * delta))
162
- psnr = 100.0 if mse <= 1e-12 else min(100.0, -10.0 * math.log10(mse))
163
-
164
- def sobel(image: Any):
165
- padded = np.pad(image, ((1, 1), (1, 1), (0, 0)), mode='edge')
166
- dx = padded[:-2, 2:] + 2 * padded[1:-1, 2:] + padded[2:, 2:] - padded[:-2, :-2] - 2 * padded[1:-1, :-2] - padded[2:, :-2]
167
- dy = padded[2:, :-2] + 2 * padded[2:, 1:-1] + padded[2:, 2:] - padded[:-2, :-2] - 2 * padded[:-2, 1:-1] - padded[:-2, 2:]
168
- return np.stack((dx, dy), axis=-1)
169
- grad_delta = sobel(pred)[valid] - sobel(truth)[valid]
170
- return {'valid_pixels': count, 'masked_mae': float(np.abs(delta).mean()), 'psnr_db': float(psnr), 'gradient_vector_mae': float(np.abs(grad_delta).mean())}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unlit full-camera rendering for measured atlas preservation gates."""
2
+ from __future__ import annotations
3
+ import math
4
+ from pathlib import Path
5
+ from typing import Any, Mapping
6
+ DEFAULT_RESOLUTION = 1024
7
+ # Phase gates render the same baseline and candidate many times; reloading a GLB
8
+ # decodes two 4096-square textures and recomputes normals each time. Keep the
9
+ # last two meshes' render tensors per process, keyed by file version.
10
+ _MESH_CACHE: dict[tuple, dict[str, Any]] = {}
11
+ _RASTER_CONTEXTS: dict[str, Any] = {}
12
+
13
+ def _load_textured_mesh(glb: Path, rt: dict[str, Any]) -> tuple[Any, Any, Any, Any, Any, Any, Any, float, float]:
14
+ (np, trimesh) = (rt['np'], rt['trimesh'])
15
+ loaded = trimesh.load(glb, process=False)
16
+ mesh = next(iter(loaded.geometry.values())) if isinstance(loaded, trimesh.Scene) else loaded
17
+ vertices = np.asarray(mesh.vertices, dtype=np.float32)
18
+ faces = np.asarray(mesh.faces, dtype=np.int32)
19
+ normals = np.asarray(mesh.vertex_normals, dtype=np.float32)
20
+ visual = mesh.visual
21
+ if vertices.size == 0 or faces.size == 0 or normals.shape != vertices.shape:
22
+ raise ValueError('GLB mesh is empty or lacks usable vertex normals')
23
+ if not hasattr(visual, 'uv') or visual.uv is None:
24
+ raise ValueError('GLB mesh lacks UV coordinates')
25
+ material = visual.material
26
+ image = getattr(material, 'baseColorTexture', None)
27
+ if image is None:
28
+ raise ValueError('GLB mesh lacks a baseColorTexture')
29
+ metallic_roughness_image = getattr(material, 'metallicRoughnessTexture', None)
30
+ if metallic_roughness_image is None:
31
+ raise ValueError('GLB mesh lacks metallicRoughnessTexture; refusing to label textured render as material-aware')
32
+ texture = np.asarray(image.convert('RGB'), dtype=np.float32) / 255.0
33
+ metallic_roughness = np.asarray(metallic_roughness_image.convert('RGB'), dtype=np.float32) / 255.0
34
+ raw_factor = getattr(material, 'baseColorFactor', None)
35
+ factor = np.asarray([1, 1, 1, 1] if raw_factor is None else raw_factor, dtype=np.float32)[:3]
36
+ if factor.max(initial=1.0) > 1.0:
37
+ factor /= 255.0
38
+ metallic = getattr(material, 'metallicFactor', None)
39
+ roughness = getattr(material, 'roughnessFactor', None)
40
+ return (vertices, faces, normals, np.asarray(visual.uv, dtype=np.float32), texture.astype(np.float32), metallic_roughness.astype(np.float32), factor.astype(np.float32), float(1 if metallic is None else metallic), float(1 if roughness is None else roughness))
41
+
42
+ def _srgb_factor_torch(srgb: Any, factor: Any, torch: Any) -> Any:
43
+ """Apply a glTF baseColorFactor in linear light and return sRGB samples."""
44
+ linear = torch.where(srgb <= 0.04045, srgb / 12.92, ((srgb + 0.055) / 1.055).pow(2.4))
45
+ linear = linear * torch.as_tensor(factor, dtype=srgb.dtype, device=srgb.device).view(1, 1, 1, 3)
46
+ return torch.where(linear <= 0.0031308, linear * 12.92, 1.055 * linear.clamp_min(0).pow(1.0 / 2.4) - 0.055).clamp(0.0, 1.0)
47
+
48
+ def _runtime_imports() -> dict[str, Any]:
49
+ """Import CUDA dependencies only when rendering is actually requested."""
50
+ import numpy as np
51
+ import torch
52
+ import torch.nn.functional as F
53
+ import trimesh
54
+ import nvdiffrast.torch as dr
55
+ import utils3d
56
+ from PIL import Image
57
+ from supratex.renderers.mesh_renderer import intrinsics_to_projection
58
+ from ._renderer import _load_textured_mesh
59
+ return locals()
60
+
61
+ def _check_resolution(resolution: int) -> None:
62
+ if resolution <= 0:
63
+ raise ValueError('resolution must be positive')
64
+
65
+ def _camera_distance(camera: Mapping[str, Any]) -> float:
66
+ try:
67
+ distance = float(camera['distance']) * float(camera.get('mesh_scale', 1.0))
68
+ except (KeyError, TypeError, ValueError) as error:
69
+ raise ValueError('camera requires canonical distance and optional mesh_scale') from error
70
+ if not math.isfinite(distance) or distance <= 0:
71
+ raise ValueError('camera distance * mesh_scale must be finite and positive')
72
+ return distance
73
+
74
+ def _native_camera(camera: Mapping[str, Any], device: str, rt: Mapping[str, Any]) -> tuple[Any, Any]:
75
+ """Return the baseline camera, accepting a full CV camera when supplied."""
76
+ if 'extrinsic' in camera and ('projection_intrinsic' in camera or 'intrinsic' in camera):
77
+ intrinsic_key = 'projection_intrinsic' if 'projection_intrinsic' in camera else 'intrinsic'
78
+ extrinsic = rt['torch'].as_tensor(camera['extrinsic'], dtype=rt['torch'].float32, device=device)
79
+ intrinsic = rt['torch'].as_tensor(camera[intrinsic_key], dtype=rt['torch'].float32, device=device)
80
+ if extrinsic.shape != (4, 4) or intrinsic.shape != (3, 3):
81
+ raise ValueError('full camera requires 4x4 extrinsic and 3x3 intrinsic')
82
+ return (extrinsic, intrinsic)
83
+ distance = _camera_distance(camera)
84
+ try:
85
+ fov = float(camera['camera_angle_x'])
86
+ except (KeyError, TypeError, ValueError) as error:
87
+ raise ValueError('canonical camera requires camera_angle_x') from error
88
+ if not 0 < fov < math.pi:
89
+ raise ValueError('camera_angle_x must be in (0, pi)')
90
+ (torch, utils3d) = (rt['torch'], rt['utils3d'])
91
+ eye = torch.tensor((0.0, 0.0, distance), dtype=torch.float32, device=device)
92
+ target = torch.zeros(3, dtype=torch.float32, device=device)
93
+ up = torch.tensor((0.0, 1.0, 0.0), dtype=torch.float32, device=device)
94
+ extrinsic = utils3d.torch.extrinsics_look_at(eye, target, up)
95
+ fov_t = torch.tensor(fov, dtype=torch.float32, device=device)
96
+ return (extrinsic, utils3d.torch.intrinsics_from_fov_xy(fov_t, fov_t))
97
+
98
+ def _exported_glb_camera(camera: Mapping[str, Any], device: str, rt: Mapping[str, Any]):
99
+ """Map exported vertices into the native camera, including off-axis poses."""
100
+ (extrinsic, intrinsic) = _native_camera(camera, device, rt)
101
+ sign = rt['torch'].diag(rt['torch'].tensor([-1.0, 1.0, -1.0, 1.0], device=device))
102
+ return (extrinsic @ sign, intrinsic)
103
+
104
+ def _write_outputs(out: Path, albedo: Any, mra: Any, mask: Any, rt: Mapping[str, Any], save_attrs: bool) -> dict[str, str]:
105
+ (np, Image) = (rt['np'], rt['Image'])
106
+ out.parent.mkdir(parents=True, exist_ok=True)
107
+ albedo_u8 = (np.clip(albedo, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
108
+ mask_u8 = (np.asarray(mask, dtype=np.float32) >= 0.5).astype(np.uint8) * 255
109
+ # Diagnostic previews and evidence arrays use fast compression; the stored
110
+ # values are unchanged and receipts bind whatever bytes are written.
111
+ Image.fromarray(albedo_u8, mode='RGB').save(out, compress_level=1)
112
+ mask_path = out.with_name(out.stem + '_mask.png')
113
+ Image.fromarray(mask_u8, mode='L').save(mask_path, compress_level=1)
114
+ result = {'png': str(out), 'mask_png': str(mask_path)}
115
+ if save_attrs:
116
+ attrs_path = out.with_suffix('.npz')
117
+ _save_npz(attrs_path, np, albedo=np.asarray(albedo, dtype=np.float32), mra=np.asarray(mra, dtype=np.float32), mask=np.asarray(mask, dtype=np.uint8))
118
+ result['attrs_npz'] = str(attrs_path)
119
+ return result
120
+
121
+ def _save_npz(path: Path, np: Any, **arrays: Any) -> None:
122
+ """Write a standard compressed .npz with zlib level 1 (numpy uses level 6)."""
123
+ import zipfile
124
+ with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED, compresslevel=1) as archive:
125
+ for name, value in arrays.items():
126
+ with archive.open(f'{name}.npy', 'w', force_zip64=True) as stream:
127
+ np.lib.format.write_array(stream, np.asanyarray(value), allow_pickle=False)
128
+
129
+ def _mesh_tensors(glb: Path, device: str, runtime: Mapping[str, Any]) -> dict[str, Any]:
130
+ """Load a GLB once per file version and keep its render inputs on the device."""
131
+ path = glb.resolve()
132
+ stat = path.stat()
133
+ key = (str(path), stat.st_mtime_ns, stat.st_size, device, id(runtime['_load_textured_mesh']))
134
+ cached = _MESH_CACHE.get(key)
135
+ if cached is None:
136
+ torch = runtime['torch']
137
+ (vertices, faces, _normals, uv, texture, mr_texture, factor, metallic_factor, roughness_factor) = runtime['_load_textured_mesh'](glb, runtime)
138
+ uv_t = torch.as_tensor(uv, dtype=torch.float32, device=device)
139
+ cached = {
140
+ 'vertices': torch.as_tensor(vertices, dtype=torch.float32, device=device),
141
+ 'faces': torch.as_tensor(faces, dtype=torch.int32, device=device).contiguous(),
142
+ 'uv': torch.stack((uv_t[:, 0], 1.0 - uv_t[:, 1]), dim=-1).contiguous(),
143
+ 'texture': torch.as_tensor(texture, dtype=torch.float32, device=device)[None].contiguous(),
144
+ 'mr_texture': torch.as_tensor(mr_texture, dtype=torch.float32, device=device)[None].contiguous(),
145
+ 'factor': factor, 'metallic_factor': metallic_factor, 'roughness_factor': roughness_factor,
146
+ }
147
+ while len(_MESH_CACHE) >= 2:
148
+ _MESH_CACHE.pop(next(iter(_MESH_CACHE)))
149
+ _MESH_CACHE[key] = cached
150
+ return cached
151
+
152
+ def render_glb(glb: str | Path, camera: Mapping[str, Any], out: str | Path, resolution: int=DEFAULT_RESOLUTION, *, device: str='cuda', rt: Mapping[str, Any] | None=None, save_attrs: bool=True) -> dict[str, str]:
153
+ """Render an exported GLB through the supplied native-frame camera.
154
+
155
+ Sample base color and MRA directly with nvdiffrast, without lighting.
156
+ """
157
+ _check_resolution(resolution)
158
+ runtime = _runtime_imports() if rt is None else dict(rt)
159
+ distance = _camera_distance(camera)
160
+ (extrinsic, intrinsic) = _exported_glb_camera(camera, device, runtime)
161
+ mesh = _mesh_tensors(Path(glb), device, runtime)
162
+ (torch, dr) = (runtime['torch'], runtime['dr'])
163
+ (vertices_t, faces_t, uv_t) = (mesh['vertices'], mesh['faces'], mesh['uv'])
164
+ (factor, metallic_factor, roughness_factor) = (mesh['factor'], mesh['metallic_factor'], mesh['roughness_factor'])
165
+ projection = runtime['intrinsics_to_projection'](intrinsic, max(0.2, distance - 1.0), distance + 1.0)
166
+ vertices_h = torch.cat((vertices_t, torch.ones_like(vertices_t[:, :1])), dim=-1)
167
+ clip = vertices_h @ (projection @ extrinsic).T
168
+ glctx = _RASTER_CONTEXTS.get(device)
169
+ if glctx is None:
170
+ glctx = _RASTER_CONTEXTS[device] = dr.RasterizeCudaContext(device=device)
171
+ (raster, _) = dr.rasterize(glctx, clip[None].contiguous(), faces_t, (resolution, resolution))
172
+ mask = raster[..., -1] > 0
173
+ (uv_pixel, _) = dr.interpolate(uv_t[None], raster, faces_t)
174
+ base = dr.texture(mesh['texture'], uv_pixel, filter_mode='linear')
175
+ base = _srgb_factor_torch(base, factor, torch)
176
+ mra = dr.texture(mesh['mr_texture'], uv_pixel, filter_mode='linear')
177
+ mra = torch.stack((mra[..., 2] * metallic_factor, mra[..., 1] * roughness_factor, torch.ones_like(mra[..., 0])), dim=-1).clamp(0.0, 1.0)
178
+ base = torch.where(mask[..., None], base, torch.zeros_like(base))
179
+ mra = torch.where(mask[..., None], mra, torch.zeros_like(mra))
180
+ return _write_outputs(Path(out), base[0].detach().cpu().numpy(), mra[0].detach().cpu().numpy(), mask[0].detach().cpu().numpy(), runtime, save_attrs)
181
+
182
+ def _as_rgb(value: Any, name: str):
183
+ import numpy as np
184
+ array = np.asarray(value, dtype=np.float32)
185
+ if array.ndim != 3 or array.shape[-1] != 3 or (not np.isfinite(array).all()) or (array.min() < 0) or (array.max() > 1):
186
+ raise ValueError(f'{name} must be finite HxWx3 in [0,1]')
187
+ return array
188
+
189
+ def _masked_metrics(prediction: Any, target: Any, mask: Any) -> dict[str, float | int | None]:
190
+ import numpy as np
191
+ (pred, truth, valid) = (_as_rgb(prediction, 'prediction'), _as_rgb(target, 'target'), np.asarray(mask, dtype=bool))
192
+ if pred.shape != truth.shape or valid.shape != pred.shape[:2]:
193
+ raise ValueError('prediction, target, and mask shapes must agree')
194
+ count = int(valid.sum())
195
+ if count == 0:
196
+ return {'valid_pixels': 0, 'masked_mae': None, 'psnr_db': None, 'gradient_vector_mae': None}
197
+ delta = pred[valid] - truth[valid]
198
+ mse = float(np.mean(delta * delta))
199
+ psnr = 100.0 if mse <= 1e-12 else min(100.0, -10.0 * math.log10(mse))
200
+
201
+ def sobel(image: Any):
202
+ padded = np.pad(image, ((1, 1), (1, 1), (0, 0)), mode='edge')
203
+ dx = padded[:-2, 2:] + 2 * padded[1:-1, 2:] + padded[2:, 2:] - padded[:-2, :-2] - 2 * padded[1:-1, :-2] - padded[2:, :-2]
204
+ dy = padded[2:, :-2] + 2 * padded[2:, 1:-1] + padded[2:, 2:] - padded[:-2, :-2] - 2 * padded[:-2, 1:-1] - padded[:-2, 2:]
205
+ return np.stack((dx, dy), axis=-1)
206
+ grad_delta = sobel(pred)[valid] - sobel(truth)[valid]
207
+ return {'valid_pixels': count, 'masked_mae': float(np.abs(delta).mean()), 'psnr_db': float(psnr), 'gradient_vector_mae': float(np.abs(grad_delta).mean())}