Operating an OCR Service in Practice: Large Image Preprocessing and Failure Response Contracts

An OCR demo often ends with a file upload and extracted text. A real service does not. A compressed upload may decode into a much larger image, the declared content type may be false, Tesseract may be missing from the host, or the native OCR call may time out.
That is why the bluetape4k-workshop/image-processing/ocr-api example models input boundaries and
failure responses before it celebrates the happy path. Invalid requests stop at BAD_REQUEST. A
fallback response is reserved for a valid image that cannot be recognized because the current host
cannot run OCR or because OCR failed at runtime. Mixing those two cases makes clients, operators,
and alerts harder to reason about.

BAD_REQUEST is not a fallback; it is an input-boundary rejection.Example API Shape
Section titled “Example API Shape”The sample input in this post is a clean document-like image.

The workshop API accepts a multipart upload.
curl -F "file=@sample.png;type=image/png" \ -F "language=eng" \ http://localhost:8080/api/images/ocrThe controller turns the file part and optional language query parameter into a service request.
Full source:
ImageOcrController.kt
The response model does not return text alone.
data class ImageOcrResponse( // Correlation id used to connect logs, traces, and client support cases. val requestId: String,
// The first field clients should branch on. // COMPLETED, UNAVAILABLE, and FAILED share the same response shape. val status: OcrStatus,
// OCR engine used for this response. Disabled native OCR is reported explicitly. val engine: String,
// OCR language list requested by the client or normalized by the service. val languages: List<String>,
// Nullable when the OCR engine cannot report confidence. val confidence: Double?,
// Final recognized text. It can be empty for UNAVAILABLE or FAILED responses. val text: String,
// Structured line-level OCR result for highlighting or post-processing. val blocks: List<OcrTextBlock>,
// Client/operator-readable notes that replace exception-message parsing. val warnings: List<String>,)Full source: ImageOcrModels.kt
Because status is explicit, clients do not need to infer meaning from an empty text field.
Input Boundary: Upload Bytes Are Not Enough
Section titled “Input Boundary: Upload Bytes Are Not Enough”A fallback response does not mean the API accepts any image. ImageOcrServiceImpl validates the
request before it attempts OCR.
| Guard | Why it exists |
|---|---|
| Reject empty bytes | Do not treat an empty multipart field as a valid OCR request |
maxUploadBytes | Keep one request from pressuring heap and native runtime resources |
| JPEG/PNG/WebP content type | Keep the client/server contract narrow |
| Magic-byte check against declared type | Reject spoofed uploads such as JPEG bytes declared as PNG |
| Decoded pixel budget | Bound decode-time cost by width * height |
The actual control is the decoded pixel budget. A 4096x3860 image may look small as compressed
bytes, but it decodes to about 15.8M pixels. In practice, the API worker and preprocessing pipeline
care about width * height, not the uploaded byte count alone. The workshop example enforces that
contract with maxImagePixels.
Full source: ImageOcrServiceImpl.kt, ImageOcrProperties.kt
The important rule is that BAD_REQUEST is not a fallback status. Empty files, unsupported
content types, magic-byte mismatches, corrupt images, and pixel-budget violations fail at the input
boundary. A fallback response is not a device for normalizing bad requests. It is used only when a
valid image cannot be processed because the OCR runtime is unavailable or failed.
One Response Shape, Different States
Section titled “One Response Shape, Different States”When native OCR succeeds, the COMPLETED response returns text and line-based blocks.
{ "requestId": "ocr-20260630-0001", "status": "COMPLETED", "engine": "tesseract", "languages": ["eng"], "confidence": null, "text": "BLUETAPE OCR\nLine 1: Upload image\nLine 2: Validate native OCR\nLine 3: Return fallback safely", "blocks": [ { "index": 0, "text": "BLUETAPE OCR", "confidence": null }, { "index": 1, "text": "Line 1: Upload image", "confidence": null }, { "index": 2, "text": "Line 2: Validate native OCR", "confidence": null }, { "index": 3, "text": "Line 3: Return fallback safely", "confidence": null } ], "warnings": ["Confidence is not available from the current OCR engine."]}When native OCR is disabled, the API still validates the image but does not run OCR. The response
is UNAVAILABLE.
{ "requestId": "ocr-20260630-0002", "status": "UNAVAILABLE", "engine": "disabled", "languages": ["eng"], "confidence": null, "text": "", "blocks": [], "warnings": [ "Native OCR is disabled. Enable workshop.ocr.native-enabled=true or -Docr.enabled=true." ]}Missing Tesseract configuration or language data is also expressed as UNAVAILABLE. A timeout or
runtime OCR exception becomes FAILED.
| Status | text/blocks | Common cause | Client interpretation |
|---|---|---|---|
COMPLETED | Filled | OCR completed | Use text and blocks |
UNAVAILABLE | Empty | Native disabled, Tesseract/tessdata/language pack not ready | Retry later or continue with an OCR-less UX |
FAILED | Empty | Timeout or OCR runtime exception | Treat it as input quality or processing failure and apply a separate retry policy |
All three states share the same response shape. Clients read status, warnings, text, and
blocks instead of parsing exception messages.
Native OCR Is a Host Contract
Section titled “Native OCR Is a Host Contract”bluetape4k-image/images-ocr provides a Tess4J/Tesseract-backed engine, but it does not bundle the
Tesseract binary or traineddata. The host or container must provide the native runtime.
brew install tesseractls /opt/homebrew/share/tessdata/eng.traineddata
sudo apt-get install -y tesseract-ocr tesseract-ocr-engThe workshop exposes this through configuration.
| Property | Role |
|---|---|
workshop.ocr.native-enabled | Enables the native OCR path |
workshop.ocr.tessdata-path | Points to host-specific traineddata |
workshop.ocr.timeout | Keeps a native call from holding an API worker too long |
workshop.ocr.max-upload-bytes | Limits upload byte budget |
workshop.ocr.max-image-pixels | Limits decoded image pixel budget |
ImageOcrServiceImpl wraps the native OCR section with Semaphore(permits = 1), withTimeout,
and runInterruptible(Dispatchers.IO). That is the right signal: a coroutine API does not make a
native OCR call cheap. Native OCR consumes CPU and native memory, so concurrency and timeout must
be explicit.
Full source: TesseractOcrEngine.kt, images-ocr README
Large Images Cost Money Before OCR Starts
Section titled “Large Images Cost Money Before OCR Starts”Memory pressure in an OCR API does not start only inside Tesseract. A small compressed upload can expand into a large decoded image and pressure heap or native pipelines before recognition begins.
The images-benchmark large-streaming report includes a document/OCR-like workload. It processes
a 2480x3508 document input, resizes it to 1240x1754, applies grayscale, and encodes JPEG output.
This is not an OCR recognition benchmark. It is evidence for the image preprocessing cost that
happens before OCR.

| Boundary | Latency | Managed heap allocation | Interpretation |
|---|---|---|---|
Scrimage ByteArray | 143.64 ms/op | 164.46 MiB/op | Convenient, but stages compressed input bytes |
Scrimage Path | 145.13 ms/op | 164.34 MiB/op | Similar to other blocking Scrimage boundaries |
Scrimage InputStream / OutputStream | 148.39 ms/op | 165.33 MiB/op | Stream boundary does not remove decoded heap cost |
Scrimage Okio Source / Sink | 145.59 ms/op | 165.34 MiB/op | Useful for lifecycle/integration, not a latency win |
| Scrimage suspended source/sink | 170.69 ms/op | 166.10 MiB/op | Shows blocking stream bridge overhead |
vips ByteArray | 15.38 ms/op | 0.34 MiB/op | Fast, but still stages compressed input bytes |
vips Path | 5.47 ms/op | 0.34 MiB/op | Strongest row in this snapshot |
vips InputStream / OutputStream | 15.59 ms/op | 1.40 MiB/op | Low managed allocation with stream boundary cost |
Source: large-streaming-2026-06-05.md
The API-design consequence is straightforward. maxUploadBytes is not enough for an OCR API.
Large images can arrive as small compressed payloads, so a decoded pixel budget must sit beside the
upload byte budget. Also, if preprocessing cost matters, do not describe pure-JVM and libvips paths
as interchangeable. vips is not an OCR engine; it is a way to reduce image preprocessing cost. The
recognition step still belongs to an OCR runtime such as Tesseract.
Ktor and Spring Boot Differ Only at the Adapter
Section titled “Ktor and Spring Boot Differ Only at the Adapter”bluetape4k-image includes two OCR quickstarts.
| Example | Route | Upload handling | Error shape |
|---|---|---|---|
| Ktor OCR API | /api/ocr | Reads streamed multipart parts up to maxInputBytes + 1 | error, message, status, path |
| Spring Boot OCR API | /api/ocr | Reads MultipartFile.bytes on Dispatchers.IO | error, message |
Both eventually call immutableImageOf(uploadBytes).suspendExtractText(...). The difference is how
the HTTP stack reads multipart data and shapes errors. The OCR engine contract remains in
images-ocr.
Full source: KtorOcrApiApplication.kt, SpringBootOcrApiApplication.kt
Closing
Section titled “Closing”An OCR service gets stuck quickly if it starts as an endpoint returning only detected text. In practice, upload byte limits, decoded pixel budgets, preprocessing cost, native runtime readiness, timeouts, failure responses, and client retry policy move together.
Whether the adapter is coroutines, Ktor, or Spring Boot does not change the core contract very
much. The HTTP layer receives a file and builds a service request. The careful part is not to treat
a native OCR call as cheap just because it is wrapped by a suspend function. Bound blocking/native
work with concurrency and timeout controls, then return COMPLETED, UNAVAILABLE, and FAILED
inside the same response shape.
Image detection will need a similar failure response contract, but the result shape is different: object bounding boxes, labels, and scores instead of line-based text. A separate example will cover that path.
Comments
Leave a note or reaction with your GitHub account.