Skip to content

Commit 978e8cb

Browse files
committed
fix(sam-picker): real per-mask thumbnails + correct widget position (v1.14.0)
- Python: serialize 3 candidate masks as PNGs to temp dir, ship via ui.mask_thumbs - JS: load/composite real masks per-thumbnail (was flat-red fillRect on all 3) - JS: fix doubled offset that pushed picker ~600px below node body - Remove IS_CHANGED=NaN that force-rerun SAM on every queue - Visually verified via Playwright screenshot
1 parent 093deaa commit 978e8cb

4 files changed

Lines changed: 174 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,34 @@
22

33
All notable changes to ComfyUI-CustomNodePacks are documented here.
44

5+
## [1.14.0] – 2026-05-04
6+
7+
### Fixed
8+
9+
- **SAM Multi-Mask Picker — render real per-mask thumbnails**: the JS
10+
widget previously called an empty `buildMaskThumbnails()` stub and then
11+
drew a flat red `fillRect` over all three thumbnails identically, so
12+
every candidate looked the same regardless of what SAM produced. The
13+
Python node now serializes the three candidate masks as grayscale PNGs
14+
to ComfyUI's temp directory (stable input-hash filenames) and ships
15+
them on the UI payload as `mask_thumbs`. The JS now loads each PNG via
16+
`/view?...&type=temp` into `this.maskImages[i]` and composites the
17+
real mask onto each thumbnail using an offscreen canvas with
18+
`globalCompositeOperation="source-in"` plus a per-thumb tint.
19+
- **SAM Multi-Mask Picker — widget no longer renders ~600 px below the
20+
node body**: the `addCustomWidget` registration was forwarding
21+
`node.pos[0]` and `node.pos[1] + widgetY` into the inner draw/mouse
22+
handlers, but LiteGraph already translates the canvas context to the
23+
node origin before invoking widget callbacks — the result was a
24+
doubled offset that pushed the picker off the bottom of the node.
25+
Registration now passes widget-local coordinates (`0, widgetY` for
26+
draw; `pos[0], pos[1]` for mouse), so the picker lives inside the
27+
node body where it belongs.
28+
- **SAM Multi-Mask Picker — stop force-rerunning SAM on every queue**:
29+
removed `IS_CHANGED → NaN`, which was unconditionally invalidating
30+
the cache and re-running SAM on identical inputs. Standard cache
31+
semantics now apply.
32+
533
## [1.13.1] – 2026-05-04
634

735
### Fixed

js/sam_multi_mask_picker.js

Lines changed: 85 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,34 @@ class SamMultiMaskPickerWidget {
150150
}
151151
}
152152

153-
// ── Build mask overlay thumbnails from all_masks output ──────────
154-
buildMaskThumbnails(allMasksData) {
155-
// allMasksData would be (3, H, W) tensor data
156-
// In practice, JS doesn't have direct tensor access,
157-
// so we render a visual approximation on re-execution.
158-
// The real mask data is sent to the widget via node output images.
153+
// ── Build mask overlay thumbnails from server-rendered PNGs ──────
154+
// The Python node renders each of the 3 SAM candidate masks to a PNG in
155+
// ComfyUI's temp dir and passes the file infos via the ``ui`` payload.
156+
// We download them as <img> elements once (browser caches by filename)
157+
// and store them in maskImages[] so draw() can composite each thumbnail.
158+
receiveMaskThumbnails(thumbInfos) {
159+
if (!Array.isArray(thumbInfos)) return;
160+
for (let i = 0; i < Math.min(3, thumbInfos.length); i++) {
161+
const info = thumbInfos[i];
162+
if (!info || !info.filename) {
163+
this.maskImages[i] = null;
164+
continue;
165+
}
166+
const url = `/view?filename=${encodeURIComponent(info.filename)}`
167+
+ `&subfolder=${encodeURIComponent(info.subfolder || "")}`
168+
+ `&type=${encodeURIComponent(info.type || "temp")}`
169+
+ `&t=${Date.now()}`;
170+
const img = new Image();
171+
img.onload = () => {
172+
this.maskImages[i] = img;
173+
this._needsRedraw = true;
174+
this.node.setDirtyCanvas(true, true);
175+
};
176+
img.onerror = () => {
177+
this.maskImages[i] = null;
178+
};
179+
img.src = url;
180+
}
159181
}
160182

161183
// ── Draw the picker widget ───────────────────────────────────────
@@ -224,10 +246,33 @@ class SamMultiMaskPickerWidget {
224246
}
225247
ctx.drawImage(this.sourceImage, sx, sy, sw, sh, tx, ty, thumbW, thumbH);
226248

227-
// Overlay: semi-transparent colored tint for "mask area"
228-
const overlayAlpha = 0.5;
229-
ctx.fillStyle = `rgba(${theme.overlayColor[0]}, ${theme.overlayColor[1]}, ${theme.overlayColor[2]}, ${overlayAlpha})`;
230-
ctx.fillRect(tx, ty, thumbW, thumbH);
249+
// Real mask overlay if Python sent the per-mask PNG. The PNG is
250+
// a grayscale L-mode image where pixel value == mask probability.
251+
// We composite by drawing the mask in a colored tint using
252+
// ``destination-in``-style masking via an offscreen canvas.
253+
const maskImg = this.maskImages[i];
254+
if (maskImg && maskImg.complete && maskImg.naturalWidth > 0) {
255+
// Match the same letterboxed crop the source image used,
256+
// so alignment matches frame-for-frame.
257+
const off = document.createElement("canvas");
258+
off.width = thumbW;
259+
off.height = thumbH;
260+
const octx = off.getContext("2d");
261+
// Draw the grayscale mask scaled to thumb size with the
262+
// same source crop as the base image (mask is full-res).
263+
octx.drawImage(maskImg, sx, sy, sw, sh, 0, 0, thumbW, thumbH);
264+
// Tint: replace the RGB with the overlay color, keep mask
265+
// luminance as alpha multiplier.
266+
octx.globalCompositeOperation = "source-in";
267+
octx.fillStyle = `rgba(${theme.overlayColor[0]}, ${theme.overlayColor[1]}, ${theme.overlayColor[2]}, 0.55)`;
268+
octx.fillRect(0, 0, thumbW, thumbH);
269+
ctx.drawImage(off, tx, ty);
270+
} else {
271+
// No real mask available yet -> faint hint that this card
272+
// is selectable, but no fake-red full overlay anymore.
273+
ctx.fillStyle = `rgba(${theme.overlayColor[0]}, ${theme.overlayColor[1]}, ${theme.overlayColor[2]}, 0.10)`;
274+
ctx.fillRect(tx, ty, thumbW, thumbH);
275+
}
231276

232277
ctx.restore();
233278
} else {
@@ -371,12 +416,16 @@ app.registerExtension({
371416
if (!this._mecPicker) return;
372417
this._mecPicker.updateFromNode();
373418
this._mecPicker.tryLoadSourceImage();
419+
// ctx is already translated to the node origin by LiteGraph,
420+
// so widget-local coordinates start at (0, widgetY). Passing
421+
// absolute node.pos[*] here doubled the offset and pushed
422+
// the widget far below the node body.
374423
return this._mecPicker.draw(
375424
ctx,
376-
node.pos[0],
377-
node.pos[1] + widgetY,
425+
0,
426+
widgetY,
378427
widgetWidth,
379-
node.pos[1] + widgetY,
428+
widgetY,
380429
widgetHeight
381430
);
382431
},
@@ -385,8 +434,10 @@ app.registerExtension({
385434
},
386435
mouse: (event, pos, node) => {
387436
if (!this._mecPicker) return false;
388-
const localX = pos[0] + node.pos[0];
389-
const localY = pos[1] + node.pos[1];
437+
// ``pos`` is already in widget-local coordinates that match
438+
// the (0, widgetY) origin we draw from above.
439+
const localX = pos[0];
440+
const localY = pos[1];
390441

391442
if (event.type === "mousemove") {
392443
this._mecPicker.onMouseMove(localX, localY);
@@ -421,30 +472,37 @@ app.registerExtension({
421472
return false;
422473
};
423474

424-
// Parse output scores on execution complete
475+
// Parse output scores + receive per-mask thumbnail PNGs on execution
425476
const onExecuted = nodeType.prototype.onExecuted;
426477
nodeType.prototype.onExecuted = function (message) {
427478
if (onExecuted) onExecuted.apply(this, arguments);
428479

429480
if (this._mecPicker && message) {
430-
// Try to extract scores from the output
481+
// Scores can arrive either as ui.scores (preferred) or via the
482+
// legacy output.scores path. Try both.
431483
try {
432-
const scoresOutput = message.output?.scores;
433-
if (scoresOutput && typeof scoresOutput === "string") {
434-
const parsed = JSON.parse(scoresOutput);
435-
if (Array.isArray(parsed) && parsed.length >= 3) {
436-
this._mecPicker.scores = parsed.slice(0, 3);
437-
}
484+
let parsed = null;
485+
if (Array.isArray(message.scores) && message.scores.length > 0) {
486+
parsed = JSON.parse(message.scores[0]);
487+
} else if (message.output?.scores) {
488+
const raw = message.output.scores;
489+
parsed = JSON.parse(typeof raw === "string" ? raw : raw[0]);
438490
} else if (message.output?.ui?.scores) {
439-
const parsed = JSON.parse(message.output.ui.scores);
440-
if (Array.isArray(parsed)) {
441-
this._mecPicker.scores = parsed.slice(0, 3);
442-
}
491+
parsed = JSON.parse(message.output.ui.scores);
492+
}
493+
if (Array.isArray(parsed) && parsed.length >= 3) {
494+
this._mecPicker.scores = parsed.slice(0, 3);
443495
}
444496
} catch (_) {
445497
// Score parsing optional
446498
}
447499

500+
// Real mask thumbnails sent by the Python side.
501+
const thumbs = message.mask_thumbs;
502+
if (Array.isArray(thumbs) && thumbs.length > 0) {
503+
this._mecPicker.receiveMaskThumbnails(thumbs);
504+
}
505+
448506
this._mecPicker._needsRedraw = true;
449507
this.setDirtyCanvas(true, true);
450508
}

nodes/sam_multi_mask_picker.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,25 @@
1313
from __future__ import annotations
1414

1515
import gc
16+
import hashlib
1617
import json
1718
import logging
19+
import os
1820
from typing import Optional
1921

2022
import numpy as np
2123
import torch
2224

25+
try:
26+
import folder_paths # type: ignore
27+
except Exception: # pragma: no cover - only at import-time outside ComfyUI
28+
folder_paths = None # type: ignore
29+
30+
try:
31+
from PIL import Image as _PILImage # type: ignore
32+
except Exception: # pragma: no cover
33+
_PILImage = None # type: ignore
34+
2335
from .model_manager import (
2436
MODEL_REGISTRY,
2537
get_or_load_model,
@@ -130,10 +142,12 @@ def INPUT_TYPES(cls):
130142
},
131143
}
132144

133-
@classmethod
134-
def IS_CHANGED(cls, **kwargs):
135-
"""Force re-execution when selected_index changes (JS widget interaction)."""
136-
return float("nan")
145+
# NOTE: IS_CHANGED removed intentionally. Returning float("nan") forced SAM
146+
# to re-run on every prompt queue, even when only ``selected_index`` changed,
147+
# which is wasteful (SAM inference is heavy). With normal caching, ComfyUI
148+
# re-executes only when an input value changes -- which still includes
149+
# ``selected_index`` -- so the JS picker continues to work, but identical
150+
# re-queues are now properly cached. See docs/inpaint-suite-guide.md.
137151

138152
def pick_mask(
139153
self,
@@ -315,7 +329,48 @@ def pick_mask(
315329
"device": device,
316330
}, indent=2)
317331

318-
return (selected_mask_t, all_masks_t, idx, scores_str, info)
332+
# ── Render mask thumbnails for the JS picker ──────────────
333+
# Without this, the JS widget falls back to a flat red overlay on every
334+
# thumbnail (it has no access to torch tensors). We save each mask as a
335+
# grayscale PNG into ComfyUI's temp dir and return them via the ``ui``
336+
# payload so the JS ``onExecuted`` hook can load and composite them.
337+
mask_thumb_files: list[dict] = []
338+
if folder_paths is not None and _PILImage is not None:
339+
try:
340+
temp_dir = folder_paths.get_temp_directory()
341+
os.makedirs(temp_dir, exist_ok=True)
342+
# Stable hash of the inputs so reusing the same prompts hits the
343+
# browser cache instead of re-uploading three PNGs every run.
344+
key_blob = (
345+
img_np.tobytes()[:16384]
346+
+ str(points_list).encode()
347+
+ str(box_np.tolist() if box_np is not None else None).encode()
348+
+ model_name.encode()
349+
)
350+
digest = hashlib.sha256(key_blob).hexdigest()[:12]
351+
for i in range(3):
352+
mask_arr = (np.clip(masks_np[i], 0.0, 1.0) * 255.0).astype(np.uint8)
353+
fname = f"mec_sam_mask_{digest}_{i}.png"
354+
fpath = os.path.join(temp_dir, fname)
355+
_PILImage.fromarray(mask_arr, mode="L").save(fpath, compress_level=1)
356+
mask_thumb_files.append({
357+
"filename": fname,
358+
"subfolder": "",
359+
"type": "temp",
360+
})
361+
except Exception as exc:
362+
logger.warning("[MEC] failed to write mask thumbnails: %s", exc)
363+
mask_thumb_files = []
364+
365+
ui_payload = {
366+
"mask_thumbs": mask_thumb_files,
367+
"scores": [scores_str],
368+
"selected_index": [idx],
369+
}
370+
return {
371+
"ui": ui_payload,
372+
"result": (selected_mask_t, all_masks_t, idx, scores_str, info),
373+
}
319374

320375
@staticmethod
321376
def _detect_model_type(model_name: str) -> str:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "comfyui-customnodepacks"
33
description = "Custom node packs for ComfyUI: FolderIncrementer (auto-versioning) + MaskEditControl (pinpoint mask editing with SAM 2.1 / SAM 3, per-axis erode/expand, point editor, bbox tools, video mask propagation)."
4-
version = "1.13.2"
4+
version = "1.14.0"
55
license = { file = "MIT-License" }
66
requires-python = ">=3.10"
77
readme = "README.md"

0 commit comments

Comments
 (0)