Spaces:
Running
Running
Round 7 fixes: flagged-view share, partial-view retry, inside of hollow parts, no straight-down gap views
Browse files- app/llms.txt +5 -2
- app/server.py +11 -4
- projtex/planning.py +20 -4
- projtex/project.py +99 -13
- projtex/prompts.py +6 -2
- 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.
|
|
|
|
|
|
|
| 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)
|
|
|
|
| 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 |
-
|
| 163 |
-
|
|
|
|
|
|
|
| 164 |
why = [f"coverage {cov} %" if cov < 75 else "",
|
| 165 |
-
f"
|
|
|
|
| 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 ({
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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,
|
| 1060 |
if post["accepted"]:
|
| 1061 |
-
best = (img, post, seed + 7919 * k, rescue_inside,
|
| 1062 |
break
|
| 1063 |
-
|
|
|
|
| 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
|