2026-07-10 15:58:03 -04:00
import json
2026-06-25 10:34:09 -04:00
import numpy as np
import torch
from PIL import Image , ImageDraw , ImageEnhance , ImageFont
from typing_extensions import override
from comfy_api . latest import ComfyExtension , io
from comfy_extras . color_util import hex_to_rgb , normalize_palette , readable_color
_PREVIEW_LONG_EDGE = 1024
_PREVIEW_DIM = 0.25
def pixels_to_fractions ( box : dict , width : int , height : int ) - > dict :
w = width or 1
h = height or 1
return {
" x " : box . get ( " x " , 0 ) / w ,
" y " : box . get ( " y " , 0 ) / h ,
" w " : box . get ( " width " , 0 ) / w ,
" h " : box . get ( " height " , 0 ) / h ,
}
def fractions_to_pixels ( box : dict , width : int , height : int ) - > dict :
x , y = box . get ( " x " , 0.0 ) , box . get ( " y " , 0.0 )
w , h = box . get ( " w " , 0.0 ) , box . get ( " h " , 0.0 )
if w < 0 :
x , w = x + w , - w
if h < 0 :
y , h = y + h , - h
return {
" x " : round ( x * width ) ,
" y " : round ( y * height ) ,
" width " : round ( w * width ) ,
" height " : round ( h * height ) ,
}
def fractions_to_bbox_frame ( boxes : list , width : int , height : int ) - > list :
pixels = [
fractions_to_pixels ( box , width , height )
for box in boxes
if isinstance ( box , dict )
]
return [ pixels ] if pixels else [ ]
def _font ( size : int ) :
try :
return ImageFont . load_default ( size )
except Exception :
return ImageFont . load_default ( )
def _wrap ( draw , text : str , font , max_w : float ) - > list [ str ] :
lines = [ ]
for para in text . split ( " \n " ) :
line = " "
for word in para . split ( ) :
test = word if not line else line + " " + word
if line and draw . textlength ( test , font = font ) > max_w :
lines . append ( line )
line = word
else :
line = test
lines . append ( line )
return lines
def _bg_from_image ( image ) - > Image . Image | None :
if image is None :
return None
try :
arr = ( image [ 0 ] . detach ( ) . cpu ( ) . numpy ( ) * 255 ) . clip ( 0 , 255 ) . astype ( np . uint8 )
return Image . fromarray ( arr )
except Exception :
return None
def render_preview ( regions , width , height , bg = None ) :
if bg is not None :
iw , ih = bg . size
long_edge = max ( iw , ih ) or 1
scale = min ( 1.0 , _PREVIEW_LONG_EDGE / long_edge )
rw , rh = max ( 1 , round ( iw * scale ) ) , max ( 1 , round ( ih * scale ) )
base = bg . convert ( " RGB " ) . resize ( ( rw , rh ) , Image . LANCZOS )
base = ImageEnhance . Brightness ( base ) . enhance ( _PREVIEW_DIM )
img = base . convert ( " RGBA " )
else :
long_edge = max ( width , height ) or 1
scale = min ( 1.0 , _PREVIEW_LONG_EDGE / long_edge )
rw , rh = max ( 1 , round ( width * scale ) ) , max ( 1 , round ( height * scale ) )
grey = round ( _PREVIEW_DIM * 128 )
img = Image . new ( " RGBA " , ( rw , rh ) , ( grey , grey , grey , 255 ) )
overlay = Image . new ( " RGBA " , ( rw , rh ) , ( 0 , 0 , 0 , 0 ) )
draw = ImageDraw . Draw ( overlay )
fs = max ( 10 , round ( rh / 64 ) )
font = _font ( fs )
tag_font = _font ( max ( 9 , fs - 2 ) )
line_h = fs + 2
for i , region in enumerate ( regions ) :
if not isinstance ( region , dict ) :
continue
palette = [ c for c in ( region . get ( " palette " ) or [ ] ) if c ]
r , g , b = hex_to_rgb ( palette [ 0 ] ) if palette else ( 140 , 140 , 140 )
x1 = max ( 0 , min ( rw , round ( region . get ( " x " , 0 ) * rw ) ) )
y1 = max ( 0 , min ( rh , round ( region . get ( " y " , 0 ) * rh ) ) )
x2 = max ( 0 , min ( rw , round ( ( region . get ( " x " , 0 ) + region . get ( " w " , 0 ) ) * rw ) ) )
y2 = max ( 0 , min ( rh , round ( ( region . get ( " y " , 0 ) + region . get ( " h " , 0 ) ) * rh ) ) )
if x2 < x1 :
x1 , x2 = x2 , x1
if y2 < y1 :
y1 , y2 = y2 , y1
draw . rectangle ( [ x1 , y1 , x2 , y2 ] , outline = ( r , g , b , 255 ) , width = 2 )
swatches = palette [ : 5 ]
if swatches and ( x2 - x1 ) > 2 :
sh = max ( 5 , fs / / 2 )
seg = ( x2 - x1 ) / len ( swatches )
for p , hexc in enumerate ( swatches ) :
sx = x1 + round ( p * seg )
draw . rectangle ( [ sx , y1 , x1 + round ( ( p + 1 ) * seg ) , y1 + sh ] , fill = hex_to_rgb ( hexc ) )
etype = " text " if region . get ( " type " ) == " text " else " obj "
tag = str ( i + 1 ) . zfill ( 2 )
tw = draw . textlength ( tag , font = tag_font )
draw . rectangle ( [ x1 , y1 , x1 + tw + 6 , y1 + fs + 2 ] , fill = ( r , g , b , 255 ) )
tag_fill = ( 0 , 0 , 0 , 255 ) if ( 0.299 * r + 0.587 * g + 0.114 * b ) > 140 else ( 255 , 255 , 255 , 255 )
draw . text ( ( x1 + 3 , y1 + 1 ) , tag , fill = tag_fill , font = tag_font )
body = region . get ( " desc " , " " ) or " "
if etype == " text " and region . get ( " text " ) :
body = ' " %s " %s ' % ( region [ " text " ] , " — " + body if body else " " )
if body and ( x2 - x1 ) > 8 :
ty = y1 + fs + 5
for line in _wrap ( draw , body , font , x2 - x1 - 8 ) :
if ty > y2 :
break
draw . text ( ( x1 + 4 , ty ) , line , fill = readable_color ( ( r , g , b ) ) + ( 255 , ) , font = font )
ty + = line_h
composed = Image . alpha_composite ( img , overlay ) . convert ( " RGB " )
arr = np . asarray ( composed , dtype = np . float32 ) / 255.0
return torch . from_numpy ( arr ) . unsqueeze ( 0 )
def boxes_to_regions ( boxes , width : int , height : int ) - > list :
regions : list = [ ]
if not isinstance ( boxes , list ) :
return regions
for box in boxes :
if not isinstance ( box , dict ) :
continue
meta = box . get ( " metadata " )
meta = meta if isinstance ( meta , dict ) else { }
regions . append ( {
* * pixels_to_fractions ( box , width , height ) ,
" type " : meta . get ( " type " , " obj " ) ,
" text " : meta . get ( " text " , " " ) ,
" desc " : meta . get ( " desc " , " " ) ,
" palette " : meta . get ( " palette " , [ ] ) ,
} )
return regions
2026-07-10 15:58:03 -04:00
def normalize_incoming_boxes ( bboxes ) - > list :
if isinstance ( bboxes , dict ) :
frame = [ bboxes ]
elif not isinstance ( bboxes , list ) or not bboxes :
frame = [ ]
elif isinstance ( bboxes [ 0 ] , dict ) :
frame = bboxes
else :
frame = bboxes [ 0 ] if isinstance ( bboxes [ 0 ] , list ) else [ ]
boxes = [ ]
for box in frame :
if not isinstance ( box , dict ) :
continue
norm = {
" x " : box . get ( " x " , 0 ) ,
" y " : box . get ( " y " , 0 ) ,
" width " : box . get ( " width " , 0 ) ,
" height " : box . get ( " height " , 0 ) ,
}
meta = box . get ( " metadata " )
if isinstance ( meta , dict ) :
norm [ " metadata " ] = meta
boxes . append ( norm )
return boxes
def _looks_like_element ( box : dict ) - > bool :
bbox = box . get ( " bbox " )
return isinstance ( bbox , ( list , tuple ) ) and len ( bbox ) == 4
def _looks_like_bbox ( box : dict ) - > bool :
return all ( key in box for key in ( " x " , " y " , " width " , " height " ) )
def elements_to_boxes ( elements : list , width : int , height : int ) - > list :
boxes = [ ]
for element in elements :
if not isinstance ( element , dict ) :
continue
bbox = element . get ( " bbox " )
if not ( isinstance ( bbox , ( list , tuple ) ) and len ( bbox ) == 4 ) :
raise ValueError ( " bboxes element is missing a valid ' bbox ' [ymin, xmin, ymax, xmax] " )
try :
ymin , xmin , ymax , xmax = ( float ( v ) / 1000.0 for v in bbox )
except ( TypeError , ValueError ) :
raise ValueError ( " bboxes element ' bbox ' must contain four numbers " )
etype = " text " if element . get ( " type " ) == " text " else " obj "
boxes . append ( {
" x " : round ( min ( xmin , xmax ) * width ) ,
" y " : round ( min ( ymin , ymax ) * height ) ,
" width " : round ( abs ( xmax - xmin ) * width ) ,
" height " : round ( abs ( ymax - ymin ) * height ) ,
" metadata " : {
" type " : etype ,
" text " : element . get ( " text " , " " ) if etype == " text " else " " ,
" desc " : element . get ( " desc " , " " ) ,
" palette " : element . get ( " color_palette " , [ ] ) or [ ] ,
} ,
} )
return boxes
def boxes_from_input ( data , width : int , height : int ) - > list :
if data is None :
return [ ]
if isinstance ( data , str ) :
text = data . strip ( )
if not text :
return [ ]
try :
data = json . loads ( text )
except ( ValueError , TypeError ) as exc :
raise ValueError ( f " bboxes string input is not valid JSON: { exc } " ) from exc
if isinstance ( data , dict ) :
if _looks_like_element ( data ) :
return elements_to_boxes ( [ data ] , width , height )
if _looks_like_bbox ( data ) :
return normalize_incoming_boxes ( data )
raise ValueError (
" bboxes dict must be a bounding box (x, y, width, height) or an element (with a ' bbox ' ) "
)
if not isinstance ( data , list ) :
raise ValueError (
" bboxes input must be bounding boxes, elements, or a JSON string, "
f " got { type ( data ) . __name__ } "
)
if not data :
return [ ]
first = data [ 0 ]
if isinstance ( first , list ) :
return normalize_incoming_boxes ( data )
if isinstance ( first , dict ) :
if _looks_like_element ( first ) :
return elements_to_boxes ( data , width , height )
if _looks_like_bbox ( first ) :
return normalize_incoming_boxes ( data )
raise ValueError (
" bboxes items must be bounding boxes (x, y, width, height) or elements (with a ' bbox ' ) "
)
raise ValueError (
f " bboxes list must contain bounding boxes or elements, got { type ( first ) . __name__ } "
)
2026-06-25 10:34:09 -04:00
def _norm_bbox ( region : dict ) - > list [ int ] :
def grid ( value : float ) - > int :
return max ( 0 , min ( 1000 , round ( value * 1000 ) ) )
x , y = region . get ( " x " , 0.0 ) , region . get ( " y " , 0.0 )
w , h = region . get ( " w " , 0.0 ) , region . get ( " h " , 0.0 )
ymin , xmin , ymax , xmax = grid ( y ) , grid ( x ) , grid ( y + h ) , grid ( x + w )
if ymin > ymax :
ymin , ymax = ymax , ymin
if xmin > xmax :
xmin , xmax = xmax , xmin
return [ ymin , xmin , ymax , xmax ]
def build_elements ( regions : list ) - > list :
elements = [ ]
for region in regions :
if not isinstance ( region , dict ) :
continue
etype = " text " if region . get ( " type " ) == " text " else " obj "
element = { " type " : etype }
element [ " bbox " ] = _norm_bbox ( region )
if etype == " text " :
element [ " text " ] = region . get ( " text " , " " )
element [ " desc " ] = region . get ( " desc " , " " )
palette = normalize_palette ( region . get ( " palette " , [ ] ) )
if palette :
element [ " color_palette " ] = palette [ : 5 ]
elements . append ( element )
return elements
class CreateBoundingBoxes ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) :
editor_state = io . BoundingBoxes . Input (
" editor_state " ,
socketless = False ,
tooltip = " Draw bounding boxes and set each box type, text, description, color palette. Start with background element first and foreground last. " ,
)
return io . Schema (
node_id = " CreateBoundingBoxes " ,
display_name = " Create Bounding Boxes " ,
category = " utilities " ,
description = " Draw bounding boxes in a canvas. Outputs Ideogram prompt elements, pixel-space bounding boxes, and a preview image. " ,
inputs = [
io . Image . Input (
" background " ,
optional = True ,
tooltip = " Optional image used as background in the canvas and preview. " ,
) ,
2026-07-10 15:58:03 -04:00
io . MultiType . Input (
" bboxes " ,
[ io . BoundingBox , io . Array , io . String ] ,
optional = True ,
tooltip = " Bounding boxes, elements, or a JSON string to initialize the canvas. A new upstream value initializes the canvas; edits made on the canvas take priority and are kept until the upstream value changes again. " ,
) ,
2026-06-25 10:34:09 -04:00
io . Int . Input ( " width " , default = 1024 , min = 64 , max = 16384 , step = 16 ,
tooltip = " Width of the canvas and the pixel grid for the bounding boxes. " ) ,
io . Int . Input ( " height " , default = 1024 , min = 64 , max = 16384 , step = 16 ,
tooltip = " Height of the canvas and the pixel grid for the bounding boxes. " ) ,
editor_state ,
2026-07-10 15:58:03 -04:00
io . BoundingBoxes . Input (
" last_incoming " ,
optional = True ,
tooltip = " Internal state managed by the canvas: the upstream bboxes value that last initialized it. Leave empty to re-initialize the canvas from the bboxes input on the next run. " ,
) ,
2026-06-25 10:34:09 -04:00
] ,
outputs = [
io . Image . Output ( display_name = " preview " ) ,
io . BoundingBox . Output ( display_name = " bboxes " ) ,
io . Array . Output ( display_name = " elements " ) ,
] ,
2026-07-10 15:58:03 -04:00
is_output_node = True ,
2026-06-25 10:34:09 -04:00
is_experimental = True ,
)
@classmethod
2026-07-10 15:58:03 -04:00
def execute ( cls , width , height , editor_state = None , last_incoming = None , background = None , bboxes = None ) - > io . NodeOutput :
incoming = boxes_from_input ( bboxes , width , height )
applied = last_incoming if isinstance ( last_incoming , list ) else [ ]
upstream_changed = bool ( incoming ) and incoming != applied
source = incoming if upstream_changed else ( editor_state or [ ] )
regions = boxes_to_regions ( source , width , height )
2026-06-25 10:34:09 -04:00
preview = render_preview ( regions , width , height , _bg_from_image ( background ) )
2026-07-10 15:58:03 -04:00
ui = { " dims " : [ width , height ] }
if incoming :
ui [ " input_bboxes " ] = incoming
2026-06-25 10:34:09 -04:00
return io . NodeOutput (
preview ,
fractions_to_bbox_frame ( regions , width , height ) ,
build_elements ( regions ) ,
2026-07-10 15:58:03 -04:00
ui = ui ,
2026-06-25 10:34:09 -04:00
)
class BoundingBoxesExtension ( ComfyExtension ) :
@override
async def get_node_list ( self ) - > list [ type [ io . ComfyNode ] ] :
return [ CreateBoundingBoxes ]
async def comfy_entrypoint ( ) - > BoundingBoxesExtension :
return BoundingBoxesExtension ( )