Skip to content

Commit 5227b47

Browse files
committed
feat(omniparser): add configurable Florence batching with adaptive OOM fallback
1 parent 8dcbb6d commit 5227b47

3 files changed

Lines changed: 169 additions & 54 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ FIREBASE_PROJECT_ID=your_firebase_project_id
55
FIREBASE_SERVICE_ACCOUNT_KEY=path/to/serviceAccountKey.json
66
FAISS_INDEX_PATH=./data/knowledge_base.index
77
VECTOR_STORE_PATH=./data/vector_store
8+
FLORENCE_BATCH_SIZE=3
89
LOG_LEVEL=INFO

app/core/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,13 @@ class Settings(BaseSettings):
2525
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")
2626
HOST: str = Field(default="0.0.0.0", env="HOST")
2727
PORT: int = Field(default=8000, env="PORT")
28+
FLORENCE_BATCH_SIZE: int = Field(default=3, env="FLORENCE_BATCH_SIZE", ge=1)
2829

2930
class Config:
3031
env_file = ".env"
3132
case_sensitive = True
3233

3334
settings = Settings()
35+
36+
# Runtime tuning knobs used by services
37+
FLORENCE_BATCH_SIZE = settings.FLORENCE_BATCH_SIZE

app/services/omniparser_client.py

Lines changed: 164 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
}
3333

3434
from 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

3737
logger = 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

Comments
 (0)