Strona główna / Artykuły

Samodzielnie hostowany OCR modelu Vision LLM w Go: rasteryzacja, generowanie promptów, analiza tekstu

Narzędzie w języku Go przekształca pliki PDF medyczne na format JSON za pomocą modelu językowego do rozpoznawania obrazu na platformie Ollama: rasterizacja za pomocą pdftoppm, precyzyjne instrukcje, fallback do parsowania oraz odbieranie danych z Redis Streams.

Wydawca: REACTAPP.TOP7 min czytania1684 słów

Źródła: Inside the OCR Pipeline: Code, Prompts, and Failure Handling (Part 2 of 2)

Wybór modelu wizyjnego self-hosted zamiast chmurowej API OCR to tylko połowa decyzji; drugą połową jest proces, który radzi sobie z tym, co faktycznie zwraca model. Ten przewodnik opisuje produkcyjnego pracownika w języku Go, który czyta raporty z laboratoriów medycznych i przechowuje wyniki w Postgresie. Zobaczysz, jak strony są rasterizowane, jak kształtuje się prompt, jak analizować wyniki ignorujące zapytany format oraz jak pracownik skaluje się bez podwójnej obróbki zadań.

Krok 1: Przekształcenie plików PDF w obrazy stron

Modele wizualne Ollamy przyjmują obrazy, a nie pliki PDF, więc każdy załadunek jest najpierw przetwarzany na jeden obraz na stronę. Zamiast używać ciężkiej biblioteki do renderowania, proces wykorzystuje pdftoppm z zestawu Poppler, zainstalowanego na serwerze procesora. Funkcja zapisuje dane do pliku tymczasowego, renderuje każdą stronę w formacie PNG o rozdzielczości 300 dpi, a następnie odczytuje i usuwa utworzone pliki. exec.CommandContext łączy proces potomny z kontekstem zadania, dzięki czemu anulowanie również zatrzymuje proces renderowania.

func extractImagesFromPDF(ctx context.Context, pdfBytes []byte) ([][]byte, error) {
    tmpFile, _ := os.CreateTemp("", "report-*.pdf")
    defer os.Remove(tmpFile.Name())
    tmpFile.Write(pdfBytes)
    tmpFile.Close()

outPrefix := strings.TrimSuffix(tmpFile.Name(), ".pdf")
    // Render each PDF page to a 300dpi PNG
    cmd := exec.CommandContext(ctx, "pdftoppm", "-r", "300", "-png", tmpFile.Name(), outPrefix)
    if err := cmd.Run(); err != nil {
        return nil, fmt.Errorf("pdftoppm failed: %v", err)
    }
    matches, _ := filepath.Glob(outPrefix + "*.png")
    var images [][]byte
    for _, match := range matches {
        imgBytes, _ := os.ReadFile(match)
        images = append(images, imgBytes)
        os.Remove(match)
    }
    return images, nil
}

300 dpi okazało się odpowiednią wartością – małe elementy tekstu, takie jak jednostki miary i zakresy referencyjne, pozostają czytelne dla modelu, przy czym nie powstają zbyt duże obrazy stron. Ze względu na zwięzłość fragment kodu pomija kilka błędów; należy je naprawić w rzeczywistym kodzie. Należy również zauważyć, że filepath.Glob sortuje elementy leksykalnie, więc przy dziesięciu lub więcej stronach warto posortować je według numeru strony, który dodaje narzędzie pdftoppm.

Krok 2: Poproś model o określony kształt JSON

Celem wydobywania danych jest lista wyników testów, a struktura określa ich specyfikację: nazwa, wartość, jednostka miary, zakres referencyjny, flaga wskazująca na nieprawidłowości oraz ocena pewności samodzielnie podana przez model.

type ExtractedParameter struct {
    ParameterName  string `json:"parameter_name"`
    Value          string `json:"value"`
    Unit           string `json:"unit"`
    ReferenceRange string `json:"reference_range"`
    AbnormalFlag   bool   `json:"abnormal_flag"`
    OcrConfidence  int    `json:"ocr_confidence"`
}

Zapytanie to zwykły POST do /api/generate w Ollamie. Obraz jest kodowany w formacie base64 i umieszczany w polu Images, pole prompt jest wypełniane kategorią raportu, strumieniowanie jest wyłączone, a ustawiony jest format Format: „json”. Następnie pole Response z koperty jest konwertowane na tablicę parametrów. Niektóre instrukcje w tym fragmencie są wykonywane razem na pojedynczych liniach, więc należy to traktować jako zarys przepływu, a w warunkach produkcyjnych sprawdzić wartość resp.StatusCode oraz błędy, które zostały zignorowane.

func (c *OllamaClient) ExtractParameters(ctx context.Context, imageBytes []byte, category string) ([]ExtractedParameter, error) {
    base64Image := base64.StdEncoding.EncodeToString(imageBytes)
    prompt := fmt.Sprintf(promptTemplate, category)    reqData := ollamaRequest{
        Model:  c.Model,
        Prompt: prompt,
        Images: []string{base64Image},
        Stream: false,
        Format: "json", // nudges Ollama toward JSON output — doesn't guarantee it
    }    reqBytes, _ := json.Marshal(reqData)
    req, _ := http.NewRequestWithContext(ctx, "POST", c.URL+"/api/generate", bytes.NewReader(reqBytes))
    req.Header.Set("Content-Type", "application/json")    resp, err := (&http.Client{}).Do(req)
    if err != nil {
        return nil, fmt.Errorf("failed to send request to ollama: %v", err)
    }
    defer resp.Body.Close()    var oResp ollamaResponse
    json.NewDecoder(resp.Body).Decode(&oResp)    // parsing continues below — see "Handling model output reliability"
    var parameters []ExtractedParameter
    if err := json.Unmarshal([]byte(oResp.Response), &parameters); err == nil {
        return parameters, nil
    }
    // ...
    return parameters, nil
}

Prompt, sformułowany w inny sposób niż w wersji produkcyjnej, wymienia sztywne zasady i kończy się przykładem dokładnej tablicy, którą należy zwrócić:

You are a medical data extraction assistant. You are given an image of a
medical report.
Rules:
1. Only extract tests that are explicitly visible in the image - never
   invent, guess, or infer a result that isn't printed.
2. The report may contain several distinct tests. Create one JSON object
   per test - do not merge multiple tests into a single object.
3. Capture the test name, result, unit, and reference range exactly as
   printed. Ignore narrative "interpretation" paragraphs.
4. If a result is flagged abnormal (marked with *, H, L, or similar),
   set abnormal_flag to true.
5. Rate your own confidence in reading each value, 0-100.
6. Respond with ONLY a flat JSON array - no wrapping object, no markdown.
[
  {
    "parameter_name": "Hemoglobin",
    "value": "13.2",
    "unit": "g/dL",
    "reference_range": "13.0 - 17.0",
    "abnormal_flag": false,
    "ocr_confidence": 98
  }
]

Każde zasadę dotyczy określonego sposobu awarii. Zakaz używania wymyślonych wartości pomaga w radzeniu sobie z halucynacjami, najniebezpieczniejszym błędem w danych medycznych. Jedno obiekt na test zapobiega łączeniu elementów w jeden wielki zbiór. Kopiowanie wartości w formie wydrukowanej zachowuje jednostki i zakresy, a pomijanie akapitów interpretacyjnych zapobiega umieszczaniu treści narracyjnych w strukturalnych polach. Wskaźnik pewności jest przydatny do kierowania wątpliwych wartości do człowieka, chociaż samo ocenianie przez model jest metodą heurystyczną, a nie skalibrowaną prawdopodobieństwem. Ostatnia zasada – odpowiadanie wyłącznie prostym tablicą – ma większe znaczenie, niż się wydaje, ponieważ sam tryb JSON nie gwarantuje zachowania struktury.

Analiza wyników, które ignorują żądany kształt

Format: „json” skłania do generowania poprawnego JSON, ale nie jest to schemat. W praktyce mniejszy model lokalny może umieścić listę wewnątrz obiektu, np. {"tests": [...]}, zwrócić pojedynczy obiekt lub wygenerować coś, co nie stanowi spójnego, możliwego do zinterpretowania dokumentu.

Zamiast zawieść się przy pierwszej niezgodności podczas wydobywania danych, parser próbuje coraz bardziej luźnych strategii i zatrzymuje się przy pierwszym sukcesie:

// 1. Try the shape we actually asked for: a flat array
if err := json.Unmarshal([]byte(raw), &parameters); err == nil {
    return parameters, nil
}
// 2. Try an object wrapping the array under an unknown key, e.g. {"tests": [...]}
var wrapper map[string][]ExtractedParameter
if err := json.Unmarshal([]byte(raw), &wrapper); err == nil {
    for _, v := range wrapper {
        if len(v) > 0 {
            return v, nil
        }
    }
}
// 3. Try a single object instead of an array
// 4. Recursively walk arbitrarily nested JSON, matching field names loosely
//    (e.g. "test_name", "name", "parameter_name" all resolve to the same field)
// 5. As a last resort, regex out individual {...} blocks and parse each one
//    independently - catches cases where duplicate keys break a clean unmarshal

Najpierw odbywa się ścisła analiza, więc odpowiedzi o prawidłowym zachowaniu wybierają najprostszą ścieżkę. Następnie akceptowana jest każda kluczowa wartość typu wrapper zawierająca niepustą tablicę, a pojedynczy obiekt jest przekształcany w listę. Recyrtyczne przeszukiwanie radzi sobie z głębokim nawijaniem i mapuje nazwy pól o luźnym dopasowaniu (test_name, name, parameter_name) na jedno pole. Jako ostateczność regex wyodrębnia pojedyncze bloki {...} i analizuje każdy z nich osobno, co pomaga przy naprawie odpowiedzi uszkodzonych przez duplikowane klucze.

To nie jest eleganckie, a idealnie model powinien zawsze działać poprawnie. To cena korzystania z kompaktowego modelu samodzielnie zamiast płacenia za dopracowaną komercyjną API: rachunek subskrypcji w zamian za bardziej restrykcyjną analizę danych, co było opłacalne przy tworzeniu MVP. Dwie sugestie, jeśli chcesz skopiować tę ideę: zapisuj strategię, która przyniosła sukces, aby móc ocenić, czy pomocne są zmiany w promptach lub modelu, oraz sprawdzaj wyniki z mniej restrykcyjnych strategii bardziej dokładnie. Najnowsze wersje Ollamy mogą również przyjmować schemat JSON w polu format; sprawdź aktualną dokumentację dla swojej wersji.

Krok 3: Wyodrębnianie wielostronicowych raportów strona po stronie

Każda strona wielostronicowego raportu jest wysyłana do Ollamy jako osobisty żądanie, a wyniki z poszczególnych stron są łączone w jedną listę przed zapisaniem czegokolwiek do Postgresa. Jeśli jedna strona zawiedzie, błąd jest zapisywany, a pozostałe strony są nadal przetwarzane, zamiast przez to cały raport zawieść. Wadą jest to, że raport może zostać zapisany z brakującymi niektórymi wynikami, więc rejestrowanie nieudanych stron utrzymuje ten częściowy stan widoczny.

Krok 4: Skalowanie pracownika za pomocą grup konsumentów Redis Streams

Pracownik odczytuje zadania z strumienia Redis w ramach grupy konsumentów, dzięki czemu kilka instancji może działać równolegle, przy czym każda wiadomość jest dostarczana tylko do jednej z nich. Wiadomość jest potwierdzona za pomocą XAck dopiero po tym, jak zarówno ekstrakcja, jak i zapis do bazy danych zakończyły się pomyślnie.

streams, _ := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
    Group:    groupName,
    Consumer: consumerName,
    Streams:  []string{streamName, ">"},
    Count:    1,
    Block:    0,
}).Result()

// ... process message, extract parameters, save to DB ...
rdb.XAck(ctx, streamName, groupName, message.ID)

Zapytania ID > nigdy nie są dostarczane do grupy, Count: 1 przetwarza jedno zlecenie na raz, a Block: 0 czeka nieskończenie długo. Ponieważ potwierdzenie przychodzi na końcu, awaria powoduje, że wiadomość pozostaje na liście zleceń w oczekiwaniu, zamiast zostać utracona. Jednak wiadomości w oczekiwaniu nie są automatycznie dostarczane ponownie; należy planować użycie XAUTOCLAIM lub XPENDING w połączeniu z XCLAIM, aby przekazać stare zadania zdrowemu konsumentowi.

Błędy tymczasowe są próbowane ponownie maksymalnie trzy razy z krótkim opóźnieniem, zanim raport zostanie oznaczony jako FAILED. Błędy, których nie da się naprawić poprzez ponowne próby, takie jak brak pliku źródłowego w pamięci przechowywania, są traktowane jako krytyczne i całkowicie pomijane podczas prób.

Podsumowanie

Żadna z tych opcji sama w sobie nie jest teoretycznie najlepsza. Korzystanie z pdftoppm, stosowanie alternatywnych parserów oraz akceptacja częściowych raportów to kompromisy wynikające z rzeczywistych ograniczeń: mały budżet na MVP, wrażliwe dane pacjentów przechowywane lokalnie oraz mały zespół, który musiał szybko dostarczyć produkt.

  • Dostosuj rozdzielczość rasteryzacji do najmniejszego tekstu, który ma znaczenie.
  • Traktuj tryb JSON jako wskazówkę i analizuj kształty faktycznie generowane przez model, priorytetowo biorąc pod uwagę ścieżki.
  • Zapisuj instrukcje jako wyraźne zasady, każda skierowana na konkretny zaobserwowany błąd.
  • Izoluj błędy na poziomie poszczególnych stron i zadań, a także umożliwiaj widoczność częściowych wyników.
  • Potwierdzaj otrzymanie wiadomości z kolejki dopiero po pomyślnym wykonaniu każdego efektu ubocznego.

Literatura pokrewna

  • Od zeskanowanych raportów laboratoryjnych do strukturyzowanych danych: ekonomiczne aspekty architektury OCR — Jak firma start-up działająca w sektorze zdrowia z ograniczonym budżetem może ocenić biblioteki OCR, usługi API Google Cloud oraz samodzielnie hostowane modele językowe do analizy obrazów podczas przekształcania zeskanowań medycznych w strukturyzowane dane JSON.