Mike0021's picture
Default backend: Qwen-Image-2.1-Turbo (hugging-apps/qwen-qwen-image-2-1-turbo); full Qwen-Image-2.1 and Fibo stay selectable
4de8d8b verified
Raw History Blame Contribute Delete
22.4 kB
"""Command line interface. Every command prints one JSON document on stdout (logs go to stderr).
projtex init PROJ mesh.glb --mode character
projtex config PROJ --prompt "..." --backend qwen-local
projtex plan PROJ
projtex generate PROJ
projtex bake PROJ --resolution 4096
projtex inspect PROJ
projtex retouch PROJ --focus 0.1,1.6,0.05,0.12 --mask low_quality --mode repair
projtex render PROJ --compare-source
projtex run mesh.glb OUT --prompt "..." (all of the above in one go)
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import traceback
from typing import Any
def _parse_value(s: str) -> Any:
try:
return json.loads(s)
except (json.JSONDecodeError, TypeError):
return s
def _kv(items: list[str] | None) -> dict:
out = {}
for it in items or []:
if "=" not in it:
raise SystemExit(f"expected key=value, got {it!r}")
k, v = it.split("=", 1)
out[k.strip()] = _parse_value(v)
return out
def _floats(s: str | None) -> list[float] | None:
if s is None:
return None
return [float(x) for x in s.split(",")]
def _focus(s: str | None):
if s is None:
return None
if s.strip().startswith("{"):
return json.loads(s)
if s in ("auto", "head", "face", "none"):
return None if s == "none" else s
v = _floats(s)
if len(v) == 4:
return {"point": v[:3], "radius": v[3]}
if len(v) == 7:
return {"point": v[:3], "radius": v[3], "direction": v[4:]}
raise SystemExit("focus must be x,y,z,r[,dx,dy,dz] or JSON")
def _mask(s: str | None):
if s is None:
return None
s = s.strip()
if s.startswith("{") or s.startswith("["):
return json.loads(s)
return s
def _backend(args) -> Any:
if not getattr(args, "backend", None) and not getattr(args, "backend_opt", None):
return None
d = _kv(getattr(args, "backend_opt", None))
if args.backend:
d["name"] = args.backend
return d
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(prog="projtex", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("-v", "--verbose", action="store_true", help="INFO logs on stderr")
sp = ap.add_subparsers(dest="cmd", required=True)
def proj(p):
p.add_argument("project", help="project directory")
return p
def backend_args(p):
p.add_argument("--backend", help="qwen-turbo (default) | qwen-space | fibo-space | qwen-local | hf | files | oracle | mock")
p.add_argument("--backend-opt", action="append", metavar="K=V",
help="backend option, e.g. prompt_extend=true or model=Qwen/Qwen-Image-2.1 (repeatable)")
p = sp.add_parser("init", help="import a mesh and create a project")
p.add_argument("project")
p.add_argument("mesh", help=".glb/.gltf/.obj/.ply")
p.add_argument("--mode", default="object", choices=["object", "character", "environment"])
p.add_argument("--uv", default="auto", choices=["auto", "keep", "pack", "regen"])
p.add_argument("--up", choices=["auto", "y", "z", "-z", "x"],
help="up axis of the asset (default y; auto in character mode)")
p.add_argument("--texture-size", type=int)
p.add_argument("--view-size", type=int)
p.add_argument("--overwrite", action="store_true")
p = proj(sp.add_parser("config", help="update settings"))
p.add_argument("--prompt")
p.add_argument("--subject", help="what the mesh is, e.g. 'a woman', 'a kitchen'")
p.add_argument("--negative-prompt")
p.add_argument("--lighting", choices=["flat", "soft", "natural"])
p.add_argument("--raw-prompt", type=_parse_value, help="true: send the prompt verbatim")
p.add_argument("--style-reference", help="global style reference image")
p.add_argument("--preset", help="append a material preset: ceramic bronze scifi stone abandoned workshop wood concrete")
p.add_argument("--texture-size", type=int)
p.add_argument("--view-size", type=int)
p.add_argument("--preview-texture-size", type=int)
p.add_argument("--seed", type=int)
p.add_argument("--skymap", help="equirectangular background image for environments")
p.add_argument("--front-azimuth", type=_parse_value,
help="degrees to rotate the 'front' camera if the asset does not face +Z, or \"auto\"")
p.add_argument("--fusion", action="append", metavar="K=V", help="fusion parameter, e.g. sharpness=6 (repeatable)")
p.add_argument("--set", action="append", metavar="K=V", help="any other setting as JSON value (repeatable)")
backend_args(p)
p = proj(sp.add_parser("plan", help="place cameras"))
p.add_argument("--preset", help="object: minimal | balanced | dense | greedy; environment: probes | probes-greedy")
p.add_argument("--views", type=int, help="number of views for --preset greedy")
p.add_argument("--focus", help="auto | none | x,y,z,r : close-up views (default auto in character mode)")
p.add_argument("--no-gaps", action="store_true", help="do not add gap-filling views")
p.add_argument("--max-gap-views", type=int)
p.add_argument("--probes", type=int, help="environment: number of 360 probes (default 6)")
p.add_argument("--eye-height", type=float)
p.add_argument("--levels", type=_parse_value, default="lowest",
help="environment floor levels for probes: lowest (default) | all | a height")
p.add_argument("--region", default="auto", choices=["auto", "enclosed", "all"],
help="environment: keep only enclosed walkable space (auto drops ground outside the building)")
p.add_argument("--fov", type=float)
p.add_argument("--append", action="store_true", help="keep existing views")
proj(sp.add_parser("status", help="project summary"))
p = proj(sp.add_parser("view", help="change one view"))
p.add_argument("name")
p.add_argument("--enable", action="store_true")
p.add_argument("--disable", action="store_true")
p.add_argument("--priority", type=float)
p.add_argument("--reference", help="per-view reference image (same angle)")
p.add_argument("--prompt-extra")
p.add_argument("--seed", type=int)
p.add_argument("--remove", action="store_true")
p = proj(sp.add_parser("add-view", help="add a custom camera"))
p.add_argument("name")
p.add_argument("--eye")
p.add_argument("--target")
p.add_argument("--fov", type=float, default=40.0)
p.add_argument("--focus", help="x,y,z,r[,dx,dy,dz]: frame a sphere")
p.add_argument("--priority", type=float, default=1.0)
p.add_argument("--index", type=int, help="insert position in generation order")
p = proj(sp.add_parser("generate", help="generate & project views (sequential, conditioned on previous ones)"))
p.add_argument("--views", help="comma-separated subset")
p.add_argument("--force", action="store_true", help="regenerate views that already have images")
p.add_argument("--no-bake", action="store_true")
backend_args(p)
p = proj(sp.add_parser("fill-gaps", help="add + generate cameras for surface that is still uncovered"))
p.add_argument("--max-views", type=int, default=3)
p.add_argument("--rounds", type=int, default=1)
p.add_argument("--plan-only", action="store_true")
backend_args(p)
p = proj(sp.add_parser("condition", help="write the conditioning render + prompt of a view (external generation)"))
p.add_argument("name")
p = proj(sp.add_parser("import-view", help="use an external image for a view"))
p.add_argument("name")
p.add_argument("image")
p = proj(sp.add_parser("bake", help="fuse views into the UV texture and export textured.glb"))
p.add_argument("--resolution", type=int, help="2048 / 4096 / 8192")
p = proj(sp.add_parser("export", help="export glb/obj"))
p.add_argument("--format", default="glb", choices=["glb", "obj"])
p.add_argument("--out")
p.add_argument("--resolution", type=int)
p.add_argument("--unlit", action="store_true", help="add KHR_materials_unlit")
p = proj(sp.add_parser("inspect", help="coverage/quality stats and retouch suggestions"))
p.add_argument("--max-suggestions", type=int, default=6)
p = proj(sp.add_parser("retouch", help="local repair / replace / upscale inside a mask"))
p.add_argument("--prompt", default="")
p.add_argument("--mode", default="repair", choices=["repair", "replace", "upscale"])
p.add_argument("--view", help="reuse a view's camera")
p.add_argument("--focus", help="x,y,z,r[,dx,dy,dz] or JSON {point, radius, direction, fov}")
p.add_argument("--camera", help='JSON {"eye":[..],"target":[..],"fov":40}')
p.add_argument("--render-camera", help="reuse a preview camera, e.g. walk01 or orbit03 (from renders/cameras.json)")
p.add_argument("--suggestion", type=int, help="use suggestion #N from `inspect` (focus + mask + mode)")
p.add_argument("--mask", help="all | uncovered | low_quality | mask.png | JSON {sphere|box|rect|circle|texels}")
p.add_argument("--capture-size", type=int, help="render size of the capture (e.g. 256 for the upscale trick)")
p.add_argument("--output-size", type=int, help="generated size (default view size)")
p.add_argument("--softness", type=float, default=0.75, help="feather width as a fraction of the mask radius")
p.add_argument("--grow", type=int, default=0, help="dilate the mask by N pixels")
p.add_argument("--mark", choices=["prefill", "fill", "outline", "mask", "none"],
help="how the region is shown to the model (default: replace=prefill, repair=outline, upscale=none)")
p.add_argument("--strength", type=float, default=1.0)
p.add_argument("--seed", type=int)
p.add_argument("--name")
p.add_argument("--no-color-match", action="store_true")
backend_args(p)
p = proj(sp.add_parser("pick", help="3D point / normal / UV under image pixels of a view or camera"))
p.add_argument("pixels", help="x,y[;x,y...] in pixels or 0-1 fractions")
p.add_argument("--view")
p.add_argument("--camera", help="JSON camera")
p.add_argument("--render-camera", help="a preview camera from renders/cameras.json (e.g. walk01)")
p.add_argument("--size", type=int, help="image size the pixel coordinates refer to")
proj(sp.add_parser("undo", help="disable the last retouch layer"))
p = proj(sp.add_parser("layer", help="enable/disable a retouch layer"))
p.add_argument("name")
p.add_argument("--enable", action="store_true")
p.add_argument("--disable", action="store_true")
p.add_argument("--strength", type=float)
p = proj(sp.add_parser("render", help="preview renders, comparisons, turntable video"))
p.add_argument("--turntable", type=int, default=8)
p.add_argument("--size", type=int, default=768)
p.add_argument("--mode", default="albedo", choices=["albedo", "lit", "clay", "normals"])
p.add_argument("--elevation", type=float, default=15.0)
p.add_argument("--views", help="render these view cameras instead of an orbit")
p.add_argument("--compare-source", action="store_true", help="side by side with the input / original texture")
p.add_argument("--video", action="store_true")
p.add_argument("--out-dir", default="renders")
p.add_argument("--sheet", action="store_true", help="also write a (condition | generated) sheet of all views")
p = sp.add_parser("run", help="one-shot: init + plan + generate + bake + render")
p.add_argument("mesh")
p.add_argument("project")
p.add_argument("--mode", default="object", choices=["object", "character", "environment"])
p.add_argument("--prompt", required=True)
p.add_argument("--subject")
p.add_argument("--preset")
p.add_argument("--probes", type=int)
p.add_argument("--focus")
p.add_argument("--texture-size", type=int)
p.add_argument("--view-size", type=int)
p.add_argument("--uv", default="auto")
p.add_argument("--up", choices=["auto", "y", "z", "-z", "x"])
p.add_argument("--style-reference")
p.add_argument("--lighting", choices=["flat", "soft", "natural"])
p.add_argument("--overwrite", action="store_true")
p.add_argument("--auto-retouch", type=int, default=0, help="apply up to N inspect suggestions afterwards")
backend_args(p)
sp.add_parser("backends", help="list generation backends")
sp.add_parser("schema", help="machine-readable description of all commands")
p = sp.add_parser("mcp", help="run a Model Context Protocol server on stdio")
return ap
def _schema(ap: argparse.ArgumentParser) -> dict:
out = {}
for act in ap._subparsers._group_actions: # noqa: SLF001
for name, sub in act.choices.items():
out[name] = {
"help": sub.description or next((c.help for c in act._choices_actions if c.dest == name), ""), # noqa: SLF001
"args": [{"name": a.dest, "flags": a.option_strings, "help": a.help, "default": a.default,
"required": a.required or not a.option_strings,
"choices": list(a.choices) if a.choices else None}
for a in sub._actions if a.dest != "help"], # noqa: SLF001
}
return out
def run(args, ap) -> Any:
from .project import Project
c = args.cmd
if c == "backends":
from .backends import BACKENDS
return {"backends": sorted(BACKENDS), "notes": {
"qwen-turbo": "default. Qwen-Image-2.1-Turbo (8 steps, ~7 s of GPU per view) through the Space hugging-apps/qwen-qwen-image-2-1-turbo (ZeroGPU quota of your HF login; needs `pip install projtex[spaces]`)",
"qwen-space": "full Qwen-Image-2.1 (40 steps) through the official Space Qwen/Qwen-Image-2.1 (ZeroGPU quota of your HF login; needs `pip install projtex[spaces]`)",
"qwen-local": "fallback: local diffusers QwenImage21Pipeline (default Qwen/Qwen-Image-2.1-Turbo); needs a GPU + `pip install projtex[local]`",
"hf": "Hugging Face Inference Providers image-to-image (default Qwen/Qwen-Image-Edit-2511); needs HF login/HF_TOKEN",
"fibo-space": "Bria Fibo 2.0 Edit Turbo via its official HF Space (ZeroGPU quota of your HF login); native masks; CC BY-NC 4.0",
"files": "writes requests to <project>/exchange and waits for <view>.png (any external generator)",
"oracle": "re-renders the source mesh's own texture (accuracy tests)",
"mock": "procedural pattern (smoke tests)"},
"removed": __import__("projtex.backends", fromlist=["REMOVED"]).REMOVED}
if c == "schema":
return _schema(ap)
if c == "mcp":
from .mcp_server import main as mcp_main
mcp_main()
return None
if c == "init":
p = Project.create(args.project, args.mesh, mode=args.mode, uv=args.uv, up=args.up, overwrite=args.overwrite,
texture_size=args.texture_size, view_size=args.view_size)
return {"project": str(p.root), "mesh": p.data["mesh"], "settings": p.settings}
if c == "run":
return _run_all(args)
p = Project.open(args.project)
if c == "status":
return p.status()
if c == "config":
kw = dict(prompt=args.prompt, subject=args.subject, negative_prompt=args.negative_prompt,
lighting=args.lighting, raw_prompt=args.raw_prompt, style_reference=args.style_reference,
preset=args.preset, texture_size=args.texture_size, view_size=args.view_size,
preview_texture_size=args.preview_texture_size, seed=args.seed, front_azimuth=args.front_azimuth,
skymap=p._import_file(args.skymap, "refs") if args.skymap else None)
if args.fusion:
kw["fusion"] = _kv(args.fusion)
kw.update(_kv(args.set))
b = _backend(args)
if b:
kw["backend"] = b
return p.configure(**kw)
if c == "plan":
focus = "default" if args.focus is None else _focus(args.focus)
return p.plan(preset=args.preset, n_views=args.views, focus=focus, gaps=not args.no_gaps,
max_gap_views=args.max_gap_views, fov=args.fov, probes=args.probes, eye_height=args.eye_height,
replace=not args.append, levels=args.levels, region=args.region)
if c == "view":
if args.remove:
return p.remove_view(args.name)
enabled = True if args.enable else (False if args.disable else None)
return p.set_view(args.name, enabled=enabled, priority=args.priority, reference=args.reference,
prompt_extra=args.prompt_extra, seed=args.seed)
if c == "add-view":
return p.add_view(args.name, eye=_floats(args.eye), target=_floats(args.target), fov=args.fov,
focus=_focus(args.focus), priority=args.priority, index=args.index)
if c == "generate":
return p.generate(views=args.views.split(",") if args.views else None, force=args.force,
backend=_backend(args), bake=not args.no_bake)
if c == "fill-gaps":
return p.fill_gaps(max_views=args.max_views, generate=not args.plan_only, backend=_backend(args),
rounds=args.rounds)
if c == "condition":
return p.condition_image(args.name)
if c == "import-view":
return p.import_view(args.name, args.image)
if c == "bake":
return p.bake(resolution=args.resolution)
if c == "export":
return p.export(path=args.out, fmt=args.format, resolution=args.resolution, unlit=args.unlit)
if c == "inspect":
return p.inspect(max_suggestions=args.max_suggestions)
if c == "retouch":
focus, mask, mode = _focus(args.focus), _mask(args.mask), args.mode
if args.suggestion is not None:
sug = p.inspect(write=False)["suggestions"]
if args.suggestion >= len(sug):
raise SystemExit(f"only {len(sug)} suggestions")
s = sug[args.suggestion]["retouch"]
focus = focus or s["focus"]
mask = mask or s["mask"]
if "--grow" not in sys.argv:
args.grow = s.get("grow_px", 0)
if "--mode" not in sys.argv:
mode = s["mode"]
camera = json.loads(args.camera) if args.camera else None
if args.render_camera:
camera = json.loads((p.root / "renders" / "cameras.json").read_text())[args.render_camera]
return p.retouch(prompt=args.prompt, mode=mode, view=args.view, focus=focus,
camera=camera, mask=mask,
capture_size=args.capture_size, output_size=args.output_size, softness=args.softness,
mark=args.mark, backend=_backend(args), seed=args.seed, name=args.name,
color_match=not args.no_color_match, strength=args.strength, grow_px=args.grow)
if c == "pick":
px = [[float(v) for v in t.split(",")] for t in args.pixels.split(";")]
camera = json.loads(args.camera) if args.camera else None
if args.render_camera:
camera = json.loads((p.root / "renders" / "cameras.json").read_text())[args.render_camera]
return p.pick(px, view=args.view, camera=camera, size=args.size)
if c == "undo":
return p.undo_retouch()
if c == "layer":
enabled = True if args.enable else (False if args.disable else None)
return p.set_layer(args.name, enabled=enabled, strength=args.strength)
if c == "render":
out = p.render(out_dir=args.out_dir, turntable=args.turntable, size=args.size, mode=args.mode,
elevation=args.elevation, views=args.views.split(",") if args.views else None,
compare_source=args.compare_source, video=args.video)
if args.sheet:
from .report import views_sheet
out["views_sheet"] = views_sheet(p)
return out
raise SystemExit(f"unknown command {c}")
def _run_all(args) -> dict:
from .project import Project
from .report import views_sheet
p = Project.create(args.project, args.mesh, mode=args.mode, uv=args.uv, up=args.up, overwrite=args.overwrite,
texture_size=args.texture_size, view_size=args.view_size)
p.configure(prompt=args.prompt, subject=args.subject, style_reference=args.style_reference,
lighting=args.lighting, backend=_backend(args))
focus = "default" if args.focus is None else _focus(args.focus)
plan = p.plan(preset=args.preset, probes=args.probes, focus=focus)
gen = p.generate()
out = {"project": str(p.root), "plan": {k: v for k, v in plan.items() if k != "views"},
"views": [r["view"] + ":" + r["status"] for r in gen["results"]]}
if gen.get("pending"):
out["pending"] = gen["pending"]
return out
retouched = []
for i in range(args.auto_retouch):
sug = p.inspect(write=False)["suggestions"]
if not sug:
break
s = sug[0]["retouch"]
retouched.append(p.retouch(prompt=args.prompt if s["mode"] == "replace" else "", mode=s["mode"],
focus=s["focus"], mask=s["mask"], softness=0.5, grow_px=s.get("grow_px", 4)))
if retouched:
out["retouch"] = [r.get("layer") for r in retouched]
out["bake"] = p.bake()
out["renders"] = p.render(compare_source=True)
out["views_sheet"] = views_sheet(p)
return out
def main(argv: list[str] | None = None) -> int:
ap = build_parser()
args = ap.parse_args(argv)
logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING, stream=sys.stderr,
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
try:
res = run(args, ap)
except SystemExit:
raise
except Exception as exc: # report errors as JSON for agents
print(json.dumps({"error": f"{type(exc).__name__}: {exc}",
"trace": traceback.format_exc(limit=3) if args.verbose else None}))
return 1
if res is not None:
from .project import _json_default
print(json.dumps(res, indent=2, default=_json_default))
return 0
if __name__ == "__main__":
sys.exit(main())