3232}
3333
3434from app .services .exceptions import InvalidInputError , OmniParserError
35- from app .core .config import ALLOWED_IMAGE_TYPES , MAX_IMAGE_SIZE_BYTES
35+ from app .core .config import ALLOWED_IMAGE_TYPES , MAX_IMAGE_SIZE_BYTES , FLORENCE_BATCH_SIZE
3636
3737logger = logging .getLogger (__name__ )
3838
@@ -246,6 +246,114 @@ async def initialize(self):
246246 def _map_element_type (self , raw_type : str ) -> str :
247247 return TYPE_MAPPING .get (raw_type .lower (), "unknown" )
248248
249+ def _is_probable_oom (self , err : Exception ) -> bool :
250+ msg = str (err ).lower ()
251+
252+ # Retry only when the error looks like an out-of-memory error.
253+ return (
254+ isinstance (err , MemoryError )
255+ or "out of memory" in msg
256+ or "cuda out of memory" in msg
257+ or "not enough memory" in msg
258+ or "std::bad_alloc" in msg
259+ or ("mps" in msg and "memory" in msg )
260+ )
261+
262+ def florence_batch_caption (self , batch , elements ):
263+ # Run one Florence caption pass for this batch and write captions back to elements by index.
264+
265+ if not batch :
266+ return
267+ images = [crop for _ , crop in batch ]
268+ prompts = ["<CAPTION>" ] * len (images )
269+
270+ batch_ids = [idx for idx , _ in batch ]
271+ self .logger .info (
272+ f"FLORENCE_BATCH_START size={ len (batch )} element_ids={ batch_ids } "
273+ )
274+
275+
276+ inputs = self .processor (
277+ text = prompts ,
278+ images = images ,
279+ return_tensors = "pt"
280+ ).to (self .device )
281+
282+ self .logger .info (
283+ f"FLORENCE_INPUT_SHAPE pixel_values={ tuple (inputs ['pixel_values' ].shape )} "
284+ f"input_ids={ tuple (inputs ['input_ids' ].shape )} "
285+ )
286+
287+ with torch .no_grad ():
288+ generated_ids = self .caption_model .generate (
289+ input_ids = inputs ["input_ids" ],
290+ pixel_values = inputs ["pixel_values" ],
291+ max_new_tokens = 30 ,
292+ num_beams = 1
293+ )
294+ decoded = self .processor .batch_decode (
295+ generated_ids ,
296+ skip_special_tokens = False
297+ )
298+ for (element_index , _ ), generated_text in zip (batch , decoded ):
299+ cleaned_output = (
300+ generated_text
301+ .replace ("<s>" , "" )
302+ .replace ("</s>" , "" )
303+ .replace ("<CAPTION>" , "" )
304+ .replace ("</CAPTION>" , "" )
305+ .replace ("<pad>" , "" )
306+ .strip ()
307+ )
308+ elements [element_index ].content = cleaned_output
309+ if cleaned_output :
310+ self .logger .info (f"Florence caption for element { element_index } : { cleaned_output [:50 ]} " )
311+
312+ def florence_batch_caption_adaptive (self , jobs , elements , max_batch_size : int ) -> None :
313+ """Caption jobs with retry + halving on probable OOM failures."""
314+ if not jobs :
315+ return
316+ if max_batch_size < 1 :
317+ raise ValueError ("max_batch_size must be >= 1" )
318+
319+ # Process caption jobs in chunks and reduce chunk size if a batch runs out of memory.
320+
321+ i = 0
322+ while i < len (jobs ):
323+ remaining = len (jobs ) - i
324+ chunk_size = min (max_batch_size , remaining )
325+
326+ while chunk_size >= 1 :
327+ chunk = jobs [i :i + chunk_size ]
328+ try :
329+ self .logger .info (
330+ f"FLORENCE_ADAPTIVE_TRY size={ chunk_size } "
331+ f"remaining={ remaining } start_index={ i } "
332+ )
333+ self .florence_batch_caption (chunk , elements )
334+ i += chunk_size
335+ break
336+ except Exception as e :
337+ if not self ._is_probable_oom (e ):
338+ raise
339+
340+ self .logger .warning (
341+ f"FLORENCE_OOM size={ chunk_size } , halving batch: { e } "
342+ )
343+
344+ if self .device == "cuda" :
345+ torch .cuda .empty_cache ()
346+
347+ if chunk_size == 1 :
348+ failed_element_idx , _ = chunk [0 ]
349+ self .logger .warning (
350+ f"Skipping caption for element { failed_element_idx } after OOM at size=1"
351+ )
352+ i += 1
353+ break
354+
355+ chunk_size = max (1 , chunk_size // 2 )
356+
249357 async def detect_elements (
250358 self ,
251359 image_data : bytes ,
@@ -268,7 +376,8 @@ async def detect_elements(
268376 # YOLO detection
269377 results = self .yolo_model (image )
270378 elements = []
271-
379+ batch_crops : list [tuple [int , Image .Image ]] = [] # List of (index, cropped element) for captioning
380+
272381 for result in results :
273382 for box in result .boxes :
274383 # Get coordinates
@@ -280,75 +389,76 @@ async def detect_elements(
280389 self .logger .info (f"YOLO detected: { raw_type } (class { cls_id } )" )
281390 mapped_type = self ._map_element_type (raw_type )
282391
392+ # Crop the detected element
393+ crop_x1 = max (0 , int (x1 ))
394+ crop_y1 = max (0 , int (y1 ))
395+ crop_x2 = min (width , int (x2 ))
396+ crop_y2 = min (height , int (y2 ))
397+
398+ if crop_x2 <= crop_x1 or crop_y2 <= crop_y1 :
399+ continue
400+
401+ # Always preserve the detection result even if captioning is unavailable/fails later.
402+ elements .append (UIElement (
403+ element_type = mapped_type ,
404+ bbox = [crop_x1 , crop_y1 , crop_x2 , crop_y2 ],
405+ content = "" ,
406+ interactivity = mapped_type in ["button" , "input" , "link" ]
407+ ))
408+
283409 # FLORENCE CAPTIONING - Makes content DYNAMIC
284410 element_content = ""
411+
285412 if self .caption_model is not None and self .processor is not None :
286413 try :
287- # Crop the detected element
288- crop_x1 = max (0 , int (x1 ))
289- crop_y1 = max (0 , int (y1 ))
290- crop_x2 = min (width , int (x2 ))
291- crop_y2 = min (height , int (y2 ))
292414
293- if crop_x2 <= crop_x1 or crop_y2 <= crop_y1 :
294- continue
295415
296416 element_crop = image .crop ((crop_x1 , crop_y1 , crop_x2 , crop_y2 ))
297417 self .logger .info (f"Crop format: { element_crop .mode } , size: { element_crop .size } " )
298418
299419 if element_crop .mode != "RGB" :
300420 element_crop = element_crop .convert ("RGB" )
301421
302- # Skip very small elements
303- if element_crop .width >= 10 and element_crop .height >= 10 :
304- # Use Florence to generate caption
305- prompt = "<CAPTION>"
306- inputs = self .processor (
307- text = prompt ,
308- images = element_crop ,
309- return_tensors = "pt"
310- ).to (self .device )
311-
312- # Generate caption
313- with torch .no_grad ():
314- generated_ids = self .caption_model .generate (
315- input_ids = inputs ["input_ids" ],
316- pixel_values = inputs ["pixel_values" ],
317- max_new_tokens = 50 ,
318- num_beams = 3
422+ elements_idx = len (elements ) - 1
423+ batch_crops .append ((elements_idx , element_crop ))
424+ self .logger .info (f"Added element { elements_idx } to caption batch (type: { mapped_type } )" )
425+
426+ # Flush when the queue reaches the configured target; adaptive splitting happens inside.
427+ if (len (batch_crops ) == FLORENCE_BATCH_SIZE ):
428+ try :
429+ self .logger .info (f"Processing batch of { len (batch_crops )} elements with Florence..." )
430+ self .florence_batch_caption_adaptive (
431+ batch_crops ,
432+ elements ,
433+ FLORENCE_BATCH_SIZE
319434 )
320-
321- # Decode caption
322- generated_text = self .processor .batch_decode (
323- generated_ids ,
324- skip_special_tokens = False
325- )[0 ]
326- self .logger .info (f"Florence raw output: { repr (generated_text )} " )
327- # Extract caption (remove tags)
328- element_content = (
329- generated_text
330- .replace ("<s>" , "" )
331- .replace ("</s>" , "" )
332- .replace ("<CAPTION>" , "" )
333- .replace ("</CAPTION>" , "" )
334- .replace ("<pad>" , "" )
335- .strip ()
336- )
337- if element_content :
338- self .logger .info (f"Caption: { element_content [:50 ]} " )
339-
435+ except Exception as e :
436+ self .logger .warning (f"Failed to batch caption elements: { e } " )
437+ finally :
438+ batch_crops .clear ()
439+ self .logger .info (f"BATCH_AFTER_CLEAR len={ len (batch_crops )} " )
440+
441+
442+
340443 except Exception as e :
341444 self .logger .warning (f"Failed to caption { mapped_type } : { e } " )
342445 element_content = ""
343446
344- # Create Element with DYNAMIC content from Florence
345- elements .append (UIElement (
346- element_type = mapped_type ,
347- bbox = [x1 , y1 , x2 , y2 ],
348- content = element_content ,
349- interactivity = mapped_type in ["button" , "input" , "link" ]
350- ))
351-
447+ if batch_crops :
448+ # Flush leftover detections that did not fill a complete batch.
449+ try :
450+ self .logger .info (f"BATCH_FINAL_FLUSH_TRIGGER len={ len (batch_crops )} " )
451+ self .florence_batch_caption_adaptive (
452+ batch_crops ,
453+ elements ,
454+ FLORENCE_BATCH_SIZE
455+ )
456+ except Exception as e :
457+ self .logger .warning (f"Failed final batch caption elements: { e } " )
458+ finally :
459+ batch_crops .clear ()
460+ self .logger .info (f"BATCH_FINAL_AFTER_CLEAR len={ len (batch_crops )} " )
461+
352462 layout_hierarchy = {}
353463 result = UIElementDetectionResult (
354464 elements = elements ,
0 commit comments