33import asyncio
44import base64
55import io
6+ import logging
67import os
78import time
8- from typing import Dict , List , Union , Sequence
9+ from pathlib import Path
10+ from typing import Dict , List , Sequence , Union
911
1012import httpx
1113from PIL import Image , ImageDraw
@@ -85,6 +87,65 @@ def draw_points(
8587 return image
8688
8789
90+ def draw_detections (
91+ image : Image .Image ,
92+ detections : List [Dict [str , Union [List [float ], str ]]],
93+ box_width : int = 2 ,
94+ text_offset : int = 10 ,
95+ ) -> Image .Image :
96+ draw = ImageDraw .Draw (image )
97+ width , height = image .size
98+ for det in detections :
99+ box = det .get ("box_2d" )
100+ if not isinstance (box , list ) or len (box ) != 4 :
101+ continue
102+ x1 , y1 , x2 , y2 = box
103+ x1 = int (float (x1 ) * width / 1000 )
104+ x2 = int (float (x2 ) * width / 1000 )
105+ y1 = int (float (y1 ) * height / 1000 )
106+ y2 = int (float (y2 ) * height / 1000 )
107+ draw .rectangle ((x1 , y1 , x2 , y2 ), outline = "cyan" , width = box_width )
108+ label = str (det .get ("label" , "" ))
109+ if label :
110+ draw .text ((x1 + text_offset , y1 - text_offset ), label , fill = "cyan" )
111+ return image
112+
113+
114+ _MASK_COLORS = [
115+ (255 , 0 , 0 ),
116+ (0 , 200 , 255 ),
117+ (0 , 255 , 127 ),
118+ (255 , 165 , 0 ),
119+ (186 , 85 , 211 ),
120+ ]
121+
122+
123+ def overlay_masks (
124+ image : Image .Image ,
125+ masks : List [Dict [str , Union [str , int ]]],
126+ alpha : int = 110 ,
127+ ) -> Image .Image :
128+ """Blend segmentation masks onto the provided image."""
129+ if not masks :
130+ return image
131+ base = image .convert ("RGBA" )
132+ for idx , mask_entry in enumerate (masks ):
133+ mask_b64 = mask_entry .get ("mask_png" )
134+ if not mask_b64 :
135+ continue
136+ try :
137+ mask = Image .open (io .BytesIO (base64 .b64decode (mask_b64 ))).convert ("L" )
138+ except Exception as exc : # pragma: no cover - debug helper
139+ console .print (f"[red]Failed to decode mask for visualization: { exc } [/red]" )
140+ continue
141+ if mask .size != base .size :
142+ mask = mask .resize (base .size , Image .NEAREST )
143+ color = _MASK_COLORS [idx % len (_MASK_COLORS )]
144+ overlay = Image .new ("RGBA" , base .size , color + (alpha ,))
145+ base = Image .composite (overlay , base , mask )
146+ return base .convert ("RGB" )
147+
148+
88149class PointingGeminiSAM2Client :
89150 def __init__ (self , host : str = "localhost" , port : int = 7100 ):
90151 """Initialize client with host and port."""
@@ -155,17 +216,19 @@ async def predict_async(
155216 )
156217
157218 result = response .json ()
158- entries = []
159- count = "unknown"
160- example = None
219+ entries : List [Dict ] = []
161220 if isinstance (result , dict ):
162221 entries = result .get ("results" ) or []
163- count = len (entries )
164- example = entries [0 ].get ("prompt" ) if entries else None
165- if not entries :
166- logging .warning (
167- "[PointingClient] No detections returned (HTTP 200)"
168- "; check server logs for details." )
222+ elif isinstance (result , list ):
223+ entries = result
224+ count = len (entries )
225+ example = None
226+ if entries and isinstance (entries [0 ], dict ):
227+ example = entries [0 ].get ("prompt" )
228+ if not entries :
229+ logging .warning (
230+ "[PointingClient] No detections returned (HTTP 200); "
231+ "check server logs for details." )
169232 extra = f", sample='{ example } '" if example else ""
170233 console .print (
171234 f"[green]Server request completed successfully[/green]"
@@ -181,13 +244,19 @@ def predict(
181244 points : bool = True ,
182245 segmentation : bool = False ,
183246 detection : bool = False ,
247+ save_visualizations : bool = False ,
248+ viz_dir : str = "spot_pointing_outputs_cli" ,
184249 ) -> Dict :
185250 """Synchronous wrapper for predict_async."""
186251 with Timer (enable_print = False ) as t :
187- result = asyncio .run (self .predict_async (
188- images , prompts , points , segmentation , detection
189- ))
252+ result = asyncio .run (
253+ self .predict_async (images , prompts , points , segmentation , detection ))
190254 result ["timings" ]["total" ] = t .elapsed_time
255+ if save_visualizations :
256+ try :
257+ _save_client_visualizations (result .get ("results" , []), viz_dir )
258+ except Exception as exc : # pragma: no cover - debug helper
259+ console .print (f"[red]Failed to save pointing visualizations: { exc } [/red]" )
191260 return result
192261
193262
@@ -213,6 +282,8 @@ def predict(
213282 points : bool = typer .Option (True , help = "Whether to return point coordinates" ),
214283 segmentation : bool = typer .Option (False , help = "Whether to perform segmentation" ),
215284 detection : bool = typer .Option (False , help = "Whether to perform detection instead of pointing" ),
285+ save_viz : bool = typer .Option (False , "--save-viz" , help = "Save annotated results to disk" ),
286+ viz_dir : str = typer .Option ("spot_pointing_outputs_cli" , "--viz-dir" , help = "Directory for saved visualizations" ),
216287):
217288 """Run predictions using the PointingGeminiSAM2 service."""
218289 try :
@@ -224,7 +295,13 @@ def predict(
224295 raise typer .BadParameter ("Must provide at least one image (-i) and one prompt (-p)" )
225296
226297 client = PointingGeminiSAM2Client (host = host , port = port )
227- result = client .predict (image_paths , prompts , points = points , segmentation = segmentation , detection = detection )
298+ result = client .predict (image_paths ,
299+ prompts ,
300+ points = points ,
301+ segmentation = segmentation ,
302+ detection = detection ,
303+ save_visualizations = save_viz ,
304+ viz_dir = viz_dir )
228305
229306 # Print results
230307 console .rule ("[bold blue]Results" )
@@ -236,10 +313,18 @@ def predict(
236313 console .print (f"\n [yellow]Image { img_idx + 1 } , Prompt { prompt_idx + 1 } : '{ prompt } '[/yellow]" )
237314 if res .get ("points" ):
238315 console .print (f"Points coordinates: { res ['points' ]} " , style = "cyan" )
239- if res .get ("detections" ):
316+ boxes = res .get ("detections" )
317+ if not boxes and res .get ("boxes" ):
318+ boxes = [{
319+ "box_2d" : box ,
320+ "label" : res .get ("prompt" , "" )
321+ } for box in res ["boxes" ]]
322+ if boxes :
240323 console .print ("\n Bounding boxes:" , style = "cyan" )
241- for i , box in enumerate (res [ "detections" ] , 1 ):
324+ for i , box in enumerate (boxes , 1 ):
242325 console .print (f"Box { i } : { box ['box_2d' ]} ({ box ['label' ]} )" , style = "cyan" )
326+ if res .get ("masks" ):
327+ console .print (f"Segmentation masks: { len (res ['masks' ])} " , style = "magenta" )
243328
244329 # Print timing
245330 console .rule ("[bold blue]Timing" )
@@ -257,3 +342,40 @@ def predict(
257342
258343if __name__ == "__main__" :
259344 app ()
345+
346+
347+ def _save_client_visualizations (entries : Union [List [Dict ], Dict ], directory : str ) -> None :
348+ if isinstance (entries , dict ):
349+ entries = [entries ]
350+ if not entries :
351+ console .print ("[yellow]No results to visualize.[/yellow]" )
352+ return
353+ Path (directory ).mkdir (parents = True , exist_ok = True )
354+ for entry in entries :
355+ image_b64 = entry .get ("image" )
356+ if not image_b64 :
357+ continue
358+ try :
359+ image = Image .open (io .BytesIO (base64 .b64decode (image_b64 ))).convert ("RGB" )
360+ except Exception as exc :
361+ console .print (f"[red]Failed to decode image for visualization: { exc } [/red]" )
362+ continue
363+ if entry .get ("points" ):
364+ image = draw_points (image , entry ["points" ])
365+ detections = entry .get ("detections" )
366+ if not detections and entry .get ("boxes" ):
367+ detections = [{
368+ "box_2d" : box ,
369+ "label" : entry .get ("prompt" , "" )
370+ } for box in entry ["boxes" ]]
371+ if detections :
372+ image = draw_detections (image , detections )
373+ masks = entry .get ("masks" ) or []
374+ if masks :
375+ image = overlay_masks (image , masks )
376+ timestamp = time .strftime ("%Y%m%d-%H%M%S" )
377+ prompt = entry .get ("prompt" , "prompt" ).replace (" " , "_" )
378+ image_idx = entry .get ("image_index" , 0 )
379+ filename = Path (directory ) / f"client_pointing_{ timestamp } _{ prompt } _{ image_idx } .png"
380+ image .save (filename )
381+ console .print (f"Saved pointing visualization to { filename } " , style = "yellow" )
0 commit comments