feat(ocr): add Python OCR microservice, RestClientOcrClient, Docker Compose

Python microservice (ocr-service/):
- FastAPI app with /ocr and /health endpoints
- Surya engine: transformer-based OCR for typewritten/modern handwriting
- Kraken engine: historical HTR for Kurrent/Suetterlin with
  pure-Python polygon-to-quad approximation (gift wrapping + rotating calipers)
- Eager model loading at startup via lifespan context manager
- PDF download via httpx, page rendering via pypdfium2 at 300 DPI

Java RestClientOcrClient:
- Implements OcrClient + OcrHealthClient interfaces
- Calls Python service via Spring RestClient
- Health check with graceful fallback

Docker Compose:
- New ocr-service container (mem_limit 6g, no host ports)
- Health check with start_period 60s for model loading
- ocr_models volume for Kraken model files
- Backend depends on ocr-service health

Refs #226, #227

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-12 15:26:40 +02:00
parent aea46c5fd0
commit 6737bd6db5
9 changed files with 500 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
package org.raddatz.familienarchiv.service;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.model.ScriptType;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
@Component
@Slf4j
public class RestClientOcrClient implements OcrClient, OcrHealthClient {
private final RestClient restClient;
public RestClientOcrClient(@Value("${app.ocr.base-url:http://ocr-service:8000}") String baseUrl) {
this.restClient = RestClient.builder().baseUrl(baseUrl).build();
}
@Override
public List<OcrBlockResult> extractBlocks(String pdfUrl, ScriptType scriptType) {
Map<String, String> body = Map.of(
"pdfUrl", pdfUrl,
"scriptType", scriptType.name(),
"language", "de");
List<OcrBlockJson> response = restClient.post()
.uri("/ocr")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(new ParameterizedTypeReference<>() {});
if (response == null) return List.of();
return response.stream()
.map(OcrBlockJson::toResult)
.toList();
}
@Override
public boolean isHealthy() {
try {
restClient.get()
.uri("/health")
.retrieve()
.toBodilessEntity();
return true;
} catch (Exception e) {
log.warn("OCR service health check failed: {}", e.getMessage());
return false;
}
}
record OcrBlockJson(
@JsonProperty("pageNumber") int pageNumber,
double x,
double y,
double width,
double height,
List<List<Double>> polygon,
String text
) {
OcrBlockResult toResult() {
return new OcrBlockResult(pageNumber, x, y, width, height, polygon, text);
}
}
}