simready-forge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simready_forge/__init__.py +14 -0
- simready_forge/cli.py +221 -0
- simready_forge/cost.py +120 -0
- simready_forge/examples/__init__.py +0 -0
- simready_forge/examples/cabinet.py +124 -0
- simready_forge/export.py +170 -0
- simready_forge/isaac_probe.py +785 -0
- simready_forge/isaac_review.py +420 -0
- simready_forge/kit.py +1047 -0
- simready_forge/poses.py +293 -0
- simready_forge/prefs.py +105 -0
- simready_forge/preview.py +74 -0
- simready_forge/readback.py +177 -0
- simready_forge/review.py +167 -0
- simready_forge/scan.py +129 -0
- simready_forge/trellis.py +378 -0
- simready_forge/validate.py +66 -0
- simready_forge-0.1.0.dist-info/METADATA +165 -0
- simready_forge-0.1.0.dist-info/RECORD +22 -0
- simready_forge-0.1.0.dist-info/WHEEL +4 -0
- simready_forge-0.1.0.dist-info/entry_points.txt +2 -0
- simready_forge-0.1.0.dist-info/licenses/LICENSE +21 -0
simready_forge/review.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""The review of a saved asset: Isaac Sim's RTX renders and PhysX checks when Isaac Sim is installed, a software
|
|
2
|
+
preview sheet when it is not."""
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
SCRIPT = Path(__file__).with_name("isaac_review.py")
|
|
14
|
+
PROBE = Path(__file__).with_name("isaac_probe.py")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def find_isaac(explicit=None):
|
|
18
|
+
"""Isaac Sim's Python: the argument, SIMREADY_FORGE_ISAAC, ISAACSIM_PYTHON, or a common install path."""
|
|
19
|
+
candidates = [explicit, os.environ.get("SIMREADY_FORGE_ISAAC"), os.environ.get("ISAACSIM_PYTHON")]
|
|
20
|
+
home = Path.home()
|
|
21
|
+
candidates += [
|
|
22
|
+
str(p)
|
|
23
|
+
for p in (
|
|
24
|
+
Path("D:/isaac/venv/Scripts/python.exe"),
|
|
25
|
+
Path("C:/isaac/venv/Scripts/python.exe"),
|
|
26
|
+
home / "isaacsim" / "python.bat",
|
|
27
|
+
home / "isaacsim" / "python.sh",
|
|
28
|
+
Path("/isaac-sim/python.sh"),
|
|
29
|
+
)
|
|
30
|
+
]
|
|
31
|
+
for candidate in candidates:
|
|
32
|
+
if candidate and Path(candidate).is_file():
|
|
33
|
+
return str(candidate)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextlib.contextmanager
|
|
38
|
+
def isaac_turn(wait=3600):
|
|
39
|
+
"""One Isaac Sim review at a time on this machine: two at once can exhaust memory. The lock file holds the owner's
|
|
40
|
+
process id; one left by a process that has exited is taken over."""
|
|
41
|
+
lock = Path(os.environ.get("SIMREADY_FORGE_LOCK", Path.home() / ".simready-forge" / "isaac.lock"))
|
|
42
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
deadline = time.monotonic() + wait
|
|
44
|
+
while True:
|
|
45
|
+
try:
|
|
46
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
47
|
+
os.write(fd, str(os.getpid()).encode())
|
|
48
|
+
os.close(fd)
|
|
49
|
+
break
|
|
50
|
+
except FileExistsError:
|
|
51
|
+
if not _alive(lock):
|
|
52
|
+
lock.unlink(missing_ok=True)
|
|
53
|
+
continue
|
|
54
|
+
if time.monotonic() > deadline:
|
|
55
|
+
raise TimeoutError(f"another Isaac review still holds {lock}") from None
|
|
56
|
+
time.sleep(5)
|
|
57
|
+
try:
|
|
58
|
+
yield
|
|
59
|
+
finally:
|
|
60
|
+
lock.unlink(missing_ok=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _alive(lock):
|
|
64
|
+
try:
|
|
65
|
+
pid = int(lock.read_text(encoding="utf-8").strip() or 0)
|
|
66
|
+
except (OSError, ValueError):
|
|
67
|
+
return False
|
|
68
|
+
if pid <= 0:
|
|
69
|
+
return False
|
|
70
|
+
if os.name == "nt":
|
|
71
|
+
import ctypes
|
|
72
|
+
|
|
73
|
+
handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
74
|
+
if not handle:
|
|
75
|
+
return False
|
|
76
|
+
code = ctypes.c_ulong()
|
|
77
|
+
ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(code))
|
|
78
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
79
|
+
return code.value == 259 # STILL_ACTIVE
|
|
80
|
+
try:
|
|
81
|
+
os.kill(pid, 0)
|
|
82
|
+
except OSError:
|
|
83
|
+
return False
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def review(asset, out_dir, isaac=None, timeout=1800):
|
|
88
|
+
"""Review an asset into out_dir. Returns the report dict (review.json)."""
|
|
89
|
+
out = Path(out_dir)
|
|
90
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
python = find_isaac(isaac)
|
|
92
|
+
if python is None:
|
|
93
|
+
return software_review(asset, out)
|
|
94
|
+
env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "PYTHONHOME")}
|
|
95
|
+
env["OMNI_KIT_ACCEPT_EULA"] = "YES"
|
|
96
|
+
command = [python, str(SCRIPT), str(Path(asset).resolve()), str(out.resolve())]
|
|
97
|
+
with isaac_turn(), (out / "isaac.log").open("w", encoding="utf-8") as log:
|
|
98
|
+
code = subprocess.run( # noqa: S603 (our own interpreter and script paths)
|
|
99
|
+
command,
|
|
100
|
+
env=env,
|
|
101
|
+
stdout=log,
|
|
102
|
+
stderr=subprocess.STDOUT,
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
check=False,
|
|
105
|
+
).returncode
|
|
106
|
+
report_file = out / "review.json"
|
|
107
|
+
report = json.loads(report_file.read_text(encoding="utf-8")) if report_file.is_file() else {}
|
|
108
|
+
report.update({"renderer": "isaac", "exit_code": code, "log": str(out / "isaac.log")})
|
|
109
|
+
if code == 2 or not report_file.is_file():
|
|
110
|
+
report["error"] = "the Isaac review crashed: see the log"
|
|
111
|
+
report_file.write_text(json.dumps(report, indent=1), encoding="utf-8")
|
|
112
|
+
return report
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def probe(asset, out_dir, isaac=None, timeout=3600, tests=()):
|
|
116
|
+
"""Probe an asset's physics in Isaac Sim (isaac_probe.py): settle, tilt, push, lift, load, or only the named
|
|
117
|
+
`tests`, kept beside the others' earlier results. Returns the report dict (probe.json), or one with an error when
|
|
118
|
+
Isaac Sim is missing or the probe crashed."""
|
|
119
|
+
out = Path(out_dir)
|
|
120
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
python = find_isaac(isaac)
|
|
122
|
+
if python is None:
|
|
123
|
+
return {"asset": str(asset), "error": "the probe needs Isaac Sim"}
|
|
124
|
+
env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "PYTHONHOME")}
|
|
125
|
+
env["OMNI_KIT_ACCEPT_EULA"] = "YES"
|
|
126
|
+
command = [python, str(PROBE), str(Path(asset).resolve()), str(out.resolve()), *tests]
|
|
127
|
+
with isaac_turn(), (out / "isaac_probe.log").open("w", encoding="utf-8") as log:
|
|
128
|
+
code = subprocess.run( # noqa: S603 (our own interpreter and script paths)
|
|
129
|
+
command, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=timeout, check=False
|
|
130
|
+
).returncode
|
|
131
|
+
report_file = out / "probe.json"
|
|
132
|
+
report = json.loads(report_file.read_text(encoding="utf-8")) if report_file.is_file() else {}
|
|
133
|
+
report.update({"exit_code": code, "log": str(out / "isaac_probe.log")})
|
|
134
|
+
if code == 2 or not report_file.is_file():
|
|
135
|
+
report["error"] = "the Isaac probe crashed: see the log"
|
|
136
|
+
report_file.write_text(json.dumps(report, indent=1), encoding="utf-8")
|
|
137
|
+
return report
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def software_review(asset, out):
|
|
141
|
+
"""Without Isaac Sim: four matplotlib views of the asset's meshes and no physics."""
|
|
142
|
+
from pxr import Usd, UsdGeom
|
|
143
|
+
|
|
144
|
+
from .preview import sheet
|
|
145
|
+
|
|
146
|
+
stage = Usd.Stage.Open(str(asset))
|
|
147
|
+
cache = UsdGeom.XformCache()
|
|
148
|
+
meshes = []
|
|
149
|
+
for prim in stage.Traverse():
|
|
150
|
+
if not prim.IsA("Mesh") or UsdGeom.Imageable(prim).ComputePurpose() == UsdGeom.Tokens.guide:
|
|
151
|
+
continue
|
|
152
|
+
mesh = UsdGeom.Mesh(prim)
|
|
153
|
+
points = np.array(mesh.GetPointsAttr().Get(), dtype=float)
|
|
154
|
+
matrix = np.array(cache.GetLocalToWorldTransform(prim))
|
|
155
|
+
points = points @ matrix[:3, :3] + matrix[3, :3]
|
|
156
|
+
tris = np.array(mesh.GetFaceVertexIndicesAttr().Get(), dtype=int).reshape(-1, 3)
|
|
157
|
+
colour = mesh.GetDisplayColorAttr().Get()
|
|
158
|
+
meshes.append((points, tris, tuple(colour[0]) if colour else (0.7, 0.7, 0.7), 1.0))
|
|
159
|
+
sheet(meshes, out / "review.png")
|
|
160
|
+
report = {
|
|
161
|
+
"asset": str(asset),
|
|
162
|
+
"renderer": "software",
|
|
163
|
+
"contact_sheet": str(out / "review.png"),
|
|
164
|
+
"note": "No Isaac Sim found: shapes and colours only, physics and joints not checked.",
|
|
165
|
+
}
|
|
166
|
+
(out / "review.json").write_text(json.dumps(report, indent=1), encoding="utf-8")
|
|
167
|
+
return report
|
simready_forge/scan.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Generated textured meshes (GLB from an image-to-3D model such as TRELLIS.2) as scans at real size."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
UNITS = {"m": 1.0, "cm": 0.01, "mm": 0.001}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Scan:
|
|
12
|
+
"""A textured triangle mesh in model units: Z up, front at -Y. Faces are split into bodies with `faces_in`."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, path, points, tris, uv, normals, images, scale):
|
|
15
|
+
self.path, self.points, self.tris, self.uv, self.normals = path, points, tris, uv, normals
|
|
16
|
+
self.images, self.scale = images, scale
|
|
17
|
+
self.used = np.zeros(len(tris), dtype=bool)
|
|
18
|
+
self.centres = points[tris].mean(axis=1)
|
|
19
|
+
|
|
20
|
+
def faces_in(self, lo, hi):
|
|
21
|
+
"""Faces whose centre lies in the axis-aligned box lo..hi (model units) and are not assigned yet."""
|
|
22
|
+
lo, hi = np.asarray(lo, dtype=float), np.asarray(hi, dtype=float)
|
|
23
|
+
return np.all((self.centres >= lo) & (self.centres <= hi), axis=1) & ~self.used
|
|
24
|
+
|
|
25
|
+
def remaining(self):
|
|
26
|
+
"""Faces not assigned to any body yet."""
|
|
27
|
+
return ~self.used
|
|
28
|
+
|
|
29
|
+
def face_colors(self):
|
|
30
|
+
"""Each face's colour in the base texture at its mean UV, sRGB 0-255, one row per face (None without a
|
|
31
|
+
texture): to select a sole, a stripe or a label by colour. `uv` holds one pair per triangle corner, so
|
|
32
|
+
`uv[tris]` would read the wrong pixels."""
|
|
33
|
+
if self.images.get("base") is None or self.uv is None:
|
|
34
|
+
return None
|
|
35
|
+
image = np.asarray(self.images["base"].convert("RGB"), dtype=float)
|
|
36
|
+
uv = self.uv.reshape(-1, 3, 2).mean(1) % 1.0
|
|
37
|
+
px = (uv[:, 0] * (image.shape[1] - 1)).astype(int), ((1 - uv[:, 1]) * (image.shape[0] - 1)).astype(int)
|
|
38
|
+
return image[px[1], px[0]]
|
|
39
|
+
|
|
40
|
+
def views(self, path, size=1100):
|
|
41
|
+
"""Front (looking +Y), side (looking -X) and top views with a grid in model units, to choose split boxes."""
|
|
42
|
+
import matplotlib
|
|
43
|
+
|
|
44
|
+
matplotlib.use("Agg")
|
|
45
|
+
import matplotlib.pyplot as plt
|
|
46
|
+
from matplotlib.collections import PolyCollection
|
|
47
|
+
|
|
48
|
+
lo, hi = self.points.min(0), self.points.max(0)
|
|
49
|
+
found = self.face_colors()
|
|
50
|
+
colour = np.full((len(self.tris), 3), 0.6) if found is None else found / 255
|
|
51
|
+
fig, axes = plt.subplots(1, 3, figsize=(size / 100, size / 300), dpi=100)
|
|
52
|
+
corners = self.points[self.tris]
|
|
53
|
+
for ax, (a, b, depth, sign, title) in zip(
|
|
54
|
+
axes,
|
|
55
|
+
(
|
|
56
|
+
(0, 2, 1, 1, "front: X right, Z up"),
|
|
57
|
+
(1, 2, 0, -1, "side: Y right, Z up"),
|
|
58
|
+
(0, 1, 2, -1, "top: X right, Y up"),
|
|
59
|
+
),
|
|
60
|
+
strict=True,
|
|
61
|
+
):
|
|
62
|
+
order = np.argsort(sign * corners[:, :, depth].mean(1))[::-1]
|
|
63
|
+
ax.add_collection(PolyCollection(corners[order][:, :, [a, b]], facecolors=colour[order], edgecolors="none"))
|
|
64
|
+
ax.set_xlim(lo[a], hi[a])
|
|
65
|
+
ax.set_ylim(lo[b], hi[b])
|
|
66
|
+
ax.set_aspect("equal")
|
|
67
|
+
ax.grid(visible=True, linewidth=0.4, alpha=0.6)
|
|
68
|
+
ax.set_title(title, fontsize=8)
|
|
69
|
+
ax.tick_params(labelsize=6)
|
|
70
|
+
fig.tight_layout()
|
|
71
|
+
fig.savefig(path)
|
|
72
|
+
plt.close(fig)
|
|
73
|
+
return path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_scan(path, units="mm", height=None, longest=None, fit=None, turn=0, size=None):
|
|
77
|
+
"""Load a GLB (Y-up glTF) as a Scan at real size. Give exactly one of:
|
|
78
|
+
|
|
79
|
+
height= (Z extent) or longest= (largest extent): base on z=0, centred on X/Y;
|
|
80
|
+
size=(width, depth, height): scaled on each axis to the stated size (a request's 450 x 400 x 550), base on z=0,
|
|
81
|
+
centred on X/Y;
|
|
82
|
+
fit=(lo, hi): scaled uniformly to fit that box and placed in it, base on lo z (a part of an assembly).
|
|
83
|
+
turn: degrees about Z applied first, so the part faces the right way (the GLB's front ends up at -Y).
|
|
84
|
+
"""
|
|
85
|
+
import trimesh
|
|
86
|
+
|
|
87
|
+
if sum(x is not None for x in (height, longest, fit, size)) != 1:
|
|
88
|
+
raise ValueError("give exactly one of height=, longest=, size=(w, d, h) or fit=(lo, hi) (model units)")
|
|
89
|
+
scene = cast(trimesh.Scene, trimesh.load(path, force="scene"))
|
|
90
|
+
mesh = cast(trimesh.Trimesh, scene.to_geometry())
|
|
91
|
+
mesh.apply_transform(trimesh.transformations.rotation_matrix(np.pi / 2, [1, 0, 0])) # glTF Y-up -> Z-up
|
|
92
|
+
if turn:
|
|
93
|
+
mesh.apply_transform(trimesh.transformations.rotation_matrix(np.radians(turn), [0, 0, 1]))
|
|
94
|
+
s = UNITS[units]
|
|
95
|
+
if fit is not None:
|
|
96
|
+
lo_box, hi_box = np.asarray(fit[0], dtype=float) * s, np.asarray(fit[1], dtype=float) * s
|
|
97
|
+
mesh.apply_scale(float(np.min((hi_box - lo_box) / mesh.extents)))
|
|
98
|
+
lo, hi = mesh.bounds
|
|
99
|
+
centre = (lo_box + hi_box) / 2
|
|
100
|
+
mesh.apply_translation([centre[0] - (lo[0] + hi[0]) / 2, centre[1] - (lo[1] + hi[1]) / 2, lo_box[2] - lo[2]])
|
|
101
|
+
elif size is not None:
|
|
102
|
+
scale = np.eye(4)
|
|
103
|
+
scale[:3, :3] = np.diag(np.asarray(size, dtype=float) * s / mesh.extents)
|
|
104
|
+
mesh.apply_transform(scale) # normals follow: trimesh recomputes them from the scaled faces
|
|
105
|
+
lo, hi = mesh.bounds
|
|
106
|
+
mesh.apply_translation([-(lo[0] + hi[0]) / 2, -(lo[1] + hi[1]) / 2, -lo[2]])
|
|
107
|
+
else:
|
|
108
|
+
one = height if height is not None else longest
|
|
109
|
+
target = float(one or 0) * s # one of the two is set, checked above
|
|
110
|
+
extent = mesh.extents[2] if height is not None else mesh.extents.max()
|
|
111
|
+
mesh.apply_scale(target / float(extent))
|
|
112
|
+
lo, hi = mesh.bounds
|
|
113
|
+
mesh.apply_translation([-(lo[0] + hi[0]) / 2, -(lo[1] + hi[1]) / 2, -lo[2]])
|
|
114
|
+
material = getattr(mesh.visual, "material", None)
|
|
115
|
+
images = {
|
|
116
|
+
key: getattr(material, attr, None)
|
|
117
|
+
for key, attr in (
|
|
118
|
+
("base", "baseColorTexture"),
|
|
119
|
+
("metal_rough", "metallicRoughnessTexture"),
|
|
120
|
+
("normal", "normalTexture"),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
uv = getattr(mesh.visual, "uv", None)
|
|
124
|
+
tris = np.asarray(mesh.faces)
|
|
125
|
+
# trimesh already flips glTF's V to USD's lower-left origin: flipping again scrambles every texture
|
|
126
|
+
corner_uv = np.asarray(uv, dtype=float)[tris].reshape(-1, 2) if uv is not None else None
|
|
127
|
+
return Scan(
|
|
128
|
+
str(Path(path)), np.asarray(mesh.vertices) / s, tris, corner_uv, np.asarray(mesh.vertex_normals), images, s
|
|
129
|
+
)
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Local image-to-3D through a ComfyUI server: Z-Image-Turbo (text -> reference image) and TRELLIS.2 (image ->
|
|
2
|
+
textured GLB). Effort sets the quality and how many candidates come back to choose from.
|
|
3
|
+
|
|
4
|
+
simready-forge trellis txt2img "<prompt>" <out_dir> [--effort low|medium|high] [--server URL]
|
|
5
|
+
simready-forge trellis image <image.png|jpg> <out_dir> [--effort low|medium|high] [--server URL]
|
|
6
|
+
|
|
7
|
+
Effort candidates sampling mesh faces textures
|
|
8
|
+
low 1 fast 80k 1024
|
|
9
|
+
medium 2 template 150k 2048
|
|
10
|
+
high 4 more steps 300k 2048 + finer remesh
|
|
11
|
+
The server defaults to $COMFYUI_URL or http://127.0.0.1:8188. It needs ComfyUI's native TRELLIS.2 nodes and the
|
|
12
|
+
models of its "3d_pixal3d_trellis2_image_to_model" and Z-Image-Turbo templates: trellis_2_int8_convrot,
|
|
13
|
+
trellis_2_shape_vae_bf16, trellis_2_texture_vae_bf16, dino_v3_L_naf_fp32, birefnet, z_image_turbo_bf16,
|
|
14
|
+
qwen_3_4b, ae. Prints one JSON line with the files written.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
import uuid
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
EFFORT = {
|
|
28
|
+
"low": {
|
|
29
|
+
"candidates": 1,
|
|
30
|
+
"steps": (8, 12, 8, 8),
|
|
31
|
+
"upsample": 1024,
|
|
32
|
+
"remesh": 512,
|
|
33
|
+
"faces": 80000,
|
|
34
|
+
"texture": 1024,
|
|
35
|
+
"normal": 1024,
|
|
36
|
+
"ao": 512,
|
|
37
|
+
},
|
|
38
|
+
"medium": {
|
|
39
|
+
"candidates": 2,
|
|
40
|
+
"steps": (12, 20, 12, 12),
|
|
41
|
+
"upsample": 1536,
|
|
42
|
+
"remesh": 768,
|
|
43
|
+
"faces": 150000,
|
|
44
|
+
"texture": 2048,
|
|
45
|
+
"normal": 2048,
|
|
46
|
+
"ao": 1024,
|
|
47
|
+
},
|
|
48
|
+
"high": {
|
|
49
|
+
"candidates": 4,
|
|
50
|
+
"steps": (16, 28, 16, 16),
|
|
51
|
+
"upsample": 1536,
|
|
52
|
+
"remesh": 896,
|
|
53
|
+
"faces": 300000,
|
|
54
|
+
"texture": 2048,
|
|
55
|
+
"normal": 2048,
|
|
56
|
+
"ao": 2048,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Comfy:
|
|
62
|
+
def __init__(self, server):
|
|
63
|
+
# every request goes to this server, so the urlopen calls below can only reach http or https
|
|
64
|
+
if urllib.parse.urlsplit(server).scheme not in ("http", "https"):
|
|
65
|
+
raise ValueError(f"the ComfyUI server must be an http or https URL, not {server!r}")
|
|
66
|
+
self.server = server.rstrip("/")
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _read(request, tries=4):
|
|
70
|
+
"""The response body; a connection that fails to open is retried (a busy machine ran out of local ports, and
|
|
71
|
+
a 2-candidate run lost its second candidate)."""
|
|
72
|
+
for attempt in range(tries):
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(request) as response: # noqa: S310 (checked scheme in __init__)
|
|
75
|
+
return response.read()
|
|
76
|
+
except (urllib.error.URLError, ConnectionError):
|
|
77
|
+
if attempt == tries - 1:
|
|
78
|
+
raise
|
|
79
|
+
time.sleep(5 * (attempt + 1))
|
|
80
|
+
return b""
|
|
81
|
+
|
|
82
|
+
def post(self, path, payload):
|
|
83
|
+
request = urllib.request.Request( # noqa: S310 (checked scheme)
|
|
84
|
+
self.server + path, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}
|
|
85
|
+
)
|
|
86
|
+
return json.loads(self._read(request))
|
|
87
|
+
|
|
88
|
+
def upload(self, image):
|
|
89
|
+
image = Path(image)
|
|
90
|
+
boundary = uuid.uuid4().hex
|
|
91
|
+
body = (
|
|
92
|
+
(
|
|
93
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="image"; filename="{image.name}"\r\n'
|
|
94
|
+
f"Content-Type: application/octet-stream\r\n\r\n"
|
|
95
|
+
).encode()
|
|
96
|
+
+ image.read_bytes()
|
|
97
|
+
+ f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="overwrite"\r\n\r\n'.encode()
|
|
98
|
+
+ f"true\r\n--{boundary}--\r\n".encode()
|
|
99
|
+
)
|
|
100
|
+
request = urllib.request.Request( # noqa: S310 (checked scheme)
|
|
101
|
+
self.server + "/upload/image",
|
|
102
|
+
data=body,
|
|
103
|
+
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
|
104
|
+
)
|
|
105
|
+
return json.loads(self._read(request))["name"]
|
|
106
|
+
|
|
107
|
+
def run(self, graph, timeout=3600):
|
|
108
|
+
prompt_id = self.post("/prompt", {"prompt": graph, "client_id": "simready_kit"})["prompt_id"]
|
|
109
|
+
start = time.time()
|
|
110
|
+
while time.time() - start < timeout:
|
|
111
|
+
history = json.loads(self._read(f"{self.server}/history/{prompt_id}"))
|
|
112
|
+
if prompt_id in history:
|
|
113
|
+
status = history[prompt_id].get("status", {})
|
|
114
|
+
if status.get("status_str") == "error":
|
|
115
|
+
raise RuntimeError(json.dumps(status.get("messages", status))[:3000])
|
|
116
|
+
return history[prompt_id]["outputs"]
|
|
117
|
+
time.sleep(2)
|
|
118
|
+
raise TimeoutError(prompt_id)
|
|
119
|
+
|
|
120
|
+
def fetch(self, outputs, kind, target):
|
|
121
|
+
for value in outputs.values():
|
|
122
|
+
for item in value.get(kind, []) if isinstance(value, dict) else []:
|
|
123
|
+
query = urllib.parse.urlencode(
|
|
124
|
+
{
|
|
125
|
+
"filename": item["filename"],
|
|
126
|
+
"subfolder": item.get("subfolder", ""),
|
|
127
|
+
"type": item.get("type", "output"),
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
Path(target).write_bytes(self._read(f"{self.server}/view?{query}"))
|
|
131
|
+
return target
|
|
132
|
+
raise RuntimeError(f"no {kind} output in {list(outputs)}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def image3d_graph(image_name, seed, p, prefix):
|
|
136
|
+
g = {}
|
|
137
|
+
|
|
138
|
+
def node(i, cls, **inputs):
|
|
139
|
+
g[str(i)] = {"class_type": cls, "inputs": inputs}
|
|
140
|
+
|
|
141
|
+
def ref(i, slot=0):
|
|
142
|
+
return [str(i), slot]
|
|
143
|
+
|
|
144
|
+
s1, s2, s3, s4 = p["steps"]
|
|
145
|
+
node(122, "LoadImage", image=image_name)
|
|
146
|
+
node(193, "LoadBackgroundRemovalModel", bg_removal_name="birefnet.safetensors")
|
|
147
|
+
node(192, "RemoveBackground", bg_removal_model=ref(193), image=ref(122))
|
|
148
|
+
node(
|
|
149
|
+
312,
|
|
150
|
+
"ImageCropToMask",
|
|
151
|
+
images=ref(122),
|
|
152
|
+
masks=ref(192),
|
|
153
|
+
width=1024,
|
|
154
|
+
height=1024,
|
|
155
|
+
pad_factor=1.1,
|
|
156
|
+
grow_mask=0,
|
|
157
|
+
background="#000000",
|
|
158
|
+
)
|
|
159
|
+
node(15, "CLIPVisionLoader", clip_name="dino_v3_L_naf_fp32.safetensors")
|
|
160
|
+
node(40, "UNETLoader", unet_name="trellis_2_int8_convrot.safetensors", weight_dtype="default")
|
|
161
|
+
node(299, "Trellis2Conditioning", clip_vision_model=ref(15), image=ref(312))
|
|
162
|
+
node(199, "CFGOverride", model=ref(40), cfg=1, start_percent=0.667, end_percent=1)
|
|
163
|
+
node(125, "RescaleCFG", model=ref(199), multiplier=0.7)
|
|
164
|
+
node(108, "ModelSamplingSD3", model=ref(125), shift=5)
|
|
165
|
+
node(87, "EmptyTrellis2LatentStructure", batch_size=1)
|
|
166
|
+
node(
|
|
167
|
+
3,
|
|
168
|
+
"KSampler",
|
|
169
|
+
model=ref(108),
|
|
170
|
+
seed=seed + 14,
|
|
171
|
+
steps=s1,
|
|
172
|
+
cfg=7.5,
|
|
173
|
+
sampler_name="euler",
|
|
174
|
+
scheduler="normal",
|
|
175
|
+
positive=ref(299, 0),
|
|
176
|
+
negative=ref(299, 1),
|
|
177
|
+
latent_image=ref(87),
|
|
178
|
+
denoise=1,
|
|
179
|
+
)
|
|
180
|
+
node(117, "VAELoader", vae_name="trellis_2_shape_vae_bf16.safetensors")
|
|
181
|
+
node(118, "VAELoader", vae_name="trellis_2_texture_vae_bf16.safetensors")
|
|
182
|
+
node(119, "VaeDecodeStructureTrellis2", samples=ref(3), vae=ref(117), resolution="32")
|
|
183
|
+
node(91, "Trellis2ShapeStage", positive=ref(299, 0), negative=ref(299, 1), voxel=ref(119))
|
|
184
|
+
node(279, "CFGOverride", model=ref(40), cfg=1, start_percent=0.769, end_percent=1)
|
|
185
|
+
node(126, "RescaleCFG", model=ref(279), multiplier=0.5)
|
|
186
|
+
node(
|
|
187
|
+
18,
|
|
188
|
+
"KSampler",
|
|
189
|
+
model=ref(126),
|
|
190
|
+
seed=seed,
|
|
191
|
+
steps=s2,
|
|
192
|
+
cfg=7.5,
|
|
193
|
+
sampler_name="euler",
|
|
194
|
+
scheduler="normal",
|
|
195
|
+
positive=ref(91, 0),
|
|
196
|
+
negative=ref(91, 1),
|
|
197
|
+
latent_image=ref(91, 2),
|
|
198
|
+
denoise=1,
|
|
199
|
+
)
|
|
200
|
+
node(
|
|
201
|
+
94,
|
|
202
|
+
"Trellis2UpsampleStage",
|
|
203
|
+
positive=ref(91, 0),
|
|
204
|
+
negative=ref(91, 1),
|
|
205
|
+
shape_latent=ref(18),
|
|
206
|
+
vae=ref(117),
|
|
207
|
+
target_resolution=p["upsample"],
|
|
208
|
+
)
|
|
209
|
+
node(
|
|
210
|
+
23,
|
|
211
|
+
"KSampler",
|
|
212
|
+
model=ref(126),
|
|
213
|
+
seed=seed,
|
|
214
|
+
steps=s3,
|
|
215
|
+
cfg=7.5,
|
|
216
|
+
sampler_name="euler",
|
|
217
|
+
scheduler="simple",
|
|
218
|
+
positive=ref(94, 0),
|
|
219
|
+
negative=ref(94, 1),
|
|
220
|
+
latent_image=ref(94, 2),
|
|
221
|
+
denoise=1,
|
|
222
|
+
)
|
|
223
|
+
node(92, "VaeDecodeShapeTrellis", samples=ref(23), vae=ref(117))
|
|
224
|
+
node(98, "Trellis2TextureStage", positive=ref(94, 0), negative=ref(94, 1), shape_latent=ref(23))
|
|
225
|
+
node(
|
|
226
|
+
12,
|
|
227
|
+
"KSampler",
|
|
228
|
+
model=ref(40),
|
|
229
|
+
seed=seed + 1,
|
|
230
|
+
steps=s4,
|
|
231
|
+
cfg=1,
|
|
232
|
+
sampler_name="euler",
|
|
233
|
+
scheduler="normal",
|
|
234
|
+
positive=ref(98, 0),
|
|
235
|
+
negative=ref(98, 1),
|
|
236
|
+
latent_image=ref(98, 2),
|
|
237
|
+
denoise=1,
|
|
238
|
+
)
|
|
239
|
+
node(93, "VaeDecodeTextureTrellis", samples=ref(12), vae=ref(118), shape_subdivides=ref(92, 1))
|
|
240
|
+
node(202, "GetMeshInfo", mesh=ref(92))
|
|
241
|
+
g["241"] = {
|
|
242
|
+
"class_type": "RemeshMesh",
|
|
243
|
+
"inputs": {
|
|
244
|
+
"mesh": ref(202),
|
|
245
|
+
"resolution": p["remesh"],
|
|
246
|
+
"sign_mode": "solid",
|
|
247
|
+
"sign_mode.fill": 20.0,
|
|
248
|
+
"sign_mode.qef": False,
|
|
249
|
+
"band": 1.0,
|
|
250
|
+
"project_back": 0.0,
|
|
251
|
+
"fix_poles": False,
|
|
252
|
+
"smooth_iters": 20,
|
|
253
|
+
"drop_small_components": 0.01,
|
|
254
|
+
"precluster_max_verts": 20000000,
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
node(186, "DecimateMesh", mesh=ref(241), target_face_count=p["faces"], placement_mode="midpoint")
|
|
258
|
+
node(238, "MeshSmoothNormals", mesh=ref(186), crease_angle=180)
|
|
259
|
+
node(196, "UnwrapMesh", mesh=ref(238), segmenter="pec", resolution=p["texture"], padding=1, weld_distance=0.0002)
|
|
260
|
+
node(
|
|
261
|
+
147,
|
|
262
|
+
"BakeTextureFromVoxel",
|
|
263
|
+
mesh=ref(196),
|
|
264
|
+
voxel_colors=ref(93),
|
|
265
|
+
reference_mesh=ref(92),
|
|
266
|
+
texture_size=p["texture"],
|
|
267
|
+
)
|
|
268
|
+
node(
|
|
269
|
+
224,
|
|
270
|
+
"BakeNormalMapFromMesh",
|
|
271
|
+
low_poly=ref(196),
|
|
272
|
+
high_poly=ref(241),
|
|
273
|
+
resolution=p["normal"],
|
|
274
|
+
cage_distance=0.05,
|
|
275
|
+
ignore_backfaces=True,
|
|
276
|
+
)
|
|
277
|
+
node(
|
|
278
|
+
233,
|
|
279
|
+
"BakeAmbientOcclusion",
|
|
280
|
+
low_poly=ref(196),
|
|
281
|
+
high_poly=ref(241),
|
|
282
|
+
resolution=p["ao"],
|
|
283
|
+
samples=64,
|
|
284
|
+
max_distance=0.71,
|
|
285
|
+
strength=1,
|
|
286
|
+
bias=0.01,
|
|
287
|
+
)
|
|
288
|
+
node(
|
|
289
|
+
210,
|
|
290
|
+
"ApplyTextureToMesh",
|
|
291
|
+
mesh=ref(196),
|
|
292
|
+
base_color=ref(147, 0),
|
|
293
|
+
metallic=ref(147, 1),
|
|
294
|
+
roughness=ref(147, 2),
|
|
295
|
+
occlusion=ref(233),
|
|
296
|
+
normal_map=ref(224),
|
|
297
|
+
)
|
|
298
|
+
node(260, "MeshSmoothNormals", mesh=ref(210), crease_angle=180)
|
|
299
|
+
node(900, "SaveGLB", mesh=ref(260), filename_prefix=prefix)
|
|
300
|
+
node(901, "SaveImage", images=ref(312), filename_prefix=prefix + "_input")
|
|
301
|
+
return g
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def txt2img_graph(text, seed, prefix):
|
|
305
|
+
return {
|
|
306
|
+
"1": {
|
|
307
|
+
"class_type": "UNETLoader",
|
|
308
|
+
"inputs": {"unet_name": "z_image_turbo_bf16.safetensors", "weight_dtype": "default"},
|
|
309
|
+
},
|
|
310
|
+
"2": {
|
|
311
|
+
"class_type": "CLIPLoader",
|
|
312
|
+
"inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "lumina2", "device": "default"},
|
|
313
|
+
},
|
|
314
|
+
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "ae.safetensors"}},
|
|
315
|
+
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": text}},
|
|
316
|
+
"5": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}},
|
|
317
|
+
"6": {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}},
|
|
318
|
+
"7": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 3}},
|
|
319
|
+
"8": {
|
|
320
|
+
"class_type": "KSampler",
|
|
321
|
+
"inputs": {
|
|
322
|
+
"model": ["7", 0],
|
|
323
|
+
"seed": seed,
|
|
324
|
+
"steps": 8,
|
|
325
|
+
"cfg": 1,
|
|
326
|
+
"sampler_name": "res_multistep",
|
|
327
|
+
"scheduler": "simple",
|
|
328
|
+
"positive": ["4", 0],
|
|
329
|
+
"negative": ["5", 0],
|
|
330
|
+
"latent_image": ["6", 0],
|
|
331
|
+
"denoise": 1,
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
"9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}},
|
|
335
|
+
"10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": prefix}},
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def main(argv=None):
|
|
340
|
+
parser = argparse.ArgumentParser(prog="simready-forge trellis")
|
|
341
|
+
parser.add_argument("mode", choices=["txt2img", "image"])
|
|
342
|
+
parser.add_argument("source", help="text prompt (txt2img) or image path (image)")
|
|
343
|
+
parser.add_argument("out_dir")
|
|
344
|
+
parser.add_argument("--effort", default="medium", choices=list(EFFORT))
|
|
345
|
+
parser.add_argument("--server", default=os.environ.get("COMFYUI_URL", "http://127.0.0.1:8188"))
|
|
346
|
+
parser.add_argument("--seed", type=int, default=42)
|
|
347
|
+
args = parser.parse_args(argv)
|
|
348
|
+
comfy, p = Comfy(args.server), EFFORT[args.effort]
|
|
349
|
+
out = Path(args.out_dir)
|
|
350
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
351
|
+
from .review import isaac_turn
|
|
352
|
+
|
|
353
|
+
with isaac_turn(): # the same GPU as Isaac Sim: one heavy job on the machine at a time
|
|
354
|
+
written, start = [], time.time()
|
|
355
|
+
if args.mode == "txt2img":
|
|
356
|
+
for k in range(p["candidates"]):
|
|
357
|
+
outputs = comfy.run(
|
|
358
|
+
txt2img_graph(args.source, args.seed + 101 * k, f"simready_kit/ref_{uuid.uuid4().hex[:8]}")
|
|
359
|
+
)
|
|
360
|
+
written.append(str(comfy.fetch(outputs, "images", out / f"reference_{k}.png")))
|
|
361
|
+
else:
|
|
362
|
+
name = comfy.upload(args.source)
|
|
363
|
+
for k in range(p["candidates"]):
|
|
364
|
+
prefix = f"simready_kit/mesh_{uuid.uuid4().hex[:8]}"
|
|
365
|
+
outputs = comfy.run(image3d_graph(name, args.seed + 1000 * k, p, prefix))
|
|
366
|
+
written.append(str(comfy.fetch(outputs, "3d", out / f"candidate_{k}.glb")))
|
|
367
|
+
if k == 0:
|
|
368
|
+
comfy.fetch(outputs, "images", out / "input_crop.png")
|
|
369
|
+
print(
|
|
370
|
+
json.dumps(
|
|
371
|
+
{"mode": args.mode, "effort": args.effort, "files": written, "seconds": round(time.time() - start, 1)}
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
if __name__ == "__main__":
|
|
378
|
+
raise SystemExit(main())
|