Mike0021 commited on
Commit
8cc7e31
·
verified ·
1 Parent(s): 7c0b4fc

Round 7 fixes: flagged-view share, partial-view retry, inside of hollow parts, no straight-down gap views

Browse files
Files changed (6) hide show
  1. app/llms.txt +5 -2
  2. app/server.py +11 -4
  3. projtex/planning.py +20 -4
  4. projtex/project.py +99 -13
  5. projtex/prompts.py +6 -2
  6. projtex/qa.py +36 -1
app/llms.txt CHANGED
@@ -119,7 +119,9 @@ it leaves much of a complex mesh uncovered; use balanced or dense for real work.
119
  - summary.coverage_pct / holes_pct are measured over the surface a camera can see from the side or above
120
  (summary.measured_over). The underside, visible only from below (e.g. the bottom of a crate standing on the
121
  ground), is reported apart as underside_pct / underside_covered_pct; uncovered texels are filled with nearby
122
- colours. Typical good results: coverage 85-95 %, holes < 10 %.
 
 
123
  - last_result: outcome of the latest operation. retouch: status (applied | empty_mask | rejected), images.compare
124
  (before | model input | model output | after), validation attempts; regenerate/texture: per-view status;
125
  fill_gaps: coverage before/after and views added. If a retouch was not applied, `next` says how to aim it.
@@ -130,7 +132,8 @@ it leaves much of a complex mesh uncovered; use balanced or dense for real work.
130
  partial: true = the view matched the geometry but not the texture already painted (each view of a teapot
131
  invents its own flowers), so it only paints where it sees the surface better than the views before it.
132
  - summary.views_flagged_inconsistent: views whose content contradicts the others (e.g. a face painted on the back
133
- of a head). Regenerate them; disable if still flagged.
 
134
  - retouch_suggestions: problem regions (uncovered / stretched / inconsistent) with an index for /retouch.
135
  - warnings: parameter problems worth fixing (e.g. "up" given for a GLB).
136
 
 
119
  - summary.coverage_pct / holes_pct are measured over the surface a camera can see from the side or above
120
  (summary.measured_over). The underside, visible only from below (e.g. the bottom of a crate standing on the
121
  ground), is reported apart as underside_pct / underside_covered_pct; uncovered texels are filled with nearby
122
+ colours. So is the inside of hollow parts seen only through an opening (a vase, a cup, a well, a chimney):
123
+ inside_pct; image models cannot paint views into such openings, so it gets the main painted colour of its
124
+ material (e.g. the white glaze of a blue-and-white vase). Typical good results: coverage 85-95 %, holes < 10 %.
125
  - last_result: outcome of the latest operation. retouch: status (applied | empty_mask | rejected), images.compare
126
  (before | model input | model output | after), validation attempts; regenerate/texture: per-view status;
127
  fill_gaps: coverage before/after and views added. If a retouch was not applied, `next` says how to aim it.
 
132
  partial: true = the view matched the geometry but not the texture already painted (each view of a teapot
133
  invents its own flowers), so it only paints where it sees the surface better than the views before it.
134
  - summary.views_flagged_inconsistent: views whose content contradicts the others (e.g. a face painted on the back
135
+ of a head); flagged_views_shown_pct is how much of the surface they actually paint that way. Regenerate them;
136
+ disable if still flagged. Below about 2 % it rarely shows: look at the contact sheet before spending a retry.
137
  - retouch_suggestions: problem regions (uncovered / stretched / inconsistent) with an index for /retouch.
138
  - warnings: parameter problems worth fixing (e.g. "up" given for a GLB).
139
 
app/server.py CHANGED
@@ -78,9 +78,13 @@ def status_payload(job, base: str, events: int = 12) -> dict:
78
  "inconsistent_pct": stats.get("inconsistent"), "views_used": stats.get("views_used"),
79
  "views_rejected": [v["name"] for v in views if v["status"] == "rejected"],
80
  "views_flagged_inconsistent": (ins.get("regenerate_views") or {}).get("views", [])}
 
 
81
  if stats.get("underside"):
82
  summary["underside_pct"] = stats["underside"]
83
  summary["underside_covered_pct"] = stats.get("underside_covered")
 
 
84
  summary["verdict"], summary["verdict_why"] = verdict(summary)
85
  out: dict[str, Any] = {
86
  "job_id": job.id, "state": job.state, "operation": job.op, "progress": job.progress,
@@ -159,15 +163,18 @@ def verdict(summ: dict) -> tuple[str, str]:
159
  """good: ship it | usable: fine at a distance, optional fixes | needs work: follow `next`."""
160
  cov, holes = summ.get("coverage_pct") or 0.0, summ.get("holes_pct") or 0.0
161
  flagged, rejected = summ.get("views_flagged_inconsistent") or [], summ.get("views_rejected") or []
162
- incons = summ.get("inconsistent_pct") or 0.0
163
- if cov < 75 or (flagged and incons >= 2.0):
 
 
164
  why = [f"coverage {cov} %" if cov < 75 else "",
165
- f"inconsistent views {flagged} ({incons} % of the surface)" if flagged and incons >= 2.0 else ""]
 
166
  return "needs work", "; ".join(w for w in why if w)
167
  if cov < 90 or holes >= 5 or rejected or flagged:
168
  why = [f"coverage {cov} %" if cov < 90 else "", f"holes {holes} %" if holes >= 5 else "",
169
  f"rejected views {rejected}" if rejected else "",
170
- f"view(s) {flagged} disagree with the others ({incons} % of the surface)" if flagged else ""]
171
  return "usable", "; ".join(w for w in why if w)
172
  return "good", f"coverage {cov} %, holes {holes} %, all views consistent"
173
 
 
78
  "inconsistent_pct": stats.get("inconsistent"), "views_used": stats.get("views_used"),
79
  "views_rejected": [v["name"] for v in views if v["status"] == "rejected"],
80
  "views_flagged_inconsistent": (ins.get("regenerate_views") or {}).get("views", [])}
81
+ if summary["views_flagged_inconsistent"] and stats.get("flagged_views_shown") is not None:
82
+ summary["flagged_views_shown_pct"] = stats["flagged_views_shown"]
83
  if stats.get("underside"):
84
  summary["underside_pct"] = stats["underside"]
85
  summary["underside_covered_pct"] = stats.get("underside_covered")
86
+ if stats.get("inside"):
87
+ summary["inside_pct"] = stats["inside"]
88
  summary["verdict"], summary["verdict_why"] = verdict(summary)
89
  out: dict[str, Any] = {
90
  "job_id": job.id, "state": job.state, "operation": job.op, "progress": job.progress,
 
163
  """good: ship it | usable: fine at a distance, optional fixes | needs work: follow `next`."""
164
  cov, holes = summ.get("coverage_pct") or 0.0, summ.get("holes_pct") or 0.0
165
  flagged, rejected = summ.get("views_flagged_inconsistent") or [], summ.get("views_rejected") or []
166
+ # the part of the texture where a flagged view wins the blend and contradicts the others (older results only
167
+ # have the overall disagreement between views)
168
+ shown = summ.get("flagged_views_shown_pct", summ.get("inconsistent_pct") or 0.0)
169
+ if cov < 75 or (flagged and shown >= 2.0):
170
  why = [f"coverage {cov} %" if cov < 75 else "",
171
+ f"view(s) {flagged} contradict the others on {shown} % of the surface" if flagged and shown >= 2.0
172
+ else ""]
173
  return "needs work", "; ".join(w for w in why if w)
174
  if cov < 90 or holes >= 5 or rejected or flagged:
175
  why = [f"coverage {cov} %" if cov < 90 else "", f"holes {holes} %" if holes >= 5 else "",
176
  f"rejected views {rejected}" if rejected else "",
177
+ f"view(s) {flagged} disagree with the others ({shown} % of the surface shows it)" if flagged else ""]
178
  return "usable", "; ".join(w for w in why if w)
179
  return "good", f"coverage {cov} %, holes {holes} %, all views consistent"
180
 
projtex/planning.py CHANGED
@@ -605,28 +605,34 @@ def greedy_probe_positions(probe: CoverageProbe, walkable: np.ndarray, relevant:
605
 
606
 
607
  def outside_visibility(probe: CoverageProbe, mesh: TexMesh, n: int = 64, fov: float = 35.0, size: int = 768,
608
- min_elevation: float | None = None, with_mean: bool = False):
609
  """Texels visible from a dense sphere of outside cameras (what any orbit view could ever show).
610
  ``min_elevation`` (degrees) keeps only cameras at or above that elevation, e.g. 0 for 'seen from the side or
611
  above' (an object's underside is then excluded). ``size``: probe resolution; coarse probes (e.g. 192 px) count
612
  faces lying millimetres under other parts as visible, which the real 1024 px projection rightly never covers.
613
  ``with_mean``: also return the mean quality over the cameras, i.e. how much a texel is seen from around the
614
- object (deep concave surfaces glimpsed from one grazing angle get little weight)."""
 
 
615
  if min_elevation is not None: # same density on the part of the sphere that is kept
616
  n = int(round(n * 2 / (1 - np.sin(np.radians(min_elevation)))))
617
  k = np.arange(n) + 0.5
618
  el = np.degrees(np.arcsin(1 - 2 * k / n))
619
  az = np.degrees(np.pi * (1 + 5 ** 0.5) * k) % 360
620
  vis = np.zeros((probe.res, probe.res), np.float32)
621
- total, used = np.zeros_like(vis), 0
622
  for a, e in zip(az, el):
623
  if min_elevation is not None and e < min_elevation:
624
  continue
625
  q = probe.quality(_frame_camera(mesh, a, e, fov, 256), size=size)
626
  vis = np.maximum(vis, q)
627
  total += q
 
 
 
628
  used += 1
629
- return (vis, total / max(used, 1)) if with_mean else vis
 
630
 
631
 
632
  def walk_viewpoints(gl: GL, mesh: TexMesh, walkable: np.ndarray, n: int, size: int, fov: float = 75.0,
@@ -740,6 +746,16 @@ def plan_gaps(gl: GL, mesh: TexMesh, alpha: np.ndarray, probe: CoverageProbe, ma
740
  q = probe.quality(cam)
741
  gain = float((W * np.maximum(q - best, 0) * todo).sum())
742
  scored.append((gain, cam, q))
 
 
 
 
 
 
 
 
 
 
743
  gain, cam, q = max(scored, key=lambda s_: s_[0])
744
  whole = [s_ for s_ in scored if getattr(s_[1], "meta_whole", False)]
745
  if whole and not getattr(cam, "meta_whole", False):
 
605
 
606
 
607
  def outside_visibility(probe: CoverageProbe, mesh: TexMesh, n: int = 64, fov: float = 35.0, size: int = 768,
608
+ min_elevation: float | None = None, with_mean: bool = False, with_frac: bool = False):
609
  """Texels visible from a dense sphere of outside cameras (what any orbit view could ever show).
610
  ``min_elevation`` (degrees) keeps only cameras at or above that elevation, e.g. 0 for 'seen from the side or
611
  above' (an object's underside is then excluded). ``size``: probe resolution; coarse probes (e.g. 192 px) count
612
  faces lying millimetres under other parts as visible, which the real 1024 px projection rightly never covers.
613
  ``with_mean``: also return the mean quality over the cameras, i.e. how much a texel is seen from around the
614
+ object (deep concave surfaces glimpsed from one grazing angle get little weight). ``with_frac``: also return
615
+ the share of the cameras that see each texel at all, and the share of those below 50 degrees elevation, as
616
+ a (res, res, 2) array (the inside of a vase is seen only from steep cameras, through its mouth)."""
617
  if min_elevation is not None: # same density on the part of the sphere that is kept
618
  n = int(round(n * 2 / (1 - np.sin(np.radians(min_elevation)))))
619
  k = np.arange(n) + 0.5
620
  el = np.degrees(np.arcsin(1 - 2 * k / n))
621
  az = np.degrees(np.pi * (1 + 5 ** 0.5) * k) % 360
622
  vis = np.zeros((probe.res, probe.res), np.float32)
623
+ total, seen_by, used = np.zeros_like(vis), np.zeros((probe.res, probe.res, 2), np.float32), 0
624
  for a, e in zip(az, el):
625
  if min_elevation is not None and e < min_elevation:
626
  continue
627
  q = probe.quality(_frame_camera(mesh, a, e, fov, 256), size=size)
628
  vis = np.maximum(vis, q)
629
  total += q
630
+ seen_by[..., 0] += q > 0.01 # geometric visibility: small parts get low quality from every camera
631
+ if e < 50:
632
+ seen_by[..., 1] += q > 0.01
633
  used += 1
634
+ out = (vis,) + ((total / max(used, 1),) if with_mean else ()) + ((seen_by / max(used, 1),) if with_frac else ())
635
+ return out if len(out) > 1 else vis
636
 
637
 
638
  def walk_viewpoints(gl: GL, mesh: TexMesh, walkable: np.ndarray, n: int, size: int, fov: float = 75.0,
 
746
  q = probe.quality(cam)
747
  gain = float((W * np.maximum(q - best, 0) * todo).sum())
748
  scored.append((gain, cam, q))
749
+ if not environment: # straight-down views of an object come back as a closed top (a vase's mouth painted
750
+ # over): keep one only when no camera at 70 degrees or lower gets at least 60 % of its gain
751
+ def steep(c_):
752
+ d_ = c_.eye_np - np.asarray(c_.target, np.float64)
753
+ return d_[1] / max(np.linalg.norm(d_), 1e-12) > np.sin(np.radians(70))
754
+
755
+ top = max(scored, key=lambda s_: s_[0])
756
+ lower = [s_ for s_ in scored if not steep(s_[1])]
757
+ if steep(top[1]) and lower and max(s_[0] for s_ in lower) >= 0.6 * top[0]:
758
+ scored = lower
759
  gain, cam, q = max(scored, key=lambda s_: s_[0])
760
  whole = [s_ for s_ in scored if getattr(s_[1], "meta_whole", False)]
761
  if whole and not getattr(cam, "meta_whole", False):
projtex/project.py CHANGED
@@ -78,6 +78,8 @@ DEFAULT_SETTINGS: dict[str, Any] = {
78
  # a view that only disagrees with texture already painted, while matching the geometry well, paints just the
79
  # surface not painted yet (see _partial_ok) instead of being rejected
80
  "partial_views": True,
 
 
81
  "min_edge_agreement_env": 0.1,
82
  "max_outside_change": 0.06,
83
  "max_lowpass_change": 0.035, # retouch upscale: the blurred content must stay the same # retouch: reject outputs that repaint the picture outside the mask
@@ -103,6 +105,19 @@ def _save(a: np.ndarray, path: Path) -> Path:
103
  return path
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  @dataclass
107
  class View:
108
  name: str
@@ -405,6 +420,9 @@ class Project:
405
  if not env: # estimate what the QA reports: the surface seen from the side or above, weighted by how much
406
  relevant = self.relevance(probe.res, above=True)
407
  weights = self.seen_weights(probe.res)
 
 
 
408
  best = np.zeros((probe.res, probe.res), np.float32)
409
  for s in specs:
410
  q = probe.quality(s.camera)
@@ -479,8 +497,10 @@ class Project:
479
  if self.mode == "environment" and walk is not None:
480
  rel = planning.pvs_alpha(probe, walk)
481
  elif above:
482
- rel, seen = planning.outside_visibility(probe, self.mesh, min_elevation=0.0, with_mean=True)
 
483
  np.save(self.root / "mesh" / f"seen_above_{res}.npy", seen)
 
484
  else:
485
  rel = planning.outside_visibility(probe, self.mesh)
486
  np.save(f, rel)
@@ -499,6 +519,55 @@ class Project:
499
  tws = texel_world_size(self.gl.uv_gbuffer(self.mesh, res))
500
  return np.nan_to_num(np.load(f) * tws * tws).astype(np.float32)
501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  def _set_dens_ref(self, specs) -> None:
503
  vals = []
504
  for s in specs[:12]:
@@ -707,8 +776,9 @@ class Project:
707
  alpha = acc.alpha.copy()
708
  changed = self._apply_layers(color, alpha, res) if layers else 0
709
  covered = (acc.den > 0) | (alpha > 0.01)
 
710
  out = {"color": color, "alpha": alpha, "covered": covered, "acc": acc, "views": used,
711
- "layer_texels": changed, "res": res}
712
  self._tex_cache = {"key": key, "value": out}
713
  return out
714
 
@@ -913,7 +983,8 @@ class Project:
913
  return {"color": color, "alpha": alpha, "covered": (acc.den > 0) | (alpha > 0.01), "acc": acc}
914
 
915
  def _view_prompt(self, v: View, coverage: float, n_refs: int, cropped: bool = False, retry: int = 0,
916
- caption: str = "", layout: str = "", color_guide: bool = False) -> str:
 
917
  s = self.settings
918
  hint = v.prompt_hint
919
  base = s.get("focus_prompt") if v.kind == "focus" and s.get("focus_prompt") else s["prompt"]
@@ -947,7 +1018,8 @@ class Project:
947
  return view_prompt(prompt, coverage=coverage, kind=v.kind, hint=hint, lighting=s["lighting"],
948
  subject=subject, environment=self.mode == "environment", n_refs=n_refs,
949
  raw_prompt=bool(s.get("raw_prompt")), cropped=cropped, retry=retry, focus_label=label,
950
- caption=caption, pose_hint=ph, layout=layout, color_guide=color_guide)
 
951
 
952
  def generate(self, views: list[str] | None = None, force: bool = False, backend: Any = None,
953
  bake: bool = True, progress: Any = None) -> dict:
@@ -996,7 +1068,8 @@ class Project:
996
  tries = 1 + (int(self.settings.get("max_retries", 2)) if validate else 0)
997
  attempts, best, failed = [], None, None
998
  rescue_inside = None
999
- partial_mask = None
 
1000
  view_mask = None
1001
  if self.settings.get("view_masks") and be.native_mask and cond["coverage"] > 0.02:
1002
  clay = (cond["cover_px"] < 0.5) & (cond["depth"] > 0)
@@ -1010,7 +1083,7 @@ class Project:
1010
  layout, negative = self._layout(cond["face"])
1011
  prompt = self._view_prompt(v, cond["coverage"], len(refs), cropped=cond["cropped"], retry=k,
1012
  caption=cond.get("caption", ""), layout=layout,
1013
- color_guide=cond.get("color_guide", False))
1014
  (vdir / "prompt.txt").write_text(prompt + (f"\n\nNEGATIVE: {negative}" if negative else ""))
1015
  req = GenRequest(image=_np_to_img(cond["image"]), prompt=prompt, width=v.camera.width, mask=view_mask,
1016
  height=v.camera.height, seed=seed + 7919 * k, task="view", references=refs,
@@ -1047,23 +1120,33 @@ class Project:
1047
  post.update(chk)
1048
  post.pop("reject_reasons", None)
1049
  rescue_inside = inside
 
1050
  if not post["accepted"] and self._partial_ok(post):
1051
- partial_mask = self._unpainted_mask(cond, img.shape[:2])
1052
- if partial_mask is not None: # keep it, but only where nothing was painted yet
1053
- post["accepted"], post["partial"] = True, True
1054
  cost = (getattr(be, "last_usage", None) or {}).get("cost")
1055
  attempts.append({"seed": seed + 7919 * k, "cost_usd": cost,
1056
  **{kk: post.get(kk) for kk in ("edge_agreement", "silhouette_iou", "painted_corr",
1057
- "accepted")}})
 
 
 
 
 
 
1058
  if best is None or (post.get("edge_agreement") or 0) > (best[1].get("edge_agreement") or 0):
1059
- best = (img, post, seed + 7919 * k, rescue_inside, partial_mask)
1060
  if post["accepted"]:
1061
- best = (img, post, seed + 7919 * k, rescue_inside, partial_mask)
1062
  break
1063
- rescue_inside = partial_mask = None
 
1064
  log.warning("view %s attempt %d rejected (%s)", v.name, k + 1,
1065
  {kk: post.get(kk) for kk in ("edge_agreement", "silhouette_iou", "painted_corr",
1066
  "painted_diff")})
 
 
 
 
1067
  if failed and failed[0] == "fatal": # e.g. GPU quota exhausted: stop, keep the view planned
1068
  _emit({"view": v.name, "status": "stopped", "detail": failed[1]})
1069
  stopped = (failed[1], [t.name for t in targets[targets.index(v):]])
@@ -1152,6 +1235,9 @@ class Project:
1152
  probe = planning.CoverageProbe(self.gl, self.mesh, res=res, params=self.fusion())
1153
  rel = self.relevance(res)
1154
  env = self.mode == "environment"
 
 
 
1155
  imp = self.importance(res) if env else None
1156
  weights = planning.pvs_weights(imp) if imp is not None else None
1157
  before = self._cov(probe, st["alpha"], rel, weights=weights)
 
78
  # a view that only disagrees with texture already painted, while matching the geometry well, paints just the
79
  # surface not painted yet (see _partial_ok) instead of being rejected
80
  "partial_views": True,
81
+ "inside_seen_frac": 0.06, # surface seen by fewer cameras is "inside" (see Project.inside); 0 disables
82
+ "partial_retries": 1, # extra tries for a view kept as partial, in case one continues the painted pattern
83
  "min_edge_agreement_env": 0.1,
84
  "max_outside_change": 0.06,
85
  "max_lowpass_change": 0.035, # retouch upscale: the blurred content must stay the same # retouch: reject outputs that repaint the picture outside the mask
 
105
  return path
106
 
107
 
108
+ def _main_color(cols: np.ndarray) -> np.ndarray:
109
+ """Dominant colour of a set of texels: the fullest cell of a coarse colour grid, then its median (the white
110
+ ground of a patterned glaze, not the average of ground and pattern)."""
111
+ from scipy.ndimage import uniform_filter
112
+
113
+ cols = np.nan_to_num(cols.reshape(-1, 3))
114
+ q = np.clip((cols * 8).astype(np.int64), 0, 7)
115
+ hist = np.bincount(q[:, 0] * 64 + q[:, 1] * 8 + q[:, 2], minlength=512).reshape(8, 8, 8).astype(np.float64)
116
+ top = np.array(np.unravel_index(uniform_filter(hist, 3, mode="constant").argmax(), hist.shape))
117
+ near = (np.abs(q - top) <= 1).all(1) # one colour family can straddle a cell border
118
+ return np.median(cols[near], axis=0).astype(np.float32)
119
+
120
+
121
  @dataclass
122
  class View:
123
  name: str
 
420
  if not env: # estimate what the QA reports: the surface seen from the side or above, weighted by how much
421
  relevant = self.relevance(probe.res, above=True)
422
  weights = self.seen_weights(probe.res)
423
+ ins = self.inside(probe.res)
424
+ if ins is not None: # no view can paint it: filled with its material's colour instead
425
+ relevant = np.where(ins, 0.0, relevant)
426
  best = np.zeros((probe.res, probe.res), np.float32)
427
  for s in specs:
428
  q = probe.quality(s.camera)
 
497
  if self.mode == "environment" and walk is not None:
498
  rel = planning.pvs_alpha(probe, walk)
499
  elif above:
500
+ rel, seen, frac = planning.outside_visibility(probe, self.mesh, min_elevation=0.0, with_mean=True,
501
+ with_frac=True)
502
  np.save(self.root / "mesh" / f"seen_above_{res}.npy", seen)
503
+ np.save(self.root / "mesh" / f"seenfrac_above_{res}.npy", frac)
504
  else:
505
  rel = planning.outside_visibility(probe, self.mesh)
506
  np.save(f, rel)
 
519
  tws = texel_world_size(self.gl.uv_gbuffer(self.mesh, res))
520
  return np.nan_to_num(np.load(f) * tws * tws).astype(np.float32)
521
 
522
+ def inside(self, res: int = 1024) -> np.ndarray | None:
523
+ """Objects: surface seen only from above through a narrow opening (the inside of a vase, a cup or a
524
+ chimney): by fewer than ``inside_seen_frac`` of the cameras around and above the object, and mostly by
525
+ steep ones (a recessed window or a porch floor is seen from the side). Image models cannot paint views
526
+ down such openings (they close them: three gap views into a vase's mouth all failed), so gap views do not
527
+ aim at it and fuse() gives what stays unpainted there the main colour of its material. None for
528
+ environments."""
529
+ thr = float(self.settings.get("inside_seen_frac") or 0.0)
530
+ if self.mode == "environment" or thr <= 0:
531
+ return None
532
+ f = self.root / "mesh" / f"seenfrac_above_{res}.npy"
533
+ if not f.exists():
534
+ (self.root / "mesh" / f"pvs_above_{res}.npy").unlink(missing_ok=True)
535
+ self.relevance(res, above=True)
536
+ frac = np.load(f)
537
+ if frac.ndim != 3: # written before the low-camera share was kept
538
+ f.unlink()
539
+ (self.root / "mesh" / f"pvs_above_{res}.npy").unlink(missing_ok=True)
540
+ self.relevance(res, above=True)
541
+ frac = np.load(f)
542
+ seen, low = frac[..., 0], frac[..., 1]
543
+ return (seen > 0) & (seen < thr) & (low <= 0.25 * seen)
544
+
545
+ def _fill_inside(self, color: np.ndarray, alpha: np.ndarray, covered: np.ndarray, res: int) -> int:
546
+ """Paint the unpainted inside surface (see inside()) with the dominant painted colour of its material
547
+ (the white glaze of a blue-and-white vase), in place. Returns the number of texels filled."""
548
+ ins = self.inside(min(res, 1024))
549
+ if ins is None or not ins.any():
550
+ return 0
551
+ idx = np.arange(res) * ins.shape[0] // res
552
+ todo = ins[idx][:, idx] & (alpha < 0.3)
553
+ if not todo.any():
554
+ return 0
555
+ fid = self.gl.uv_gbuffer(self.mesh, res)["face"]
556
+ mat = np.where(fid >= 0, self.mesh.face_material[np.maximum(fid, 0)], -1)
557
+ good = (alpha >= 0.6) & (fid >= 0)
558
+ if good.sum() < 64:
559
+ return 0
560
+ filled = np.zeros_like(todo)
561
+ for m in np.unique(mat[todo]):
562
+ src = good & (mat == m)
563
+ tgt = todo & (mat == m)
564
+ main = _main_color(color[src] if src.sum() >= 64 else color[good])
565
+ a = np.clip(alpha[tgt] / 0.3, 0, 1)[:, None] # keep what little a view painted there, blended in
566
+ color[tgt] = a * color[tgt] + (1 - a) * main
567
+ filled |= tgt
568
+ covered |= filled
569
+ return int(filled.sum())
570
+
571
  def _set_dens_ref(self, specs) -> None:
572
  vals = []
573
  for s in specs[:12]:
 
776
  alpha = acc.alpha.copy()
777
  changed = self._apply_layers(color, alpha, res) if layers else 0
778
  covered = (acc.den > 0) | (alpha > 0.01)
779
+ inside = self._fill_inside(color, alpha, covered, res) if used else 0
780
  out = {"color": color, "alpha": alpha, "covered": covered, "acc": acc, "views": used,
781
+ "layer_texels": changed, "inside_filled": inside, "res": res}
782
  self._tex_cache = {"key": key, "value": out}
783
  return out
784
 
 
983
  return {"color": color, "alpha": alpha, "covered": (acc.den > 0) | (alpha > 0.01), "acc": acc}
984
 
985
  def _view_prompt(self, v: View, coverage: float, n_refs: int, cropped: bool = False, retry: int = 0,
986
+ caption: str = "", layout: str = "", color_guide: bool = False,
987
+ retry_painted: bool = False) -> str:
988
  s = self.settings
989
  hint = v.prompt_hint
990
  base = s.get("focus_prompt") if v.kind == "focus" and s.get("focus_prompt") else s["prompt"]
 
1018
  return view_prompt(prompt, coverage=coverage, kind=v.kind, hint=hint, lighting=s["lighting"],
1019
  subject=subject, environment=self.mode == "environment", n_refs=n_refs,
1020
  raw_prompt=bool(s.get("raw_prompt")), cropped=cropped, retry=retry, focus_label=label,
1021
+ caption=caption, pose_hint=ph, layout=layout, color_guide=color_guide,
1022
+ retry_painted=retry_painted)
1023
 
1024
  def generate(self, views: list[str] | None = None, force: bool = False, backend: Any = None,
1025
  bake: bool = True, progress: Any = None) -> dict:
 
1068
  tries = 1 + (int(self.settings.get("max_retries", 2)) if validate else 0)
1069
  attempts, best, failed = [], None, None
1070
  rescue_inside = None
1071
+ partial_best = None # an attempt good enough to paint only the unpainted surface (see _partial_ok)
1072
+ retry_painted = False
1073
  view_mask = None
1074
  if self.settings.get("view_masks") and be.native_mask and cond["coverage"] > 0.02:
1075
  clay = (cond["cover_px"] < 0.5) & (cond["depth"] > 0)
 
1083
  layout, negative = self._layout(cond["face"])
1084
  prompt = self._view_prompt(v, cond["coverage"], len(refs), cropped=cond["cropped"], retry=k,
1085
  caption=cond.get("caption", ""), layout=layout,
1086
+ color_guide=cond.get("color_guide", False), retry_painted=retry_painted)
1087
  (vdir / "prompt.txt").write_text(prompt + (f"\n\nNEGATIVE: {negative}" if negative else ""))
1088
  req = GenRequest(image=_np_to_img(cond["image"]), prompt=prompt, width=v.camera.width, mask=view_mask,
1089
  height=v.camera.height, seed=seed + 7919 * k, task="view", references=refs,
 
1120
  post.update(chk)
1121
  post.pop("reject_reasons", None)
1122
  rescue_inside = inside
1123
+ partial = None
1124
  if not post["accepted"] and self._partial_ok(post):
1125
+ partial = self._unpainted_mask(cond, img.shape[:2])
 
 
1126
  cost = (getattr(be, "last_usage", None) or {}).get("cost")
1127
  attempts.append({"seed": seed + 7919 * k, "cost_usd": cost,
1128
  **{kk: post.get(kk) for kk in ("edge_agreement", "silhouette_iou", "painted_corr",
1129
+ "accepted")},
1130
+ **({"partial": True} if partial is not None else {})})
1131
+ if partial is not None and (partial_best is None or (post.get("painted_corr") or -1.0)
1132
+ > (partial_best[1].get("painted_corr") or -1.0)):
1133
+ # usable where nothing is painted yet; a retry may still continue the painted pattern
1134
+ partial_best = (img, {**post, "accepted": True, "partial": True}, seed + 7919 * k,
1135
+ rescue_inside, partial)
1136
  if best is None or (post.get("edge_agreement") or 0) > (best[1].get("edge_agreement") or 0):
1137
+ best = (img, post, seed + 7919 * k, rescue_inside, None)
1138
  if post["accepted"]:
1139
+ best = (img, post, seed + 7919 * k, rescue_inside, None)
1140
  break
1141
+ retry_painted = post.get("reject_reasons") == ["painted"]
1142
+ rescue_inside = None
1143
  log.warning("view %s attempt %d rejected (%s)", v.name, k + 1,
1144
  {kk: post.get(kk) for kk in ("edge_agreement", "silhouette_iou", "painted_corr",
1145
  "painted_diff")})
1146
+ if partial_best is not None and k >= int(self.settings.get("partial_retries", 1)):
1147
+ break
1148
+ if partial_best is not None and not (best is not None and best[1]["accepted"]):
1149
+ best, failed = partial_best, None # no retry continued the painted texture: keep the closest one
1150
  if failed and failed[0] == "fatal": # e.g. GPU quota exhausted: stop, keep the view planned
1151
  _emit({"view": v.name, "status": "stopped", "detail": failed[1]})
1152
  stopped = (failed[1], [t.name for t in targets[targets.index(v):]])
 
1235
  probe = planning.CoverageProbe(self.gl, self.mesh, res=res, params=self.fusion())
1236
  rel = self.relevance(res)
1237
  env = self.mode == "environment"
1238
+ ins = self.inside(res)
1239
+ if ins is not None:
1240
+ rel = np.where(ins, 0.0, rel)
1241
  imp = self.importance(res) if env else None
1242
  weights = planning.pvs_weights(imp) if imp is not None else None
1243
  before = self._cov(probe, st["alpha"], rel, weights=weights)
projtex/prompts.py CHANGED
@@ -35,7 +35,7 @@ def view_prompt(user_prompt: str, *, coverage: float, kind: str = "base", hint:
35
  lighting: str = "flat", subject: str = "", environment: bool = False,
36
  n_refs: int = 0, raw_prompt: bool = False, cropped: bool = False, retry: int = 0,
37
  focus_label: str = "head", caption: str = "", pose_hint: str = "", layout: str = "",
38
- color_guide: bool = False) -> str:
39
  if raw_prompt:
40
  return user_prompt
41
  what = "the inside of a 3D environment" if environment else ("a 3D model" + (f" of {subject}" if subject else ""))
@@ -80,7 +80,11 @@ def view_prompt(user_prompt: str, *, coverage: float, kind: str = "base", hint:
80
  parts.append("The render is a tight crop that cuts through the subject at the image border: keep exactly "
81
  "this crop, zoom level and camera distance. Do not zoom out, do not re-center and do not show "
82
  "more of the subject than the render shows.")
83
- if retry:
 
 
 
 
84
  parts.append("Most important: the output must overlay the input render exactly, pixel for pixel, as a "
85
  "texture painted onto the very same shapes.")
86
  if LIGHTING.get(lighting):
 
35
  lighting: str = "flat", subject: str = "", environment: bool = False,
36
  n_refs: int = 0, raw_prompt: bool = False, cropped: bool = False, retry: int = 0,
37
  focus_label: str = "head", caption: str = "", pose_hint: str = "", layout: str = "",
38
+ color_guide: bool = False, retry_painted: bool = False) -> str:
39
  if raw_prompt:
40
  return user_prompt
41
  what = "the inside of a 3D environment" if environment else ("a 3D model" + (f" of {subject}" if subject else ""))
 
80
  parts.append("The render is a tight crop that cuts through the subject at the image border: keep exactly "
81
  "this crop, zoom level and camera distance. Do not zoom out, do not re-center and do not show "
82
  "more of the subject than the render shows.")
83
+ if retry and retry_painted and coverage >= 0.02: # the last try matched the shapes but repainted the texture
84
+ parts.append("Most important: the already textured surfaces must keep exactly the pattern, motifs and "
85
+ "colours they show now, pixel for pixel; extend that same design onto the untextured parts "
86
+ "instead of painting a new one over it.")
87
+ elif retry:
88
  parts.append("Most important: the output must overlay the input render exactly, pixel for pixel, as a "
89
  "texture painted onto the very same shapes.")
90
  if LIGHTING.get(lighting):
projtex/qa.py CHANGED
@@ -23,16 +23,21 @@ def analyse(project, st: dict, max_suggestions: int = 6) -> dict:
23
  all_valid = uvg["valid"]
24
  tws = texel_world_size(uvg)
25
  area_all = tws * tws * all_valid
26
- under = None
27
  try:
28
  rel = project.relevance(res) > 0.05
29
  if project.mode != "environment": # judge what the side and top views can show; report the underside apart
30
  above = project.relevance(res, above=True) > 0.05
31
  under, rel = rel & ~above & all_valid, above
 
 
 
32
  except Exception: # pragma: no cover - QA must not fail because of the visibility pass
33
  rel = np.ones_like(all_valid)
34
  valid = all_valid & rel
35
  hidden = all_valid & ~rel if under is None else all_valid & ~rel & ~under
 
 
36
  area = tws * tws * valid
37
  if under is not None: # weight by how much each texel is seen (mean over side/top cameras), not just whether
38
  try:
@@ -79,10 +84,27 @@ def analyse(project, st: dict, max_suggestions: int = 6) -> dict:
79
  ua = float(area_all[under].sum())
80
  stats["underside"] = round(100.0 * ua / max(float(area_all.sum()), 1e-30), 2)
81
  stats["underside_covered"] = round(100.0 * float(area_all[under & (alpha >= 0.3)].sum()) / ua, 2) if ua else None
 
 
 
 
 
 
82
  bad = valid & ((alpha < 0.3) | ((cos < 0.3) & covered) | (dis > 0.15))
83
  suggestions = _clusters(uvg, area, bad, alpha, cos, dis, total, max_suggestions, mesh.radius)
84
  consistency = view_consistency(project) if len(project.views) <= 80 else []
85
  outliers = [o["view"] for o in consistency if o.get("outlier")]
 
 
 
 
 
 
 
 
 
 
 
86
  maps = {
87
  "coverage": _heat(alpha) * valid[..., None],
88
  "angle_quality": _heat(cos, 0.0, 1.0) * valid[..., None],
@@ -93,6 +115,7 @@ def analyse(project, st: dict, max_suggestions: int = 6) -> dict:
93
  if outliers:
94
  out["regenerate_views"] = {
95
  "views": outliers,
 
96
  "why": "these views disagree with the other views where they overlap (e.g. a face painted on the back "
97
  "or top of a head): regenerate them (`generate --views ... --force`, possibly with "
98
  "`view NAME --prompt-extra`) or disable them (`view NAME --disable`)"}
@@ -215,6 +238,18 @@ def view_consistency(project, res: int = 512, diff_thresh: float = 0.15) -> list
215
  if not cand or len(excluded) >= max(1, len(names) // 3):
216
  break
217
  excluded.add(max(cand)[1])
 
 
 
 
 
218
  for i, o in enumerate(out):
219
  o["outlier"] = i in excluded
 
 
 
 
 
 
 
220
  return out
 
23
  all_valid = uvg["valid"]
24
  tws = texel_world_size(uvg)
25
  area_all = tws * tws * all_valid
26
+ under = inside = None
27
  try:
28
  rel = project.relevance(res) > 0.05
29
  if project.mode != "environment": # judge what the side and top views can show; report the underside apart
30
  above = project.relevance(res, above=True) > 0.05
31
  under, rel = rel & ~above & all_valid, above
32
+ ins = project.inside(res)
33
+ if ins is not None: # seen only through an opening: filled with its material's colour, reported apart
34
+ inside, rel = ins & all_valid, rel & ~ins
35
  except Exception: # pragma: no cover - QA must not fail because of the visibility pass
36
  rel = np.ones_like(all_valid)
37
  valid = all_valid & rel
38
  hidden = all_valid & ~rel if under is None else all_valid & ~rel & ~under
39
+ if inside is not None:
40
+ hidden &= ~inside
41
  area = tws * tws * valid
42
  if under is not None: # weight by how much each texel is seen (mean over side/top cameras), not just whether
43
  try:
 
84
  ua = float(area_all[under].sum())
85
  stats["underside"] = round(100.0 * ua / max(float(area_all.sum()), 1e-30), 2)
86
  stats["underside_covered"] = round(100.0 * float(area_all[under & (alpha >= 0.3)].sum()) / ua, 2) if ua else None
87
+ if inside is not None and inside.any():
88
+ ia = float(area_all[inside].sum())
89
+ stats["inside"] = round(100.0 * ia / max(float(area_all.sum()), 1e-30), 2)
90
+ stats["inside_painted"] = round(100.0 * float(area_all[inside & (alpha >= 0.3)].sum()) / ia, 2) if ia else None
91
+ stats["stats_over"] += "; so is the inside of hollow parts, seen only through an opening, which gets the " \
92
+ "main colour of its material where no view painted it"
93
  bad = valid & ((alpha < 0.3) | ((cos < 0.3) & covered) | (dis > 0.15))
94
  suggestions = _clusters(uvg, area, bad, alpha, cos, dis, total, max_suggestions, mesh.radius)
95
  consistency = view_consistency(project) if len(project.views) <= 80 else []
96
  outliers = [o["view"] for o in consistency if o.get("outlier")]
97
+ shown = np.zeros_like(valid)
98
+ affected = {}
99
+ for o in consistency:
100
+ m = o.pop("_mask", None)
101
+ if m is None:
102
+ continue
103
+ idx = np.arange(res) * m.shape[0] // res # the check runs at its own resolution
104
+ m = m[idx][:, idx] & valid
105
+ affected[o["view"]] = pct(m)
106
+ shown |= m
107
+ stats["flagged_views_shown"] = pct(shown)
108
  maps = {
109
  "coverage": _heat(alpha) * valid[..., None],
110
  "angle_quality": _heat(cos, 0.0, 1.0) * valid[..., None],
 
115
  if outliers:
116
  out["regenerate_views"] = {
117
  "views": outliers,
118
+ "shown_pct": affected,
119
  "why": "these views disagree with the other views where they overlap (e.g. a face painted on the back "
120
  "or top of a head): regenerate them (`generate --views ... --force`, possibly with "
121
  "`view NAME --prompt-extra`) or disable them (`view NAME --disable`)"}
 
238
  if not cand or len(excluded) >= max(1, len(names) // 3):
239
  break
240
  excluded.add(max(cand)[1])
241
+ # where each flagged view actually shows: it dominates the blend there and differs from the other views
242
+ mo_all = np.maximum(W.max(0), 1e-30)
243
+ Wn = (W / mo_all[None]) ** params.sharpness
244
+ share = Wn / np.maximum(Wn.sum(0), 1e-30)[None]
245
+ ref = [j for j in range(len(names)) if j not in excluded]
246
  for i, o in enumerate(out):
247
  o["outlier"] = i in excluded
248
+ others = [j for j in ref if j != i]
249
+ if i not in excluded or not others:
250
+ continue
251
+ Wo = np.minimum(W[others] / np.maximum(W[others].max(0), 1e-30)[None], 1.0) ** params.sharpness
252
+ den_i = Wo.sum(0)
253
+ loo = (Wo[..., None] * C[others]).sum(0) / np.maximum(den_i, 1e-12)[..., None]
254
+ o["_mask"] = (share[i] >= 0.3) & (A[i] >= 0.5) & (den_i > 0) & (np.abs(C[i] - loo).mean(-1) > diff_thresh)
255
  return out