Spaces:
Running
Running
Report each operation's outcome (last_result), retouch progress and recovery hints
Browse files- app/jobs.py +24 -4
- app/llms.txt +6 -1
- app/server.py +21 -0
- tests/smoke.py +24 -13
app/jobs.py
CHANGED
|
@@ -147,6 +147,7 @@ class JobManager:
|
|
| 147 |
f"poll GET /api/jobs/{job.id} until it is done, then retry")
|
| 148 |
self.pending += 1
|
| 149 |
job.state, job.op, job.error, job.progress = "queued", op, None, {"step": "queued"}
|
|
|
|
| 150 |
job.event(f"{op}: queued")
|
| 151 |
self.pool.submit(self._run, job, op, fn)
|
| 152 |
|
|
@@ -351,6 +352,7 @@ class JobManager:
|
|
| 351 |
names = [v.name for v in p.views if v.enabled and (views is None or v.name in views)
|
| 352 |
and (force or v.status not in ("generated", "imported"))]
|
| 353 |
total = len(names)
|
|
|
|
| 354 |
for i, name in enumerate(names):
|
| 355 |
job.progress = {"step": "generating views", "view": name, "done": i, "total": total,
|
| 356 |
"seconds_per_view_estimate": BACKENDS[job.params["backend"]]["seconds_per_view"]}
|
|
@@ -367,6 +369,10 @@ class JobManager:
|
|
| 367 |
job.event(f"waiting for an external image for {name}")
|
| 368 |
return
|
| 369 |
r = out["results"][0] if out["results"] else {"status": "skipped"}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
job.event(f"view {name}: {r['status']}", view=name, status=r["status"],
|
| 371 |
edge_agreement=r.get("edge_agreement"), silhouette_iou=r.get("silhouette_iou"))
|
| 372 |
job.result.pop("remaining_views", None)
|
|
@@ -436,8 +442,15 @@ class JobManager:
|
|
| 436 |
def op(j: Job):
|
| 437 |
p = self._open(j)
|
| 438 |
try:
|
|
|
|
| 439 |
r = p.fill_gaps(max_views=max_views, generate=True, backend=self._backend(j))
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
self._finish(j, p)
|
| 442 |
finally:
|
| 443 |
self._close(p)
|
|
@@ -469,9 +482,16 @@ class JobManager:
|
|
| 469 |
if args["render_camera"] not in cams:
|
| 470 |
raise JobError(f"unknown render camera; have {sorted(cams)}", 400)
|
| 471 |
kw["camera"] = cams[args["render_camera"]]
|
|
|
|
| 472 |
r = p.retouch(backend=self._backend(j), **kw)
|
| 473 |
-
|
| 474 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
if r.get("status") == "applied":
|
| 476 |
self._finish(j, p)
|
| 477 |
finally:
|
|
@@ -486,7 +506,7 @@ class JobManager:
|
|
| 486 |
def op(j: Job):
|
| 487 |
p = self._open(j)
|
| 488 |
try:
|
| 489 |
-
j.result["
|
| 490 |
self._finish(j, p)
|
| 491 |
finally:
|
| 492 |
self._close(p)
|
|
|
|
| 147 |
f"poll GET /api/jobs/{job.id} until it is done, then retry")
|
| 148 |
self.pending += 1
|
| 149 |
job.state, job.op, job.error, job.progress = "queued", op, None, {"step": "queued"}
|
| 150 |
+
job.result["last"] = {"operation": op}
|
| 151 |
job.event(f"{op}: queued")
|
| 152 |
self.pool.submit(self._run, job, op, fn)
|
| 153 |
|
|
|
|
| 352 |
names = [v.name for v in p.views if v.enabled and (views is None or v.name in views)
|
| 353 |
and (force or v.status not in ("generated", "imported"))]
|
| 354 |
total = len(names)
|
| 355 |
+
outcomes = job.result.setdefault("last", {}).setdefault("views", {})
|
| 356 |
for i, name in enumerate(names):
|
| 357 |
job.progress = {"step": "generating views", "view": name, "done": i, "total": total,
|
| 358 |
"seconds_per_view_estimate": BACKENDS[job.params["backend"]]["seconds_per_view"]}
|
|
|
|
| 369 |
job.event(f"waiting for an external image for {name}")
|
| 370 |
return
|
| 371 |
r = out["results"][0] if out["results"] else {"status": "skipped"}
|
| 372 |
+
outcomes[name] = {k: r.get(k) for k in ("status", "edge_agreement", "silhouette_iou", "attempts")
|
| 373 |
+
if r.get(k) is not None}
|
| 374 |
+
if isinstance(outcomes[name].get("attempts"), list):
|
| 375 |
+
outcomes[name]["attempts"] = len(outcomes[name]["attempts"])
|
| 376 |
job.event(f"view {name}: {r['status']}", view=name, status=r["status"],
|
| 377 |
edge_agreement=r.get("edge_agreement"), silhouette_iou=r.get("silhouette_iou"))
|
| 378 |
job.result.pop("remaining_views", None)
|
|
|
|
| 442 |
def op(j: Job):
|
| 443 |
p = self._open(j)
|
| 444 |
try:
|
| 445 |
+
j.progress = {"step": "planning and generating gap views"}
|
| 446 |
r = p.fill_gaps(max_views=max_views, generate=True, backend=self._backend(j))
|
| 447 |
+
rounds = []
|
| 448 |
+
for rd in r["rounds"]:
|
| 449 |
+
item = {k: v for k, v in rd.items() if k != "generate"}
|
| 450 |
+
if rd.get("generate"):
|
| 451 |
+
item["views"] = {x["view"]: x["status"] for x in rd["generate"]["results"]}
|
| 452 |
+
rounds.append(item)
|
| 453 |
+
j.result["last"] = {"operation": "fill_gaps", "rounds": rounds}
|
| 454 |
self._finish(j, p)
|
| 455 |
finally:
|
| 456 |
self._close(p)
|
|
|
|
| 482 |
if args["render_camera"] not in cams:
|
| 483 |
raise JobError(f"unknown render camera; have {sorted(cams)}", 400)
|
| 484 |
kw["camera"] = cams[args["render_camera"]]
|
| 485 |
+
j.progress = {"step": "retouching: capture, generation and validation"}
|
| 486 |
r = p.retouch(backend=self._backend(j), **kw)
|
| 487 |
+
res = {k: r.get(k) for k in ("status", "layer", "outside_change", "edge_agreement", "attempts",
|
| 488 |
+
"detail", "warning", "mask_pixels") if r.get(k) is not None}
|
| 489 |
+
ldir = j.root / "layers" / (r.get("layer") or kw.get("name") or "")
|
| 490 |
+
if r.get("layer") and ldir.exists():
|
| 491 |
+
res["images"] = {n: f"layers/{r['layer']}/{n}.png" for n in ("compare", "before", "after", "raw",
|
| 492 |
+
"model_input", "mask")
|
| 493 |
+
if (ldir / f"{n}.png").exists()}
|
| 494 |
+
j.result["last"] = {"operation": "retouch", **res}
|
| 495 |
if r.get("status") == "applied":
|
| 496 |
self._finish(j, p)
|
| 497 |
finally:
|
|
|
|
| 506 |
def op(j: Job):
|
| 507 |
p = self._open(j)
|
| 508 |
try:
|
| 509 |
+
j.result["last"] = {"operation": "undo_retouch", **p.undo_retouch()}
|
| 510 |
self._finish(j, p)
|
| 511 |
finally:
|
| 512 |
self._close(p)
|
app/llms.txt
CHANGED
|
@@ -45,9 +45,14 @@ Generation runs on ZeroGPU Spaces and spends the caller's daily GPU quota (about
|
|
| 45 |
- backend: qwen-space (default) | fibo-space (most consistent views, best masked edits; CC BY-NC outputs) | mock |
|
| 46 |
external.
|
| 47 |
- texture_size 1024 | 2048 | 4096; view_size 512-1024.
|
| 48 |
-
Time: ~(views x 30 s) + 1-2 min of CPU work.
|
|
|
|
|
|
|
| 49 |
|
| 50 |
## Reading results
|
|
|
|
|
|
|
|
|
|
| 51 |
- views[].status: generated (used) | rejected (did not line up with the geometry after 3 attempts; excluded) |
|
| 52 |
planned/pending. edge_agreement/silhouette_iou: alignment scores (good views ~0.3-0.7 / >0.85).
|
| 53 |
- summary.views_flagged_inconsistent: views whose content contradicts the others (e.g. a face painted on the back
|
|
|
|
| 45 |
- backend: qwen-space (default) | fibo-space (most consistent views, best masked edits; CC BY-NC outputs) | mock |
|
| 46 |
external.
|
| 47 |
- texture_size 1024 | 2048 | 4096; view_size 512-1024.
|
| 48 |
+
Time: ~(views x 30 s) + 1-2 min of CPU work here. Measured on this Space: 4 views ~3 min (qwen-space and
|
| 49 |
+
fibo-space); a balanced object job (7 views + up to 3 gap views) ~6-8 min. preset "minimal" is for quick tests:
|
| 50 |
+
it leaves much of a complex mesh uncovered (e.g. ~40-50 % on the lantern); use balanced or dense for real work.
|
| 51 |
|
| 52 |
## Reading results
|
| 53 |
+
- last_result: outcome of the latest operation. retouch: status (applied | empty_mask | rejected), images.compare
|
| 54 |
+
(before | model input | model output | after), validation attempts; regenerate/texture: per-view status;
|
| 55 |
+
fill_gaps: coverage before/after and views added. If a retouch was not applied, `next` says how to aim it.
|
| 56 |
- views[].status: generated (used) | rejected (did not line up with the geometry after 3 attempts; excluded) |
|
| 57 |
planned/pending. edge_agreement/silhouette_iou: alignment scores (good views ~0.3-0.7 / >0.85).
|
| 58 |
- summary.views_flagged_inconsistent: views whose content contradicts the others (e.g. a face painted on the back
|
app/server.py
CHANGED
|
@@ -82,6 +82,13 @@ def status_payload(job, base: str, events: int = 12) -> dict:
|
|
| 82 |
"elapsed_seconds": round(job.updated - job.created, 1), "summary": summary, "files": files,
|
| 83 |
"views": views, "error": job.error, "recent_events": job.events[-events:],
|
| 84 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
if ins.get("suggestions"):
|
| 86 |
out["retouch_suggestions"] = [{"index": i, "reason": s["reason"], "area_pct": s["area_pct"]}
|
| 87 |
for i, s in enumerate(ins["suggestions"])]
|
|
@@ -127,6 +134,20 @@ def next_actions(job, url: str, st: dict) -> list[dict]:
|
|
| 127 |
return [{"do": "read error, fix the request and create a new job (or retry the operation)",
|
| 128 |
"error": job.error}]
|
| 129 |
acts = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
files = st.get("files") or {}
|
| 131 |
if files.get("textured_glb"):
|
| 132 |
acts.append({"do": "download the textured model", "method": "GET", "url": files["textured_glb"]})
|
|
|
|
| 82 |
"elapsed_seconds": round(job.updated - job.created, 1), "summary": summary, "files": files,
|
| 83 |
"views": views, "error": job.error, "recent_events": job.events[-events:],
|
| 84 |
}
|
| 85 |
+
last = dict(job.result.get("last") or {})
|
| 86 |
+
if last.get("images"):
|
| 87 |
+
last["images"] = {k: f"{url}/files/{v}" for k, v in last["images"].items()}
|
| 88 |
+
out["last_result"] = last or None
|
| 89 |
+
if d and d.get("layers"):
|
| 90 |
+
out["retouch_layers"] = [{"name": L["name"], "mode": L["mode"], "enabled": L.get("enabled", True),
|
| 91 |
+
"compare_url": f"{url}/files/layers/{L['name']}/compare.png"} for L in d["layers"]]
|
| 92 |
if ins.get("suggestions"):
|
| 93 |
out["retouch_suggestions"] = [{"index": i, "reason": s["reason"], "area_pct": s["area_pct"]}
|
| 94 |
for i, s in enumerate(ins["suggestions"])]
|
|
|
|
| 134 |
return [{"do": "read error, fix the request and create a new job (or retry the operation)",
|
| 135 |
"error": job.error}]
|
| 136 |
acts = []
|
| 137 |
+
last = st.get("last_result") or {}
|
| 138 |
+
if last.get("operation") == "retouch" and last.get("status") != "applied":
|
| 139 |
+
why = {"empty_mask": "the mask covered no visible surface from that camera: aim it with "
|
| 140 |
+
"POST /pick (render_camera + pixels) and use a sphere mask around the returned point, "
|
| 141 |
+
"or use a retouch_suggestions index",
|
| 142 |
+
"rejected": "the model's output did not keep the framing/geometry; try another camera, a tighter "
|
| 143 |
+
"focus, another prompt or another backend"}.get(last.get("status"), last.get("detail"))
|
| 144 |
+
acts.append({"do": f"retouch was not applied ({last.get('status')})", "why": why,
|
| 145 |
+
"pick": {"method": "POST", "url": f"{url}/pick",
|
| 146 |
+
"body": {"render_camera": "orbit00", "pixels": [[0.5, 0.5]]}}})
|
| 147 |
+
elif last.get("operation") == "retouch" and last.get("images", {}).get("compare"):
|
| 148 |
+
acts.append({"do": "check the retouch (before | model input | model output | after)", "method": "GET",
|
| 149 |
+
"url": last["images"]["compare"],
|
| 150 |
+
"undo": {"method": "POST", "url": f"{url}/undo-retouch"}})
|
| 151 |
files = st.get("files") or {}
|
| 152 |
if files.get("textured_glb"):
|
| 153 |
acts.append({"do": "download the textured model", "method": "GET", "url": files["textured_glb"]})
|
tests/smoke.py
CHANGED
|
@@ -168,8 +168,8 @@ async def mcp_checks(base: str, H: dict) -> None:
|
|
| 168 |
check("Quota and identity" in res.content[0].text, "guide tool returns llms.txt")
|
| 169 |
|
| 170 |
|
| 171 |
-
def live_checks(base: str, H: dict) -> None:
|
| 172 |
-
for backend in
|
| 173 |
print(f"LIVE job: {backend}")
|
| 174 |
r = requests.post(f"{base}/api/jobs", headers=H, timeout=60, json={
|
| 175 |
"sample": "lantern", "backend": backend, "preset": "minimal", "gap_views": False, "texture_size": 2048,
|
|
@@ -185,16 +185,26 @@ def live_checks(base: str, H: dict) -> None:
|
|
| 185 |
(OUT / f"live_{backend}_contact_sheet.png").write_bytes(img)
|
| 186 |
(OUT / f"live_{backend}_status.json").write_text(json.dumps(d, indent=1))
|
| 187 |
if backend == "fibo-space" and d["state"] == "done":
|
| 188 |
-
print("LIVE retouch (fibo-space, native mask)")
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
check(
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
|
| 200 |
def main() -> int:
|
|
@@ -202,6 +212,7 @@ def main() -> int:
|
|
| 202 |
ap.add_argument("base")
|
| 203 |
ap.add_argument("--live", action="store_true")
|
| 204 |
ap.add_argument("--skip-free", action="store_true")
|
|
|
|
| 205 |
a = ap.parse_args()
|
| 206 |
base = a.base.rstrip("/")
|
| 207 |
H: dict = {}
|
|
@@ -215,7 +226,7 @@ def main() -> int:
|
|
| 215 |
rest_checks(base, H)
|
| 216 |
asyncio.run(mcp_checks(base, H))
|
| 217 |
if a.live:
|
| 218 |
-
live_checks(base, H)
|
| 219 |
print("\nRESULT:", "all checks passed" if not FAILS else f"{len(FAILS)} failed: {FAILS}")
|
| 220 |
return 1 if FAILS else 0
|
| 221 |
|
|
|
|
| 168 |
check("Quota and identity" in res.content[0].text, "guide tool returns llms.txt")
|
| 169 |
|
| 170 |
|
| 171 |
+
def live_checks(base: str, H: dict, backends: list[str]) -> None:
|
| 172 |
+
for backend in backends:
|
| 173 |
print(f"LIVE job: {backend}")
|
| 174 |
r = requests.post(f"{base}/api/jobs", headers=H, timeout=60, json={
|
| 175 |
"sample": "lantern", "backend": backend, "preset": "minimal", "gap_views": False, "texture_size": 2048,
|
|
|
|
| 185 |
(OUT / f"live_{backend}_contact_sheet.png").write_bytes(img)
|
| 186 |
(OUT / f"live_{backend}_status.json").write_text(json.dumps(d, indent=1))
|
| 187 |
if backend == "fibo-space" and d["state"] == "done":
|
| 188 |
+
print("LIVE retouch (fibo-space, native mask), aimed with /pick like an agent would")
|
| 189 |
+
jid = d["job_id"]
|
| 190 |
+
grid = [[x / 8, y / 8] for y in range(2, 7) for x in range(2, 7)]
|
| 191 |
+
picks = requests.post(f"{base}/api/jobs/{jid}/pick", headers=H, timeout=300,
|
| 192 |
+
json={"render_camera": "orbit00", "pixels": grid}).json()["picks"]
|
| 193 |
+
hits = [pk for pk in picks if pk["hit"]]
|
| 194 |
+
check(bool(hits), f"pick found {len(hits)} surface points on orbit00")
|
| 195 |
+
pt = hits[len(hits) // 2]
|
| 196 |
+
r = requests.post(f"{base}/api/jobs/{jid}/retouch", headers=H, timeout=60, json={
|
| 197 |
+
"mode": "replace", "prompt": "a small brass plaque with an engraved number 7",
|
| 198 |
+
"focus": {"point": pt["point"], "radius": 0.12 * pt["depth"], "direction": pt["normal"]},
|
| 199 |
+
"mask": {"sphere": pt["point"] + [0.06 * pt["depth"]]}})
|
| 200 |
+
check(r.status_code == 202, "retouch -> 202")
|
| 201 |
+
d2 = wait(base, jid, H, timeout=1200)
|
| 202 |
+
last = d2.get("last_result") or {}
|
| 203 |
+
check(d2["state"] == "done" and last.get("status") == "applied",
|
| 204 |
+
f"retouch applied: {json.dumps({k: last.get(k) for k in ('status', 'detail', 'edge_agreement', 'outside_change')})}")
|
| 205 |
+
if last.get("images", {}).get("compare"):
|
| 206 |
+
(OUT / "live_fibo_retouch_compare.png").write_bytes(
|
| 207 |
+
requests.get(last["images"]["compare"], headers=H, timeout=60).content)
|
| 208 |
|
| 209 |
|
| 210 |
def main() -> int:
|
|
|
|
| 212 |
ap.add_argument("base")
|
| 213 |
ap.add_argument("--live", action="store_true")
|
| 214 |
ap.add_argument("--skip-free", action="store_true")
|
| 215 |
+
ap.add_argument("--backends", default="qwen-space,fibo-space", help="live backends to test")
|
| 216 |
a = ap.parse_args()
|
| 217 |
base = a.base.rstrip("/")
|
| 218 |
H: dict = {}
|
|
|
|
| 226 |
rest_checks(base, H)
|
| 227 |
asyncio.run(mcp_checks(base, H))
|
| 228 |
if a.live:
|
| 229 |
+
live_checks(base, H, a.backends.split(","))
|
| 230 |
print("\nRESULT:", "all checks passed" if not FAILS else f"{len(FAILS)} failed: {FAILS}")
|
| 231 |
return 1 if FAILS else 0
|
| 232 |
|