Skip to content

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

3D illustration of an OCR service workbench with image upload, validation, native OCR execution, completed response, and fallback response panels
An OCR service must return a client-readable response even when recognition cannot run.

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.

OCR service workflow diagram with vertical lanes for HTTP upload boundary, image preprocessing boundary, native OCR boundary, and response contract boundary
A practical OCR service splits upload guard, image preprocessing, native OCR, and response contract. BAD_REQUEST is not a fallback; it is an input-boundary rejection.

The sample input in this post is a clean document-like image.

OCR API sample input image with the title BLUETAPE OCR and three document lines
Actual OCR output depends on font, resolution, traineddata, and preprocessing. The JSON below is a source-backed illustration of the workshop contract.

The workshop API accepts a multipart upload.

Terminal window
curl -F "file=@sample.png;type=image/png" \
-F "language=eng" \
http://localhost:8080/api/images/ocr

The 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.

GuardWhy it exists
Reject empty bytesDo not treat an empty multipart field as a valid OCR request
maxUploadBytesKeep one request from pressuring heap and native runtime resources
JPEG/PNG/WebP content typeKeep the client/server contract narrow
Magic-byte check against declared typeReject spoofed uploads such as JPEG bytes declared as PNG
Decoded pixel budgetBound 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.

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.

Statustext/blocksCommon causeClient interpretation
COMPLETEDFilledOCR completedUse text and blocks
UNAVAILABLEEmptyNative disabled, Tesseract/tessdata/language pack not readyRetry later or continue with an OCR-less UX
FAILEDEmptyTimeout or OCR runtime exceptionTreat 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.

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.

Terminal window
brew install tesseract
ls /opt/homebrew/share/tessdata/eng.traineddata
sudo apt-get install -y tesseract-ocr tesseract-ocr-eng

The workshop exposes this through configuration.

PropertyRole
workshop.ocr.native-enabledEnables the native OCR path
workshop.ocr.tessdata-pathPoints to host-specific traineddata
workshop.ocr.timeoutKeeps a native call from holding an API worker too long
workshop.ocr.max-upload-bytesLimits upload byte budget
workshop.ocr.max-image-pixelsLimits 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

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.

OCR document preprocessing benchmark chart comparing Scrimage and libvips latency and managed heap allocation
In the document-like preprocessing workload, the libvips Java 25 FFM path used much less latency and managed heap allocation than the pure-JVM Scrimage path. These numbers measure decode/resize/grayscale/encode, not recognition time.
BoundaryLatencyManaged heap allocationInterpretation
Scrimage ByteArray143.64 ms/op164.46 MiB/opConvenient, but stages compressed input bytes
Scrimage Path145.13 ms/op164.34 MiB/opSimilar to other blocking Scrimage boundaries
Scrimage InputStream / OutputStream148.39 ms/op165.33 MiB/opStream boundary does not remove decoded heap cost
Scrimage Okio Source / Sink145.59 ms/op165.34 MiB/opUseful for lifecycle/integration, not a latency win
Scrimage suspended source/sink170.69 ms/op166.10 MiB/opShows blocking stream bridge overhead
vips ByteArray15.38 ms/op0.34 MiB/opFast, but still stages compressed input bytes
vips Path5.47 ms/op0.34 MiB/opStrongest row in this snapshot
vips InputStream / OutputStream15.59 ms/op1.40 MiB/opLow 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.

ExampleRouteUpload handlingError shape
Ktor OCR API/api/ocrReads streamed multipart parts up to maxInputBytes + 1error, message, status, path
Spring Boot OCR API/api/ocrReads MultipartFile.bytes on Dispatchers.IOerror, 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

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.