Compare commits

...
Author SHA1 Message Date
Alisson AnjosandClaude Opus 5.5 e4b4e4afd5 Segment pickers: brush edits the selected mask; Video Segment Picker clean_masks
- With an object selected, a brush stroke grows the selected mask it starts on (or the nearest selected one) and
  shift trims it, instead of adding a new object on top. The chip shows ✎; ↺ drops the edits (new /bfs/segpick/edit
  route). The brush stays on after applying.
- clean_masks (default 0 = off): on every frame, and on the picked frames before tracking, pieces smaller than that %
  of their subject's area join what surrounds them: stray specks where subjects touch switch color, holes are filled,
  stray background bits go away. Each subject's largest piece always stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 21:41:57 +00:00
Alisson AnjosandClaude Opus 5.5 ffde58f24c Video Segment Picker: keep painted regions, share objects across colors, shift to erase
- A brush region no longer disappears after switching color and back: added objects are kept in the frame data, and
  strokes not applied yet are applied to their color before the color or frame changes or a run starts.
- Objects added on a frame (brush, points, text) are shared by every color; each color selects its own. Removing one
  removes it for all colors.
- Shift while painting does the opposite of the tool: the brush erases from the mark, the eraser paints back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 21:27:18 +00:00
7 changed files with 303 additions and 42 deletions
+15
View File
@@ -2,6 +2,21 @@
All notable changes to **ComfyUI-BFSNodes** are documented in this file.
## [1.121.2] - 2026-11-13
### Added
- **Segment Picker / Video Segment Picker**: with an object selected, the brush edits that object's mask instead of adding a new object on top: a stroke grows the selected mask it starts on (or the nearest selected one), shift trims it. The chip shows ✎ and ↺ drops the edits. The brush stays on after ✓ Apply painted area.
- **Video Segment Picker**: `clean_masks` (default 0 = off) cleans the masks where subjects touch: on every frame, and on the picked frames before tracking, any piece smaller than that % of its subject's area joins what surrounds it (stray specks of one color on another subject switch to it, holes are filled, stray bits on the background go away). A subject's largest piece always stays; overlaps elsewhere are left as they were.
## [1.121.1] - 2026-11-13
### Fixed
- **Video Segment Picker**: a region painted with the brush no longer disappears when you switch to another color and back. Strokes not applied yet are applied to their color before the color or frame changes or a run starts, and the tool and brush size stay selected.
### Changed
- **Video Segment Picker**: objects added on a frame (brush, points, text) are shared by every color there; each color selects its own. Removing an added object removes it for every color.
- **Segment Picker / Video Segment Picker**: hold **shift** while painting to do the opposite of the tool: the brush erases from the mark, the eraser paints back. Erasing and painting over the same spot keeps whatever was painted last.
## [1.121.0] - 2026-11-12
### Added
+19 -1
View File
@@ -268,7 +268,8 @@ segmented again on load.
Hand edits (1.118.0): **🖌 Brush** paints extra area into the mark (e.g. hair SAM left
out), **➖ Remove** cuts out what SAM segments at the points you place, and **🧽 Eraser**
paints what to cut out. Each becomes a chip; unselect it to switch it off. Brush and
paints what to cut out; **shift** while painting does the opposite of the tool (the brush
erases, the eraser paints back). Each becomes a chip; unselect it to switch it off. Brush and
eraser strokes belong to the image they were painted on and are dropped when the image
changes. Fixed: clicking inside the node no longer shrinks its canvas while the side
panel is open.
@@ -290,6 +291,23 @@ propagates by itself (**auto**). The **Whole video** scrubber shows every frame
its masks, the lost stretches in red; **+ add this frame** puts any frame in the strip
to fix it. A status line with a progress bar and a log shows what runs.
Hand fixes on a picked frame (1.121.1): **🖌 Brush** paints into the active color's
mark and **shift** while painting erases from it (e.g. a stray piece of mask over a
hand). Strokes not applied yet are applied when you change color or frame, or press
Apply fix, so they are never lost. What any color adds on a frame (brush, points, text)
is shared by every color there: each color selects from the same objects, and ✕ on an
added object removes it for all of them.
With an object selected, the brush edits that object's own mask (1.121.2): a stroke grows
the selected mask it starts on (or the nearest selected one) and shift trims it, with no
new object on top. The chip shows ✎; ↺ drops the edits.
`clean_masks` cleans the subject masks where people touch (arms around each other, hands
crossing): on every frame, and on the picked frames before tracking, any piece smaller
than that % of its subject's area joins what surrounds it. A speck of blue on the red
arm becomes red, a hole in a mask is filled, a stray bit on the background goes away;
each subject's main body always stays. 0 = off; 0.5–2 is usually enough.
Outputs: `masks`, `marked` (tinted), `overlay`, `report`, and `hybrid`: the guide
with each subject drawn in its color, by `mark_style`:
- `normal map` — its MoGe normal map in gray detail, tinted (connect `normals`:
+67 -10
View File
@@ -68,10 +68,11 @@ def parse_state(text: str | None) -> dict:
if any(p["label"] for p in pts):
added.append({"id": str(e["id"]), "points": pts})
elif e.get("strokes"):
strokes = [{"r": float(st["r"]), "pts": [[float(x), float(y)] for x, y in st["pts"]]}
strokes = [{"r": float(st["r"]), "pts": [[float(x), float(y)] for x, y in st["pts"]], **({"erase": True} if st.get("erase") else {})}
for st in e["strokes"] if isinstance(st, dict) and st.get("pts")]
if strokes:
added.append({"id": str(e["id"]), "image": str(e.get("image") or ""), "strokes": strokes})
added.append({"id": str(e["id"]), "image": str(e.get("image") or ""), "strokes": strokes,
**({"target": str(e["target"])} if e.get("target") else {})})
if e.get("remove") and len(added) > n:
added[-1]["remove"] = True
selected = [str(i) for i in s.get("selected") or [] if isinstance(i, (str, int))]
@@ -84,7 +85,7 @@ def split_concepts(text: str) -> list[str]:
def entry_key(entry: dict) -> str:
"""What an added entry asks SAM for: an entry whose text or points change is segmented again."""
return json.dumps({k: entry[k] for k in ("text", "points", "strokes", "remove") if k in entry}, sort_keys=True)
return json.dumps({k: entry[k] for k in ("text", "points", "strokes", "remove", "target") if k in entry}, sort_keys=True)
# ------------------------------------------------------------------------------------------------------------ geometry
@@ -117,17 +118,19 @@ def compose(masks: list[torch.Tensor], h: int, w: int) -> tuple[torch.Tensor, to
return (stack.amax(0, keepdim=True)), stack
def paint(strokes: list[dict], h: int, w: int) -> torch.Tensor:
"""Brush strokes (relative points, radius relative to the width) as a bool mask [h, w]."""
img = Image.new("L", (w, h), 0)
def paint(strokes: list[dict], h: int, w: int, base: torch.Tensor | None = None) -> torch.Tensor:
"""Brush strokes (relative points, radius relative to the width) as a bool mask [h, w]; an "erase" stroke clears what
the strokes before it painted (shift while painting in the panel). `base`: a mask to paint on (grow / trim it)."""
img = Image.new("L", (w, h), 0) if base is None else Image.fromarray(base.cpu().numpy().astype(np.uint8) * 255)
draw = ImageDraw.Draw(img)
for st in strokes:
r = max(1., st["r"] * w)
pts = [(x * w, y * h) for x, y in st["pts"]]
fill = 0 if st.get("erase") else 255
if len(pts) > 1:
draw.line(pts, fill=255, width=round(2 * r), joint="curve")
draw.line(pts, fill=fill, width=round(2 * r), joint="curve")
for x, y in pts:
draw.ellipse([x - r, y - r, x + r, y + r], fill=255)
draw.ellipse([x - r, y - r, x + r, y + r], fill=fill)
return torch.from_numpy(np.array(img) > 127)
@@ -278,11 +281,49 @@ def ensure_entry(sess: dict, entry: dict) -> list[dict]:
def all_objects(sess: dict, state: dict) -> list[dict]:
objs = list(sess["auto"])
edits = []
for e in state["added"]:
if e.get("strokes") and e["image"] and e["image"] != sess["image_key"]:
continue # painted on another image
if e.get("target"):
edits.append(e) # brush strokes on an existing object: applied after every object exists
continue
objs += ensure_entry(sess, e)
return objs
return apply_edits(sess, objs, edits)
def apply_edits(sess: dict, objs: list[dict], edits: list[dict]) -> list[dict]:
"""Brush entries with a "target" grow (or, erase strokes, trim) that object's own mask, in order, instead of adding an
object of their own: the object keeps its id, so whatever selected it gets the edited mask."""
if not edits:
return objs
cache = sess.setdefault("edited", {})
out = []
for o in objs:
mine = [e for e in edits if e["target"] == o["id"]]
if not mine:
out.append(o)
continue
key = (o["id"], id(o["mask"]), json.dumps([e["strokes"] for e in mine]))
if key not in cache:
m = o["mask"]
for e in mine:
m = paint(e["strokes"], *m.shape, base=m)
cache[key] = {**{k: v for k, v in o.items() if k != "_file"}, "mask": m, "edits": len(mine)}
out.append(cache[key])
return out
def base_object(sess: dict, oid: str) -> dict | None:
"""An object of the session as segmented, before any brush edit: automatic or from an added entry."""
for o in sess["auto"]:
if o["id"] == oid:
return o
for have in sess["entries"].values():
for o in have["objects"]:
if o["id"] == oid:
return o
return None
# --------------------------------------------------------------------------------------------------------- panel files
@@ -314,7 +355,7 @@ def panel_object(o: dict, size: tuple[int, int], h: int, w: int) -> dict:
if "_file" not in o:
o["_file"] = _save(rgba, f"m_{o['id']}")
return {"id": o["id"], "label": o["label"], "source": o["source"], "entry": o.get("entry"), "score": o["score"],
"remove": bool(o.get("remove")), "area": round(float(o["mask"].float().mean()), 5), "box": [round(x / w, 4), round(y / h, 4), round(bw / w, 4), round(bh / h, 4)],
"remove": bool(o.get("remove")), "edits": int(o.get("edits") or 0), "area": round(float(o["mask"].float().mean()), 5), "box": [round(x / w, 4), round(y / h, 4), round(bw / w, 4), round(bh / h, 4)],
"mask": o["_file"]}
@@ -431,6 +472,22 @@ def _routes():
size = _preview_size(h, w)
return web.json_response({"objects": [panel_object(o, size, h, w) for o in objs]})
@PromptServer.instance.routes.post("/bfs/segpick/edit")
async def edit(request):
"""One object with every brush edit made on it ({session, target, edits: [brush entries]}; none = as segmented)."""
body = await request.json()
sess = _SESSIONS.get(str(body.get("session") or ""))
if sess is None:
return web.json_response({"error": "This image is no longer in memory: run the node once, then paint again."}, status=404)
target = str(body.get("target") or "")
o = base_object(sess, target)
if o is None:
return web.json_response({"error": f"Object {target} is not in this image any more: run the node once."}, status=404)
edits = [e for e in parse_state(json.dumps({"added": body.get("edits") or []}))["added"]
if e.get("target") == target and (not e["image"] or e["image"] == sess["image_key"])]
h, w = sess["frame"].shape[1:3]
return web.json_response({"objects": [panel_object(o2, _preview_size(h, w), h, w) for o2 in apply_edits(sess, [o], edits)]})
_routes()
+88 -7
View File
@@ -689,6 +689,74 @@ def parse_frames(text):
return out
def clean_subject_masks(masks, max_frac):
"""{subject: bool [n, h, w]} -> the same, cleaned frame by frame. Every piece of the label map (a subject's
disconnected bit, or background enclosed by subjects) smaller than max_frac of its subject's area joins the label
that surrounds it most: a speck of subject 2 on subject 1's arm becomes subject 1, a hole in a mask is filled. Each
subject's largest piece always stays, and background touching the frame border is never filled."""
import cv2
import numpy as np
keys = list(masks)
if not keys:
return masks
n, h, w = next(iter(masks.values())).shape
out = {k: masks[k].clone().cpu() for k in keys}
ring = np.ones((3, 3), np.uint8)
for i in range(n):
lab = np.zeros((h, w), np.int32)
for k, key in enumerate(keys):
lab[masks[key][i].cpu().numpy()] = k + 1
orig = lab.copy()
area = {k + 1: int((lab == k + 1).sum()) for k in range(len(keys))}
if not any(area.values()):
continue
changed = False
# subjects' stray pieces first, then the holes they leave or had
for L in [k + 1 for k in range(len(keys))] + [0]:
cnt, comp, stats, _ = cv2.connectedComponentsWithStats((lab == L).astype(np.uint8), connectivity=8 if L else 4)
if cnt <= 1:
continue
biggest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA])) if L else -1
for c in range(1, cnt):
x, y, bw, bh, a = (int(v) for v in stats[c])
if c == biggest or (L == 0 and (x == 0 or y == 0 or x + bw >= w or y + bh >= h)):
continue
x0, y0, x1, y1 = max(0, x - 1), max(0, y - 1), min(w, x + bw + 1), min(h, y + bh + 1)
piece = comp[y0:y1, x0:x1] == c
around = lab[y0:y1, x0:x1][cv2.dilate(piece.astype(np.uint8), ring).astype(bool) & ~piece]
if not around.size:
continue
vals, counts = np.unique(around, return_counts=True)
into = int(vals[np.argmax(counts)])
if into == L:
continue
ref = area.get(L) if L else area.get(into, 0)
if not ref or a > max_frac * ref:
continue
lab[y0:y1, x0:x1][piece] = into
changed = True
if changed: # only the moved pixels change: where two subjects overlapped elsewhere, both keep it
moved = torch.from_numpy(lab != orig)
for k, key in enumerate(keys):
out[key][i] = (out[key][i] & ~moved) | (torch.from_numpy(lab == k + 1) & moved)
return {k: out[k].to(masks[k].device) for k in keys}
def shared_state(frame_state, key):
"""One subject's selection on a frame, with the objects every subject added there: a region painted or found while
another color was active can be selected by this one too. Its own entries win an id clash (older states)."""
own = dict(frame_state.get(key) or {})
added = list(own.get("added") or [])
ids = {str(e.get("id")) for e in added if isinstance(e, dict)}
for k, v in frame_state.items():
for e in (v.get("added") or []) if k != key and isinstance(v, dict) else []:
if isinstance(e, dict) and str(e.get("id")) not in ids:
ids.add(str(e.get("id")))
added.append(e)
own["added"] = added
return own
def find_connected_picture_count(prompt, unique_id):
"""Inspects the prompt workflow graph to find downstream nodes consuming this node's output,
and returns the maximum index of connected picture inputs (e.g. 2 if only picture_1 and picture_2 are linked)."""
@@ -787,6 +855,12 @@ class BFSVideoSegmentPicker:
"tooltip": "Hatching line and contour thickness multiplier. 1 matches the training format."}),
"hatch_spacing": ("FLOAT", {"default": 1.0, "min": 0.25, "max": 4.0, "step": 0.05,
"tooltip": "Hatching line spacing multiplier. 1 matches the training format."}),
"clean_masks": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.1,
"tooltip": "Clean the subject masks on every frame (the picked frames before tracking too): any piece "
"smaller than this, in % of the subject's area, joins what surrounds it. Stray specks of one "
"color on another subject (e.g. where hands and arms cross) go to that subject, holes inside "
"a mask are filled, and a subject's main body is never removed. 0 = off; 0.5-2 is usually "
"enough. Too high can swallow a small real part, like a fingertip of the other person."}),
}
return {"required": req, "optional": opt, "hidden": {**t.get("hidden", {}), "prompt": "PROMPT"}}
@@ -805,14 +879,14 @@ class BFSVideoSegmentPicker:
hybrid_grow=0.08, free_after=FREE_MODES[0], pose_region="person outline",
pose_gray_opacity=1.0, max_subjects="auto", molmo_descriptions="", propagate_scope="fill only empty masks",
propagation_method="Molmo2 (classic)", unique_id=None, prompt=None, protect_mask=None, edge_feather=0.01,
hatch_width=1.0, hatch_spacing=1.0, **kw):
hatch_width=1.0, hatch_spacing=1.0, clean_masks=0.0, **kw):
try:
return self._pick(image, sam3_model, sam3_clip, concepts, threshold, max_per_concept, min_area, mark_color, mark_opacity,
selection, track, strip_frames, normals, normals_convention, hybrid_base, subject, mark_style,
molmo_clip, pose_keypoint, hybrid_grow, unique_id, pose_region, pose_gray_opacity,
max_subjects, molmo_descriptions, propagate_scope, propagation_method, prompt,
protect_mask=protect_mask, edge_feather=edge_feather, point_model=point_model,
hatch_width=hatch_width, hatch_spacing=hatch_spacing)
hatch_width=hatch_width, hatch_spacing=hatch_spacing, clean_masks=clean_masks)
finally:
self._free(sam3_model, free_after, point_model or molmo_clip)
@@ -885,7 +959,7 @@ class BFSVideoSegmentPicker:
pose_keypoint, hybrid_grow, unique_id, pose_region="person outline", pose_gray_opacity=1.0,
max_subjects="auto", molmo_descriptions="", propagate_scope="fill only empty masks",
propagation_method="Molmo2 (classic)", prompt=None, protect_mask=None, edge_feather=0.01,
point_model=None, hatch_width=1.0, hatch_spacing=1.0):
point_model=None, hatch_width=1.0, hatch_spacing=1.0, clean_masks=0.0):
st = Status(unique_id)
n = int(image.shape[0])
st(f"start: {n} frames", 0.0, log=True)
@@ -936,7 +1010,7 @@ class BFSVideoSegmentPicker:
sid, sess = sp.session_for((sam3_model, sam3_clip), frame, spec)
chosen_all = set()
for sj in subjects:
state = sp.parse_state(json.dumps(states.get(str(i), {}).get(str(sj), {})))
state = sp.parse_state(json.dumps(shared_state(states.get(str(i), {}), str(sj))))
objects = sp.all_objects(sess, state)
by_id = {o["id"]: o for o in objects}
chosen = [by_id[k] for k in state["selected"] if k in by_id]
@@ -945,7 +1019,7 @@ class BFSVideoSegmentPicker:
cut = torch.stack(cut).any(0) if cut else torch.zeros(h, w, dtype=torch.bool)
mask, _ = sp.compose([o["mask"] & ~cut for o in chosen if not o.get("remove")], h, w)
own[sj][i] = mask.reshape(h, w).float()
lock_state = sp.parse_state(json.dumps(states.get(str(i), {}).get("lock", {})))
lock_state = sp.parse_state(json.dumps(shared_state(states.get(str(i), {}), "lock")))
lock_objects = sp.all_objects(sess, lock_state)
lock_by_id = {o["id"]: o for o in lock_objects}
lock_chosen = [lock_by_id[k] for k in lock_state["selected"] if k in lock_by_id]
@@ -954,12 +1028,16 @@ class BFSVideoSegmentPicker:
lock_cut = torch.stack(lock_cut).any(0) if lock_cut else torch.zeros(h, w, dtype=torch.bool)
lock_mask, _ = sp.compose([o["mask"] & ~lock_cut for o in lock_chosen if not o.get("remove")], h, w)
lock_mask = lock_mask.reshape(h, w) > 0.5
if clean_masks > 0: # tracking starts from the cleaned picks
cleaned = clean_subject_masks({sj: (own[sj][i] > 0.5)[None] for sj in subjects}, clean_masks / 100.0)
for sj in subjects:
own[sj][i] = cleaned[sj][0].float()
selected_people = torch.stack([own[sj][i] > 0.5 for sj in subjects]).any(0)
if bool(lock_mask.any()) and mask_overlap(lock_mask, selected_people)[0] >= 0.7:
st(f"frame {i}: lock overlaps the selected subject; pick the object by text or add negative points", None, log=True)
lock_mask = torch.zeros_like(lock_mask)
own_locked[i] = lock_mask
objects = sp.all_objects(sess, sp.parse_state(json.dumps({"added": [a for st in states.get(str(i), {}).values() for a in st.get("added", [])]})))
objects = sp.all_objects(sess, sp.parse_state(json.dumps({"added": shared_state(states.get(str(i), {}), "1")["added"]})))
panels.append(sp.panel_data(sid, sess, objects))
overlays.append(sp.overlay(frame, objects, chosen_all))
cuts = detect_cuts(image) if n > 1 else []
@@ -997,7 +1075,7 @@ class BFSVideoSegmentPicker:
ckey = ("v4", vkey, sj, json.dumps(mine, sort_keys=True), tuple(cuts), bool(track),
(point_model if propagation_method == "MolmoPoint-8B" else molmo_clip) is not None,
float(threshold),
propagate_scope, propagation_method, active_frame)
propagate_scope, propagation_method, active_frame, float(clean_masks))
if (not segment_only and propagate_scope not in ("all frames", "fill only empty masks") and previous is not None
and len(previous) > 2 and previous[2] == mine):
per_subject[sj], report_s[sj] = previous[0].clone(), [dict(r) for r in previous[1]]
@@ -1261,6 +1339,9 @@ class BFSVideoSegmentPicker:
rep[:fix_frame] = [dict(r) for r in previous[1][:fix_frame]]
per_subject[sj] = m > 0.5; report_s[sj] = rep
_cache_track_result(ckey, vkey, sj, m, rep, mine, keep_last=not segment_only)
if clean_masks > 0:
per_subject.update(clean_subject_masks({sj: per_subject[sj] > 0.5 for sj in subjects}, clean_masks / 100.0))
st(f"cleaned the masks: pieces under {clean_masks:g}% of a subject joined their surroundings", None, log=True)
masks = torch.stack(list(per_subject.values())).amax(0).float()
anchors = sorted({i for sj in subjects for i in strip if bool(own[sj][i].any())})
report = [{"frame": i, "subjects": {str(sj): report_s[sj][i]["source"] for sj in subjects}} for i in range(n)]
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "bfsnodes"
description = "ComfyUI nodes for video workflows: SAM 3 segmentation and Molmo2 tracking; MiniMax H3 character swapping and sampling; shot planning and looping; LTX video controls and identity transfer; model loaders, LoRA tools, and image/video utilities."
version = "1.121.0"
version = "1.121.2"
license = {file = "LICENSE"}
dependencies = [
"torch",
+87 -18
View File
@@ -3,6 +3,9 @@
* see it, click to select it. Objects the automatic pass missed are added by text (a SAM 3 prompt), by points
* (keep / exclude), segmented at once by /bfs/segpick/add, or painted with the brush (to grow the mark by hand).
* ➖ Remove (points) and 🧽 Eraser (brush) make the same kinds of objects, cut out of the mark instead of added to it.
* Shift while painting does the opposite of the mode: with the brush it erases the mark, with the eraser it paints back.
* With an object selected, the brush edits that object's own mask (grows it, shift trims it) instead of adding a new one:
* the selected object the stroke starts on, otherwise the nearest selected one. ↺ on its chip drops the edits.
*
* All state lives in the hidden `selection` STRING widget (JSON: selected ids and the added entries), so a workflow
* saves, reloads and shares the selection. The objects themselves come from the node's last run (onExecuted).
@@ -109,11 +112,11 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
const loaded = new Map(); // object id -> loadMask result (not reactive: canvases and big arrays)
const ready = ref(0); // bumps when masks finish loading, to redraw
const hover = ref(null);
const mode = ref("select"); // "select" | "points" | "brush" | "remove" (points) | "eraser" (brush)
const mode = ref(options.mode || "select"); // "select" | "points" | "brush" | "remove" (points) | "eraser" (brush)
const points = ref([]); // the point entry being built
const strokes = ref([]); // the brush entry being built: [{r, pts: [[x, y], ...]}], relative to the image
const brush = ref(.02); // brush radius, relative to the image width
let painting = false, cursor = null;
const brush = ref(options.brush || .02); // brush radius, relative to the image width
let painting = false, cursor = null, shiftHeld = false;
const text = ref("");
const busy = ref(""); const error = ref("");
const stageEl = ref(null); const canvasEl = ref(null);
@@ -133,6 +136,7 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
function setMode(m) {
if (m !== mode.value) { points.value = []; strokes.value = []; } // unfinished points / strokes mean something else in another mode
mode.value = m; hover.value = null;
options.onMode?.(m);
}
async function loadAll() {
@@ -174,8 +178,9 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
}
g.globalAlpha = 1;
if (!brushing()) return;
g.strokeStyle = g.fillStyle = cutting() ? "rgba(20,20,20,.6)" : "rgba(255,0,255,.55)"; g.lineCap = g.lineJoin = "round";
g.lineCap = g.lineJoin = "round";
for (const st of strokes.value) {
g.strokeStyle = g.fillStyle = cutting() !== !!st.erase ? "rgba(20,20,20,.6)" : "rgba(255,0,255,.55)";
const [x0, y0] = st.pts[0];
g.beginPath(); g.arc(x0 * W, y0 * H, st.r * W, 0, 2 * Math.PI); g.fill();
if (st.pts.length < 2) continue;
@@ -184,7 +189,7 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
g.stroke();
}
if (cursor) {
g.lineWidth = Math.max(1, W / 500); g.strokeStyle = "#fff";
g.lineWidth = Math.max(1, W / 500); g.strokeStyle = cutting() !== shiftHeld ? "#f43f5e" : "#fff";
g.beginPath(); g.arc(cursor.x * W, cursor.y * H, brush.value * W, 0, 2 * Math.PI); g.stroke();
}
}
@@ -220,7 +225,9 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
if (brushing()) {
canvasEl.value.setPointerCapture(e.pointerId);
painting = true;
strokes.value = [...strokes.value, { r: +brush.value.toFixed(4), pts: [[+p.x.toFixed(4), +p.y.toFixed(4)]] }];
const st = { r: +brush.value.toFixed(4), pts: [[+p.x.toFixed(4), +p.y.toFixed(4)]] };
if (e.shiftKey) st.erase = true; // shift: the opposite of the mode (erase with the brush, paint back with the eraser)
strokes.value = [...strokes.value, st];
return;
}
const o = objectAt(p);
@@ -230,7 +237,7 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
}
function onMove(e) {
if (data.value && brushing()) {
cursor = at(e);
cursor = at(e); shiftHeld = e.shiftKey;
const pts = strokes.value.at(-1)?.pts, last = pts?.at(-1);
// a point every ~quarter radius keeps the stroke smooth without bloating the saved JSON
if (painting && Math.hypot(cursor.x - last[0], cursor.y - last[1]) > brush.value / 4) pts.push([+cursor.x.toFixed(4), +cursor.y.toFixed(4)]);
@@ -241,20 +248,44 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
hover.value = objectAt(at(e))?.id ?? null;
}
// every brush edit on one object, in order: the video picker passes the ones every color made on the frame
const editsOf = target => (options.allAdded?.() || state.added).filter(a => a.target === target);
async function editObject(target, edits) {
const r = await api.fetchApi("/bfs/segpick/edit", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session: data.value.session, target, edits }) });
const j = await r.json();
if (!r.ok || j.error) throw new Error(j.error || r.statusText);
const o = j.objects[0];
data.value = { ...data.value, objects: data.value.objects.map(x => (x.id === o.id ? o : x)) };
options.onObjects?.(data.value.objects);
await loadAll();
}
async function add(entry) {
if (!data.value) { error.value = "Run the node first (▶ Segment)."; return; }
if (entry.target) {
busy.value = "Painting on the mask…"; error.value = "";
try {
await editObject(entry.target, [...editsOf(entry.target).filter(a => a.id !== entry.id), entry]);
state.added = [...state.added.filter(a => a.id !== entry.id), entry];
state.next = Math.max(state.next, Number(entry.id.match(/(\d+)$/)?.[1] || 0) + 1);
if (!isSel(entry.target)) state.selected = [...state.selected, entry.target];
save();
} catch (err) { error.value = String(err.message || err); } finally { busy.value = ""; }
return;
}
busy.value = entry.text ? `Finding “${entry.text}”…` : entry.strokes ? "Adding the painted area…" : "Segmenting the points…"; error.value = "";
try {
const r = await api.fetchApi("/bfs/segpick/add", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session: data.value.session, entry }) });
const j = await r.json();
if (!r.ok || j.error) throw new Error(j.error || r.statusText);
if (!j.objects.length) { error.value = entry.text ? `Nothing found for “${entry.text}”: try other words or a lower threshold.` : "The points gave an empty mask."; return; }
if (!j.objects.length) { error.value = entry.text ? `Nothing found for “${entry.text}”: try other words or a lower threshold.` : entry.strokes ? "The painted area is empty." : "The points gave an empty mask."; return; }
state.added = [...state.added.filter(a => a.id !== entry.id), entry];
state.next = Math.max(state.next, Number(entry.id.match(/(\d+)$/)?.[1] || 0) + 1);
data.value = { ...data.value, objects: [...data.value.objects.filter(o => o.entry !== entry.id), ...j.objects] };
state.selected = [...state.selected, ...j.objects.map(o => o.id).filter(id => !isSel(id))]; // what you add is what you want
save();
options.onObjects?.(data.value.objects);
await loadAll();
} catch (err) {
error.value = String(err.message || err);
@@ -266,12 +297,46 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
await add(cutting() ? { id: entryId("r"), remove: true, points: points.value } : { id: entryId("p"), points: points.value });
if (!error.value) setMode("select");
};
const addBrush = async () => {
if (!strokes.value.length) return;
const stroke = { image: data.value.image_key, strokes: strokes.value };
await add(cutting() ? { id: entryId("e"), remove: true, ...stroke } : { id: entryId("b"), ...stroke });
if (!error.value) setMode("select");
// The strokes become up to two entries, in painting order: what they add (cut strokes painted as holes in it) and what
// they cut (painting strokes after a cut painted back), so erasing and painting over the same spot ends as painted last.
// The object a brush stroke edits: the selected one it starts on (the smallest), otherwise the nearest selected one
const targetFor = st => {
const [x, y] = st.pts[0];
const sel = objects.value.filter(o => isSel(o.id) && !o.remove && loaded.get(o.id));
if (!sel.length) return null;
const under = sel.filter(o => { const m = loaded.get(o.id); return m.hit[Math.min(m.h - 1, Math.floor(y * m.h)) * m.w + Math.min(m.w - 1, Math.floor(x * m.w))]; });
if (under.length) return under.sort((a, b) => a.area - b.area)[0].id;
const dist = o => { const [bx, by, bw, bh] = o.box || [0, 0, 1, 1]; return Math.hypot(Math.max(bx - x, 0, x - bx - bw), Math.max(by - y, 0, y - by - bh)); };
return sel.sort((a, b) => dist(a) - dist(b))[0].id;
};
const commitStrokes = async () => {
const all = strokes.value;
if (!all.length || !data.value) return;
const target = mode.value === "brush" ? targetFor(all[0]) : null;
if (target) { // one edit of that object's mask, the strokes in painting order
strokes.value = [];
await add({ id: entryId("g"), image: data.value.image_key, target, strokes: all.map(st => (st.erase ? { r: st.r, pts: st.pts, erase: true } : { r: st.r, pts: st.pts })) });
return;
}
const cutOf = st => cutting() !== !!st.erase;
const part = cutPart => {
const k = all.findIndex(st => cutOf(st) === cutPart);
return k < 0 ? [] : all.slice(k).map(st => (cutOf(st) === cutPart ? { r: st.r, pts: st.pts } : { r: st.r, pts: st.pts, erase: true }));
};
const keep = part(false), cut = part(true), image = data.value.image_key;
strokes.value = [];
if (keep.length) await add({ id: entryId("b"), image, strokes: keep });
if (cut.length) await add({ id: entryId("e"), remove: true, image, strokes: cut });
};
const addBrush = () => commitStrokes(); // the brush stays on: painting usually goes on
async function resetEdits(o) { // ↺: the object as segmented, without the brush edits
const ids = editsOf(o.id).map(a => a.id);
state.added = state.added.filter(a => !ids.includes(a.id));
save();
ids.forEach(id => options.onRemoveEntry?.(id, { keepSelection: true }));
busy.value = "Restoring the mask…"; error.value = "";
try { await editObject(o.id, []); } catch (err) { error.value = String(err.message || err); } finally { busy.value = ""; }
}
function removeEntry(entryId) {
state.added = state.added.filter(a => a.id !== entryId);
const gone = objects.value.filter(o => o.entry === entryId).map(o => o.id);
@@ -279,6 +344,8 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
if (data.value) data.value = { ...data.value, objects: data.value.objects.filter(o => o.entry !== entryId) };
gone.forEach(i => loaded.delete(i));
save(); ready.value++;
options.onRemoveEntry?.(entryId);
if (data.value) options.onObjects?.(data.value.objects);
}
async function runNode() {
@@ -307,6 +374,7 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
nextTick(() => { relayout(); loadAll(); });
},
reload: () => { Object.assign(state, parseState(io.get())); ready.value++; },
commit: commitStrokes, // paint strokes not added yet become entries (the video picker calls it before it remounts)
});
let ro;
@@ -352,9 +420,9 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
] : null,
brushing() ? [
h("input", { type: "range", min: .003, max: .1, step: .001, value: brush.value, title: "Brush size",
onInput: e => { brush.value = +e.target.value; } }),
onInput: e => { brush.value = +e.target.value; options.onBrush?.(brush.value); } }),
h("button", { class: "pri", disabled: !strokes.value.length || !!busy.value, onClick: addBrush },
`${cutting() ? "✓ Erase painted area" : "✓ Add painted area"} (${strokes.value.length})`),
`${cutting() ? "✓ Erase painted area" : "✓ Apply painted area"} (${strokes.value.length})`),
h("button", { disabled: !strokes.value.length, onClick: () => { strokes.value = strokes.value.slice(0, -1); } }, "Undo"),
h("button", { disabled: !strokes.value.length, onClick: () => { strokes.value = []; } }, "Clear"),
] : null,
@@ -370,8 +438,8 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
hovered ? h("div", { class: "tip" }, `${hovered.id} · ${hovered.label}${hovered.score != null ? ` · ${hovered.score}` : ""}`) : null,
mode.value === "points" ? h("div", { class: "tip" }, "click = keep · right-click or shift+click = exclude · click a point to remove it") : null,
mode.value === "remove" ? h("div", { class: "tip" }, "click on what to cut out of the mark · right-click or shift+click = keep it in · then ✓ Remove this") : null,
mode.value === "brush" ? h("div", { class: "tip" }, "paint what the mark should also cover · then ✓ Add painted area") : null,
mode.value === "eraser" ? h("div", { class: "tip" }, "paint what to cut out of the mark · then ✓ Erase painted area") : null,
mode.value === "brush" ? h("div", { class: "tip" }, "paint to grow the selected mask the stroke starts on (or the nearest) · shift = erase from it · then ✓ Apply painted area") : null,
mode.value === "eraser" ? h("div", { class: "tip" }, "paint what to cut out of the mark · shift = paint back · then ✓ Erase painted area") : null,
] : h("div", { class: "empty" }, ["Connect an image and press ▶ Segment (or run the workflow).", h("br"),
"Then click objects to select them; add what was missed by text or 📍 points."])),
busy.value ? h("div", { class: "hint" }, busy.value) : null,
@@ -381,7 +449,8 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
onClick: () => toggle(o.id), onPointerenter: () => { hover.value = o.id; }, onPointerleave: () => { hover.value = null; },
}, [
h("span", { class: "sw", style: `background:${rgb(colorOf(o.id))}` }),
`${o.remove ? "➖ " : ""}${o.id} ${o.label}`,
`${o.remove ? "➖ " : ""}${o.id} ${o.label}${o.edits ? " ✎" : ""}`,
o.edits ? h("span", { class: "x", title: "undo the brush edits on this object", onClick: e => { e.stopPropagation(); resetEdits(o); } }, "↺") : null,
o.entry ? h("span", { class: "x", title: "remove this added object", onClick: e => { e.stopPropagation(); removeEntry(o.entry); } }, "×") : null,
]))) : null,
d ? h("div", { class: "hint" }, "The outputs follow the selection on the next run.") : null,
+26 -5
View File
@@ -2,6 +2,7 @@
* BFS Video Segment Picker: a strip with every frame of the batch on top; click a frame to show it in the image
* Segment Picker's canvas (the same Panel, one selection per frame). The strip marks each frame: ✓ picked by hand,
* ↔ tracked from a picked frame, ⚠ lost or nothing. State: the hidden `selection` widget, {"frames": {"i": selection}}.
* What a color adds on a frame (brush, points, text) is shared by every color there: each one selects from the same objects.
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
@@ -157,8 +158,11 @@ app.registerExtension({
// the image picker's Panel for the active frame, remounted when the frame changes
let panelApp = null, handle = {};
const ui = { mode: "select", brush: .02 }; // the tool and brush size survive a color or frame change
const panelHost = document.createElement("div");
panelHost.className = "host";
// paint strokes not applied yet are applied (to the color they were painted with) before the panel changes
const switchPanel = async change => { try { await handle.commit?.(); } catch { /* shown in the panel */ } change(); mountPanel(); };
const mountPanel = () => {
panelApp?.unmount(); panelHost.innerHTML = ""; handle = {};
const i = run.data?.indices?.[active.value] ?? active.value; // the real frame index of the strip entry
@@ -176,8 +180,23 @@ app.registerExtension({
if (JSON.stringify(frames.v[String(i)]?.[sj]?.selected || []) !== before) autoPropagate();
},
expose: o => Object.assign(handle, o),
}, { showEraser: false, showRemove: false, autoPointOnClick: sj === "lock", entryPrefix: sj === "lock" ? "lock_" : "",
maskColor: sj === "lock" ? [251, 191, 36] : null }) });
}, { showEraser: false, showRemove: false, autoPointOnClick: sj === "lock",
// ids stay unique on the frame, since every color sees the objects the others added
entryPrefix: sj === "lock" ? "lock_" : sj === "1" ? "" : `s${sj}_`,
maskColor: sj === "lock" ? [251, 191, 36] : null,
mode: sj === "lock" ? "select" : ui.mode, brush: ui.brush,
onMode: m => { if (sj !== "lock") ui.mode = m; }, onBrush: b => { ui.brush = b; },
// added objects live in the run's frame data too, so the next mount (another color, back here) still has them
onObjects: objs => { const fd = run.data?.frames?.[active.value]; if (fd) run.data.frames[active.value] = { ...fd, objects: objs }; },
allAdded: () => Object.values(frames.v[String(i)] || {}).flatMap(v => (v && v.added) || []),
onRemoveEntry: (id, how) => { // an added object goes for every color: no color keeps selecting a gone object
for (const v of Object.values(frames.v[String(i)] || {})) {
if (!v || typeof v !== "object") continue;
if (v.added) v.added = v.added.filter(a => a.id !== id);
if (v.selected && !how?.keepSelection) v.selected = v.selected.filter(s => s !== id && !s.startsWith(`${id}.`));
}
save();
} }) });
panelApp.mount(el);
const d = run.data?.frames?.[active.value];
if (d) setTimeout(() => handle.executed?.(d), 0);
@@ -206,11 +225,13 @@ app.registerExtension({
wrapQueue();
const segmentOnly = async () => { // a picking run: the new frame is segmented, nothing is tracked
busy.value = "segmenting…";
try { await handle.commit?.(); } catch { /* shown in the panel */ }
try { const p = await app.graphToPrompt(); await api.queuePrompt(0, p, { partialExecutionTargets: [String(node.id)] }); }
catch (e) { busy.value = String(e.message || e); }
};
const propagate = async () => {
busy.value = "propagating…";
try { await handle.commit?.(); } catch { /* shown in the panel */ }
try {
const p = await app.graphToPrompt();
propagating.add(String(node.id));
@@ -264,7 +285,7 @@ app.registerExtension({
h("div", { class: "subj" }, [...SUBJECTS.map(([k, col, name]) => h("span", {
class: ["t", subject.value === k ? "on" : ""], style: subject.value === k ? { color: col } : null,
title: `Subject ${k} (${name}) = Picture ${k}: clicks select this subject`,
onClick: () => { if (subject.value !== k) { subject.value = k; mountPanel(); } } },
onClick: () => { if (subject.value !== k) switchPanel(() => { subject.value = k; }); } },
[
h("i", { style: { background: col } }),
h("span", { style: "color:#fff" }, `${k} · ${name}`),
@@ -276,7 +297,7 @@ app.registerExtension({
])), h("span", {
class: ["t", "lock", subject.value === "lock" ? "on" : ""],
title: "Everything outside the selected subject is already protected. Use this padlock only for an object inside that subject's mask; click it to segment with SAM 3.",
onClick: () => { if (subject.value !== "lock") { subject.value = "lock"; mountPanel(); } },
onClick: () => { if (subject.value !== "lock") switchPanel(() => { subject.value = "lock"; }); },
}, [
h("span", null, "🔒"),
hasPicks("lock") ? h("span", {
@@ -320,7 +341,7 @@ app.registerExtension({
const [cls, mark] = status(i);
return h("div", { class: ["fr", i === active.value ? "on" : ""], key: i, title: run.data.report?.[i]?.source || "",
style: { backgroundImage: `url("${fileUrl(d.image)}")` },
onClick: () => { if (active.value !== i) { active.value = i; focusFrame = run.data.indices?.[i] ?? i; save(); mountPanel(); } } },
onClick: () => { if (active.value !== i) switchPanel(() => { active.value = i; focusFrame = run.data.indices?.[i] ?? i; save(); }); } },
[h("span", { class: "b" }, `f${run.data.indices?.[i] ?? i}`), mark ? h("span", { class: ["s", cls] }, mark) : null,
h("span", { class: "dots" }, pickedSubjects(i).map(k => k === "lock"
? h("span", { title: "Protected object picked", style: "color:#fbbf24;background:#181818;border-radius:3px;padding:0 2px" }, "🔒")