Головна / Статті

Самостійно розгорнутий OCR для Vision LLM на Go: растеризація, формування запитів, парсинг

Робот Go перетворює медичні PDF-файли на JSON за допомогою моделі машинного навчання на Ollama: растеризація за допомогою pdftoppm, суворі запити, резервне аналізування та споживачі Redis Streams.

Видавець: REACTAPP.TOP7 хв читання1684 слів

Джерела: Inside the OCR Pipeline: Code, Prompts, and Failure Handling (Part 2 of 2)

Вибір самостійно розгорнутого моделі розпізнавання зображень замість API OCR в хмарі — це лише половина рішення; інша половина полягає у створенні потоку обробки, який здатний працювати навіть із тим, що насправді повертає модель. У цьому посібнику розглядається продакшн-робот на мові Go, який читає звіти лабораторій медичних досліджень та зберігає результати в Postgres. Ви побачите, як створюються растерні зображення сторінок, як формується запит до моделі, як парсити результати, що ігнорують вказану структуру, та як робот може масштабуватися без подвійної обробки завдань.

Крок 1: Перетворення PDF на зображення сторінок

Моделі візуалізації Ollama приймають зображення, а не PDF-файли, тому кожен завантажений контент спочатку перетворюється на одне зображення за сторінкою. Замість використання важкої бібліотеки для рендерингу використовується pdftoppm з набору Poppler, встановленого на хості працівника. Ця функція записує дані у тимчасовий файл, рендерує кожну сторінку у форматі PNG з роздільною здатністю 300 dpi, а потім читає та видаляє створені файли. exec.CommandContext пов’язує дочірній процес із контекстом завдання, тому скасування також зупиняє процес рендерингу.

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 виявилося оптимальним значенням: дрібний текст, такий як одиниці виміру та діапазони значень, залишається читабельним для моделі без створення занадто великих зображень сторінок. Для стислості у прикладі проігноровано кілька помилок; їх потрібно вирішувати у реальному коді. Також зверніть увагу, що filepath.Glob сортує елементи лексично, тому для десяти чи більше сторінок можливо захотітися сортувати їх за номером сторінки, який додається програмою pdftoppm.

Крок 2: Запитати у моделі конкретну структуру JSON

Метою вилучення є список результатів тестування, а структура цього списку визначає формат даних: назва, значення, одиниця виміру, діапазон значень, позначка про аномалію та оцінка впевненості, надана самою моделлю.

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"`
}

Це звичайний запит типу POST до /api/generate у Ollama. Зображення кодується у форматі base64 та вставляється до поля Images, у параметр запиту вказується категорія звіту, стрімінг вимкнений, а значення Format: „json“ встановлено. Поле Response конвертується у масив параметрів. Деякі операції в цьому фрагменті виконуються на одних рядках, тож сприймайте його як огляд алгоритму виконання, а у реальних умовах перевіряйте значення resp.StatusCode та ігноровані помилки.

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
}

У запиті, сформульованому у спрощеній формі замість офіційних термінів, наведені жорсткі правила та приклад точного масиву, який потрібно повернути:

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
  }
]

Кожне правило спрямоване на певний тип помилки. Заборона використання вигаданих значень допомагає усунути галюцинації — найнебезпечнішу помилку у медичних даних. Один об’єкт на тест запобігає об’єднанню кількох елементів у один великий блок. Копіювання значень у тому вигляді, у якому вони надруковані, зберігає одиниці вимірювання та діапазони, а пропуск розділів з інтерпретацією не дозволяє вносити описовий текст у структуровані поля. Оцінка впевненості корисна для направлення сумнівних значень до людини, хоча самооцінка моделі є лише евристикою, а не точно виміряною ймовірністю. Останнє правило — надсилати лише простий масив даних — має більше значення, ніж здається, адже сам режим JSON не гарантує дотримання структури.

Аналіз вихідних даних, які ігнорують запитувану структуру

Format: „json“ спрямовує процес генерації на створення коректного JSON, але це не є схемою. На практиці менший локальний модель може розмістити список всередині об’єкта, наприклад {"tests": [...]}, повернути один об’єкт або створити щось, що не є чистим документом, придатним для парсингу.

Замість того, щоб зазнати невдачі під час вилучення даних при першій різниці, парсер пробує все більш вільні стратегії та зупиняється при першій успішній спробі:

// 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

Спочатку відбувається суворий аналіз, тому правильно сформовані відповіді проходять найпростішим шляхом. Далі приймається будь-який ключ-обгортка, який містить ненульовий масив, а окремий об’єкт піднімається до рівня списку. Рекурсивний обхід дозволяє працювати з глибоко вкладеними структурами та перенаправляє поля з нечітким збігом назв (test_name, name, parameter_name) у одне поле. Як останній засіб, регулярний вираз виділяє окремі блоки {...} та аналізує кожен з них окремо, що допомагає відновити відповіді, пошкоджені через дублювання ключів.

Це не елегантно, і в ідеалі модель мала б завжди дотримуватися цих вимог. Це ціна використання компактної моделі самостійно замість оплати вдосконаленої комерційної API: плата за підписку в обмін на більш обережний аналіз даних, що було доцільно для MVP. Два поради, якщо ви будете копіювати цю ідею: фіксуйте, яка стратегія спрацювала, щоб можна було оцінити, чи допомагають зміни у запитах або самій моделі, та більш суворо перевіряйте результати від менш строгих стратегій. Останні версії Ollama також дозволяють використовувати схему JSON у полі format; перевірте актуальну документацію для вашої версії.

Крок 3: Витягувати багатосторінкові звіти поступово

Кожна сторінка багатосторінкового звіту надсилається до Ollama як окремий запит, а результати для кожної сторінки об’єднуються в один список перед тим, як щось буде записано до Postgres. Якщо одна сторінка не обробляється, помилка фіксується, а решта сторінок все одно обробляються, щоб не збиватися з процесу створення всього звіту. Недоліком є те, що звіт може бути збережений із відсутніми деякими результатами, тож фіксація невдало оброблених сторінок зберігає цей частковий стан видимим.

Крок 4: Масштабування працівника за допомогою груп споживачів Redis Streams

Працівник читає завдання з Redis Stream у рамках групи споживачів, тому кілька інстанцій можуть працювати паралельно, при цьому кожне повідомлення надсилається лише одній з них. Повідомлення підтверджується за допомогою XAck лише після того, як вдалося його отримати та записати у базу даних.

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)

Запити ID > ніколи не доставляються до групи, Count: 1 обробляє по одній завданню за раз, а Block: 0 чекає необмежено довго. Оскільки підтвердження надходить останнім, збій залишає повідомлення у списку очікуючих записів, замість того щоб втратити його. Однак очікуючі повідомлення не доставляються знову самостійно; потрібно використовувати XAUTOCLAIM або XPENDING разом із XCLAIM, щоб передати застарілі завдання справному споживачеві.

Тимчасові помилки пробуваються ще три рази з коротким інтервалом перед тим, як звіт буде позначений як FAILED. Помилки, які неможливо виправити шляхом повторних спроб, наприклад відсутність вихідного файлу у сховищі, вважаються фатальними та повністю ігноруються під час повторних спроб.

Підсумок

Жоден із цих варіантів сам по собі не є теоретично найкращим. Використання pdftoppm, поєднання резервних парсерів та прийняття часткових звітів — це компроміси, які враховують реальні обмеження: малий бюджет на створення MVP, конфіденційні дані пацієнтів, які зберігаються внутрішньо, та невелика команда, якій потрібно було швидко випустити продукт.

  • Налаштовуйте роздільну здатність растеризації відповідно до найменшого значущого тексту.
  • Розглядайте режим JSON як підказку та парсуйте форми, які насправді генерується модель, надаючи пріоритет суворим шляхам.
  • Формулюйте інструкції як чіткі правила, кожне з яких спрямоване на вирішення конкретної проблеми.
  • Ізолюйте проблеми за кожною сторінкою та завданням, а також робіть часткові результати видимими.
  • Підтверджуйте наявність повідомлень у черзі лише після того, як усі побічні ефекти виконані успішно.