@@ -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 }
0 commit comments