feat(ocr): expose DeepDoc's document-parsing pipeline as task="parse" - #5299
feat(ocr): expose DeepDoc's document-parsing pipeline as task="parse"#5299OliverBryant wants to merge 1 commit into
Conversation
The DeepDoc OCR model exposes DeepDoc's three atomic vision capabilities
(ocr, layout, table) but not `RAGFlowPdfParser.parse_into_bboxes()`, the
full document-parsing pipeline built on top of them. Table HTML, figures,
cross-page coordinates, paragraph merging and reading order are not
recoverable from the three atomic tasks, so consumers that want structured
document parsing cannot use this model today.
Add a fourth task to the existing /v1/images/ocr endpoint:
-F 'kwargs={"task": "parse"}' -F image=@doc.pdf
Unlike the per-page tasks, `parse` renders the PDF itself and needs the
whole document to accumulate cross-page coordinates, so the REST layer
passes the uploaded bytes straight through instead of rasterizing page by
page. The existing per-page path is untouched for the other three tasks.
The parser is assembled around the recognizers the model already holds
rather than constructed normally, which would load a second full set of
ONNX sessions (measured: +512 MiB). The per-document state a parse run
leaves on the cached parser is cleared afterwards, so rasterized pages
and crops are not retained between requests, and a document that fails to
load cannot be parsed against the previous request's pages.
Elements serialize to {type, text, image_base64, metadata}. Only tables
and figures carry a base64 crop by default (`image_scope`), since every
element has one internally but encoding all of them inflates the response
roughly 15x. `zoomin` selects the render scale.
Verified against a local CPU `parse_into_bboxes` run on the same 3-page
PDF, GPU vs CPU: identical element count (52), per-type counts
(text 40 / title 8 / table 2 / figure 2), ordered text, table HTML,
positions, page_number and layoutno. Coordinates differ by at most
4.6e-3 on the four re-inserted table/figure boxes, from ONNX floating
point on GPU. `col_id` varies run to run even on CPU, because deepdoc's
column clustering uses KMeans without a fixed random_state.
Refs xorbitsai#5296
There was a problem hiding this comment.
Code Review
This pull request introduces a whole-document parsing task (task="parse") for the DeepDoc OCR model, enabling layout analysis, table recognition, and paragraph merging over entire PDFs. It adds PDF validation, bypasses per-page rasterization, and optimizes memory by reusing loaded recognizers and resetting parser state after runs. The review feedback highlights two key improvements: respecting the configured device (self._device) when initializing the XGBoost booster to avoid forcing GPU usage, and catching ValueError during OCR execution in the REST API to return a 400 Bad Request instead of a 500 Internal Server Error.
| booster = xgb.Booster() | ||
| try: | ||
| import torch | ||
|
|
||
| if torch.cuda.is_available(): | ||
| booster.set_param({"device": "cuda"}) | ||
| except Exception: | ||
| logger.debug("torch unavailable, running the xgb booster on CPU") |
There was a problem hiding this comment.
The device configuration self._device is ignored when setting up the XGBoost booster. If the model is explicitly configured to run on CPU (e.g., self._device = "cpu"), but PyTorch detects CUDA is available, the booster will still be forced onto GPU. This can lead to unexpected GPU memory allocation or runtime failures on CPU-only nodes. Respect the configured device by checking if "cuda" is in self._device.
booster = xgb.Booster()
if self._device and "cuda" in self._device:
try:
booster.set_param({"device": "cuda"})
except Exception:
logger.debug("Failed to set xgboost device to cuda, running on CPU", exc_info=True)| result = await model_ref.ocr( | ||
| image=data, | ||
| **parsed_kwargs, | ||
| ) | ||
| return Response(content=result, media_type="application/json") |
There was a problem hiding this comment.
If model_ref.ocr raises a ValueError (for example, due to invalid zoomin or image_scope arguments passed in parsed_kwargs), the exception will propagate to the outer except Exception block and return a 500 Internal Server Error. User input validation errors should return a 400 Bad Request instead. Wrap the call in a try-except ValueError block to handle this gracefully.
| result = await model_ref.ocr( | |
| image=data, | |
| **parsed_kwargs, | |
| ) | |
| return Response(content=result, media_type="application/json") | |
| try: | |
| result = await model_ref.ocr( | |
| image=data, | |
| **parsed_kwargs, | |
| ) | |
| except ValueError as ve: | |
| raise HTTPException(status_code=400, detail=str(ve)) | |
| return Response(content=result, media_type="application/json") |
Summary
The DeepDoc OCR model added in #5230 exposes DeepDoc's three atomic vision capabilities (
ocr,layout,table) but notRAGFlowPdfParser.parse_into_bboxes(), the full document-parsing pipeline built on top of them. Table HTML, figures, cross-page coordinates, paragraph merging and reading order are not recoverable from the three atomic tasks, so consumers that want structured document parsing cannot use this model today.This adds a fourth task to the existing
/v1/images/ocrendpoint, as proposed in #5296:{"task": "parse", "elements": [ {"type": "table", "text": "<table><caption>...</caption><tr><th>...</th></tr></table>", "image_base64": "...", "metadata": {"page_number": 2, "x0": 20.0, "x1": 400.0, "top": 50.0, "bottom": 200.0, "layout_type": "table", "col_id": 0, "positions": [[2, 20, 400, 50, 200]]}} ]}Design notes
parsetakes the PDF, not a rasterized page. The pipeline renders the PDF itself and needs the whole document to accumulate cross-page coordinates, so the REST layer passes the uploaded bytes straight through. The existing per-page rasterizing path is untouched for the other three tasks — the new branch is entered only for tasks inWHOLE_DOCUMENT_OCR_TASKS. Bytes travel in the existingimage=channel, whichModelActor.ocralready excludes from its logs (@log_async(ignore_kwargs=["image"])), so no core or client changes were needed.Reusing the loaded recognizers. A plain
PdfParser()would construct its own OCR, layout and table recognizers on top of the ones the model already holds. Measured on an RTX 3090 Ti, that costs +512 MiB of extra ONNX sessions. Instead the constructor is bypassed and only the attributes it would have set are assembled, wiring in the existing instances; object identity is asserted in testing (parser.ocr is model._ocr, etc.). If a futuredeepdoc-libneeds attributes this doesn't know about, it falls back to the real constructor and injects afterwards, with a warning.Per-document state is released.
parse_into_bboxesleaves the document it processed attached to the parser (rasterized pages, a crop per box, extracted chars) and never clears it. Since the parser is cached, that state would stay resident after the response — for a 3-page document that is 39 MB of page images plus 52 crops; at the 200-page limit and the largest render scale it is gigabytes. Worse,__images__resetsboxesbefore loading the new document but swallows load failures, so a PDF it cannot open would leave the previous request's pages attached and silently parse those. Both are handled by clearing the state in afinally.Payload control. Every element carries a crop internally, but only tables and figures need one.
image_scopedefaults totable_figure; on the test document that is 277 KB versus 4.1 MB forall(a ~15x difference) and 15 KB fornone.Validation.
validate_pdf_for_parsechecks the document with pypdfium2 before deepdoc sees it, reusing the existingMAX_PDF_OCR_PAGES(200) ceiling so the endpoint has one page limit regardless of task. This turns deepdoc's silent-empty-result and its 3x-zoom recursive retry into a clean 400.pages/dpiare rejected with 400 forparserather than silently ignored.model_abilityand the model spec are unchanged: it gates endpoint routing rather than tasks, and the existing three tasks are likewise not advertised there.Parity with local CPU output
The motivating requirement is that DeepDoc on GPU through Xinference match running
deepdoc-liblocally on CPU. Measured on the same 3-page PDF,zoomin=3, comparing a local CPUPdfParser().parse_into_bboxes()run against this endpoint's response on an RTX 3090 Ti:{text: 40, title: 8, table: 2, figure: 2}both sides — matchpositionspage_numberlayoutnox0/x1/top/bottom)col_idTwo honest caveats rather than a claim of bit-exactness:
_extract_table_figurere-inserts, and are ~5e-3 at a 1836x2376 render — sub-pixel.col_idis not reproducible indeepdoc-libitself: column clustering usesKMeans(n_clusters=k, n_init="auto")with norandom_state, andrandom.choices()feeds the is-English heuristic. Two consecutive local CPU runs already disagree on 6 of 52 elements. This is pre-existing upstream behaviour, not introduced here.Everything structural — element count, ordering, text, table HTML, positions, page numbers — is identical.
Verification
pages/dpistill select and rasterize as before (pages: [1, 3]verified).PdfParser()would have added 512 MiB.task=parse→ 400;pages/dpiwithparse→ 400; oversized/unreadable PDF → 400; invalidzoomin/image_scope→ clear validation error.pytest xinference/api/tests/ xinference/model/image/ocr/tests/→ 321 passed, 17 skipped.pre-commit(black, ruff, isort, mypy, codespell) green on all changed files.Not run: the full non-GPU CI matrix and the model-download integration tests, which need more hardware and network than this change touches.
Note on #5295
Left alone deliberately. The virtualenv built for this model on my box resolved
transformersto 4.57.x undervirtualenv/v4/DeepDoc/default/, i.e. the#engine#-gatedtransformers<5constraint did not apply — consistent with that report. But I also saw a.../DeepDoc/deepdoc/virtualenv where the marker did engage, so the trigger condition needs pinning down on a clean environment before changing a shared dependency constraint. That belongs in #5295 rather than here.Closes #5296