Mike0021 commited on
Commit
09552e8
·
verified ·
1 Parent(s): bdb443d

Report each operation's outcome (last_result), retouch progress and recovery hints

Browse files
Files changed (4) hide show
  1. app/jobs.py +24 -4
  2. app/llms.txt +6 -1
  3. app/server.py +21 -0
  4. 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
- j.result["fill_gaps"] = [{k: v for k, v in rd.items() if k != "generate"} for rd in r["rounds"]]
 
 
 
 
 
 
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
- j.result["retouch"] = {k: r.get(k) for k in ("status", "layer", "outside_change", "edge_agreement",
474
- "attempts", "detail", "warning")}
 
 
 
 
 
 
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["undo"] = p.undo_retouch()
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. A balanced object job is about 6-8 minutes with qwen-space.
 
 
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 ("qwen-space", "fibo-space"):
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
- r = requests.post(f"{base}/api/jobs/{d['job_id']}/retouch", headers=H, timeout=60, json={
190
- "mode": "replace", "render_camera": "orbit00", "mask": {"circle": [0.5, 0.62, 0.06]},
191
- "prompt": "a small brass plaque with an engraved number 7"})
192
- d2 = wait(base, d["job_id"], H, timeout=1200)
193
- ok = d2["state"] == "done"
194
- check(ok, f"retouch finished: {d2.get('recent_events', [{}])[-1].get('msg')}")
195
- if ok:
196
- img = requests.get(d2["files"]["contact_sheet"], headers=H, timeout=60).content
197
- (OUT / "live_fibo_retouch_contact_sheet.png").write_bytes(img)
 
 
 
 
 
 
 
 
 
 
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