Use validated latest single-view inference and step-30000 completion weights
Browse filesPin 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
- app.py +70 -70
- configs/pipeline.json +98 -0
- deployment/runtime/SOURCE_PROVENANCE.json +26 -29
- deployment/runtime/geometry/pixal3d/models/__init__.py +78 -87
- deployment/runtime/geometry/pixal3d/models/structured_latent_flow.py +0 -0
- deployment/runtime/geometry/pixal3d/modules/projected_image_feature_extractor.py +458 -0
- deployment/runtime/geometry/pixal3d/pipelines/__init__.py +55 -57
- deployment/runtime/geometry/pixal3d/pipelines/rembg/RMBG2.py +59 -64
- deployment/runtime/geometry/pixal3d/representations/mesh/base.py +239 -239
- deployment/runtime/qwen/diffsynth/core/__init__.py +6 -8
- deployment/runtime/qwen/diffsynth/core/data/__init__.py +1 -1
- deployment/runtime/qwen/diffsynth/diffusion/__init__.py +1 -7
- deployment/runtime/qwen/diffsynth/diffusion/base_pipeline.py +526 -542
- deployment/runtime/qwen/diffsynth/diffusion/template.py +200 -209
- deployment/runtime/qwen/diffsynth/pipelines/qwen_image_21_pbr.py +90 -98
- deployment/runtime/qwen/examples/qwen_image_21/pbr/infer.py +371 -390
- deployment/runtime/qwen/examples/qwen_image_21/pbr/inference_utils.py +135 -0
- deployment/runtime/qwen/requirements.txt +6 -7
- deployment/runtime/requirements-python.txt +29 -31
- hf_space/ovoxel_results.py +117 -117
- hf_space/release_backend.py +176 -0
- hf_space/startup_progress.py +23 -23
- infer.py +350 -227
- pyproject.toml +29 -31
- scripts/check_runtime.py +172 -179
- scripts/convert_geometry_weights.py +41 -0
- scripts/install_runtime.py +356 -276
- scripts/prepare_models.py +552 -360
- scripts/run_example.py +156 -116
- supratex/frontend/camera.py +76 -79
- supratex/frontend/firered.py +135 -126
- supratex/frontend/geometry.py +174 -169
- supratex/frontend/geometry_loading.py +128 -69
- supratex/frontend/geometry_runner.py +434 -450
- supratex/frontend/material_model/runtime/pbr/conditioning.py +178 -0
- supratex/frontend/material_model/runtime/pbr/model_loading.py +136 -0
- supratex/frontend/material_model/runtime/pbr/predict_joint_pbr.py +592 -595
- supratex/frontend/material_model/runtime/pbr/qwen_pbr_factorized.py +853 -0
- supratex/frontend/pipeline.py +291 -265
- supratex/frontend/preprocess.py +128 -128
- supratex/frontend/qwen_image.py +180 -186
- supratex/inference/_kernel_config_h800.py +0 -0
- supratex/inference/geometry_cleanup.py +114 -0
- supratex/inference/native_observation.py +413 -364
- supratex/inference/pipeline.py +700 -552
- supratex/inference/reprojection/__init__.py +329 -286
- supratex/inference/reprojection/_common.py +117 -115
- supratex/inference/reprojection/_continuous.py +514 -389
- supratex/inference/reprojection/_project.py +283 -227
- 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": "
|
| 5 |
-
"
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
"
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
"
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
"
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
"
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 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 |
-
|
| 13 |
-
|
| 14 |
-
'
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
'
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
module = importlib.import_module(f".{
|
| 32 |
-
globals()[name] =
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 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 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
from . import
|
| 55 |
-
from .
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
import
|
| 7 |
-
from
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
self.
|
| 30 |
-
self.
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 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
|
| 9 |
-
#
|
| 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 .
|
| 3 |
-
from .
|
| 4 |
-
from .
|
| 5 |
-
from .
|
| 6 |
-
from .
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
self.
|
| 27 |
-
self.
|
| 28 |
-
self.
|
| 29 |
-
self.
|
| 30 |
-
self.
|
| 31 |
-
self.
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
self.
|
| 73 |
-
self.
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
self.
|
| 77 |
-
self.
|
| 78 |
-
self.
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
device
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
image =
|
| 121 |
-
image = image
|
| 122 |
-
image = image
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
video =
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
image =
|
| 139 |
-
image =
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
#
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
noise =
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
print("
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
def
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
for
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 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 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
self
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
data =
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
]
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
return
|
| 163 |
-
|
| 164 |
-
def
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
template_cache =
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 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 |
-
|
| 6 |
-
import
|
| 7 |
-
import
|
| 8 |
-
import
|
| 9 |
-
import
|
| 10 |
-
import
|
| 11 |
-
|
| 12 |
-
import
|
| 13 |
-
|
| 14 |
-
import
|
| 15 |
-
from
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
from
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
from
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
or
|
| 71 |
-
or
|
| 72 |
-
or
|
| 73 |
-
or
|
| 74 |
-
or type(
|
| 75 |
-
raise ValueError('
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
if
|
| 122 |
-
raise ValueError('
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
def
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
layout =
|
| 149 |
-
|
| 150 |
-
if
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
pipe =
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
parser =
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
if args.
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 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 |
-
|
| 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 |
-
|
| 29 |
-
|
| 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">
|
| 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 |
-
|
| 9 |
-
from
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
if
|
| 61 |
-
|
| 62 |
-
if
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
""
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "
|
| 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 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 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
|
| 41 |
-
if sys.version_info[:2] != (3, 10):
|
| 42 |
-
raise RuntimeError('The supported installation uses Python 3.10')
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
if
|
| 46 |
-
raise RuntimeError('
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
(
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
""
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
f"sys.path.insert(0, {str(RUNTIME / '
|
| 72 |
-
"
|
| 73 |
-
"
|
| 74 |
-
"import
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
"import
|
| 79 |
-
"import
|
| 80 |
-
"import
|
| 81 |
-
|
| 82 |
-
"
|
| 83 |
-
"
|
| 84 |
-
"
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
("
|
| 116 |
-
("
|
| 117 |
-
Path(config["
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
("
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 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
|
| 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 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
("
|
| 27 |
-
("
|
| 28 |
-
("
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
"-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
#
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
"
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
"
|
| 46 |
-
"
|
| 47 |
-
)
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
"
|
| 51 |
-
"assert
|
| 52 |
-
"assert
|
| 53 |
-
"assert torch.
|
| 54 |
-
"torch.
|
| 55 |
-
"
|
| 56 |
-
)
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
"
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
"
|
| 75 |
-
"
|
| 76 |
-
"
|
| 77 |
-
"
|
| 78 |
-
"
|
| 79 |
-
"
|
| 80 |
-
"
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
if
|
| 113 |
-
raise InstallError("
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
if
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
def
|
| 145 |
-
"""
|
| 146 |
-
if
|
| 147 |
-
raise InstallError(
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
print("
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
if
|
| 214 |
-
|
| 215 |
-
else:
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
run([*pip,
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
if
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
import
|
| 11 |
-
|
| 12 |
-
import
|
| 13 |
-
import
|
| 14 |
-
|
| 15 |
-
import
|
| 16 |
-
import
|
| 17 |
-
import
|
| 18 |
-
|
| 19 |
-
from
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
"""
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
value
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
if override is not
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
"
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def
|
| 167 |
-
index =
|
| 168 |
-
|
| 169 |
-
if
|
| 170 |
-
raise ValueError(f"
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
""
|
| 231 |
-
if
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
else:
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
if
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 7 |
-
import
|
| 8 |
-
|
| 9 |
-
import
|
| 10 |
-
import
|
| 11 |
-
import
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 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 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
rgb =
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
if
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
parser
|
| 66 |
-
parser.add_argument("--
|
| 67 |
-
|
| 68 |
-
parser.add_argument("--
|
| 69 |
-
parser.add_argument("--
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 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
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
import
|
| 10 |
-
import
|
| 11 |
-
import
|
| 12 |
-
import
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
#
|
| 23 |
-
#
|
| 24 |
-
|
| 25 |
-
"
|
| 26 |
-
"
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
if
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
if not
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
if
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
parser.
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
parser.add_argument("--
|
| 114 |
-
parser.add_argument("--
|
| 115 |
-
parser.add_argument("--
|
| 116 |
-
parser.add_argument("--
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
if
|
| 77 |
-
raise
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
if
|
| 83 |
-
raise ValueError("
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
if
|
| 111 |
-
raise
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
parser.
|
| 154 |
-
parser.add_argument("--
|
| 155 |
-
parser.add_argument("--
|
| 156 |
-
parser.add_argument("--
|
| 157 |
-
parser.add_argument("--
|
| 158 |
-
parser.add_argument("--
|
| 159 |
-
parser.add_argument("--
|
| 160 |
-
parser.add_argument("--
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
""
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
break
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
receipt["
|
| 105 |
-
|
| 106 |
-
"
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
raise RuntimeError("geometry
|
| 170 |
-
if not math.
|
| 171 |
-
raise RuntimeError("
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
if
|
| 184 |
-
raise ValueError("geometry
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
geometry
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
import
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
pipe =
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
""
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
args.
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
)
|
| 320 |
-
transformer.
|
| 321 |
-
transformer.
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
)
|
| 330 |
-
scheduler_config =
|
| 331 |
-
|
| 332 |
-
pipe.
|
| 333 |
-
pipe.
|
| 334 |
-
pipe.
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
#
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
import
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
).
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
import
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
)
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
pipe.scheduler.config.get("
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
"
|
| 435 |
-
"
|
| 436 |
-
"
|
| 437 |
-
"
|
| 438 |
-
"
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
prediction
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
).to(
|
| 459 |
-
|
| 460 |
-
1, pipe.vae.config.z_dim, 1, 1, 1
|
| 461 |
-
).to(unpacked.device, unpacked.dtype)
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
parser =
|
| 483 |
-
parser.add_argument("--
|
| 484 |
-
parser.add_argument("--
|
| 485 |
-
parser.add_argument("--
|
| 486 |
-
parser.add_argument("--
|
| 487 |
-
parser.add_argument("--
|
| 488 |
-
parser.add_argument("--
|
| 489 |
-
parser.add_argument("--
|
| 490 |
-
parser.add_argument("--
|
| 491 |
-
parser.add_argument("--
|
| 492 |
-
parser.add_argument("--
|
| 493 |
-
parser.add_argument("--
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
log("
|
| 536 |
-
return
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
"
|
| 568 |
-
"
|
| 569 |
-
"
|
| 570 |
-
"
|
| 571 |
-
"
|
| 572 |
-
"
|
| 573 |
-
"
|
| 574 |
-
"
|
| 575 |
-
"
|
| 576 |
-
"
|
| 577 |
-
"
|
| 578 |
-
"
|
| 579 |
-
"
|
| 580 |
-
"
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
"
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
)
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 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
|
| 10 |
-
|
| 11 |
-
from .
|
| 12 |
-
from .
|
| 13 |
-
from .
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 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 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
"
|
| 66 |
-
"
|
| 67 |
-
"
|
| 68 |
-
"
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
parser.
|
| 232 |
-
parser.add_argument("--
|
| 233 |
-
parser.add_argument("--
|
| 234 |
-
parser.add_argument("--
|
| 235 |
-
parser.add_argument("--
|
| 236 |
-
parser.add_argument("--
|
| 237 |
-
parser.add_argument("--
|
| 238 |
-
parser.add_argument("--
|
| 239 |
-
parser.add_argument("--
|
| 240 |
-
parser.add_argument("--
|
| 241 |
-
parser.add_argument("--
|
| 242 |
-
parser.add_argument("--
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 79 |
-
return image.crop((
|
| 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", "
|
| 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 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
env
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
"
|
| 177 |
-
"
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 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 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
import torch.nn.functional as F
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
if
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
if
|
| 276 |
-
raise
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
if
|
| 298 |
-
raise
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 13 |
-
from
|
| 14 |
-
from
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
from . import
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
raise ValueError("model config
|
| 60 |
-
if
|
| 61 |
-
raise ValueError("
|
| 62 |
-
if
|
| 63 |
-
raise ValueError("model config
|
| 64 |
-
if
|
| 65 |
-
raise ValueError("model config
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
_file(Path(f"{stem}.
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
stems
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
if
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
if
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
""
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
if
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
if
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
from .
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
import
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
from
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
from .
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
)
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
"--
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
if not
|
| 136 |
-
raise ValueError("reprojection side
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
final =
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
"
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
path.
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
if
|
| 58 |
-
raise ValueError(f"artifact
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
if
|
| 76 |
-
raise ValueError(
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
if
|
| 81 |
-
raise ValueError("candidate lacks
|
| 82 |
-
if
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
if
|
| 101 |
-
raise ValueError("
|
| 102 |
-
if
|
| 103 |
-
raise ValueError("outside-source color
|
| 104 |
-
if
|
| 105 |
-
raise ValueError("
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
(
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
(
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
(
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
if
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
ap
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
a.
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
(
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
o =
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
import
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
(
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
(
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
return
|
| 151 |
-
|
| 152 |
-
def
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
if
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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())}
|