#include "OcrUtils.h" #include #include #include #include #include #include #include #include namespace { // Кэш байт rus.traineddata. Грузим файл через Qt (Unicode-aware пути) и // отдаём Tesseract в виде in-memory blob через Init(data, size, lang, ...). // Это обходит проблему, что на Windows Tesseract открывает traineddata через // `fopen`, который использует ANSI-кодировку (cp1251 на русской системе). // Если путь к exe содержит кириллицу (например, OneDrive\Рабочий стол) — // fopen не находит файл и Init возвращает -1. Memory-init не зависит от пути. const QByteArray &tessdataBytes() { static QByteArray bytes; static bool tried = false; if (tried) return bytes; tried = true; const QString appDir = QCoreApplication::applicationDirPath(); const QStringList candidates = { appDir + "/tessdata/rus.traineddata", // канонический путь appDir + "/rus.traineddata", // legacy: рядом с exe }; for (const QString &path : candidates) { QFile f(path); if (!f.open(QIODevice::ReadOnly)) continue; bytes = f.readAll(); f.close(); if (!bytes.isEmpty()) { qDebug() << "[OcrUtils] loaded rus.traineddata from" << path << "(" << bytes.size() << "bytes)"; return bytes; } } qWarning() << "[OcrUtils] rus.traineddata NOT FOUND. Checked:" << candidates << "— OCR будет работать в fallback-режиме без поиска текста."; return bytes; } // Расстояние Левенштейна на QString — нужен для нечёткого сравнения OCR-вывода // с искомым словом. Для "Подтверждаю" допускаем 1-2 ошибки. int editDistance(const QString &a, const QString &b) { const int n = a.size(); const int m = b.size(); if (n == 0) return m; if (m == 0) return n; QVector prev(m + 1), cur(m + 1); for (int j = 0; j <= m; ++j) prev[j] = j; for (int i = 1; i <= n; ++i) { cur[0] = i; for (int j = 1; j <= m; ++j) { const int cost = a[i - 1] == b[j - 1] ? 0 : 1; cur[j] = qMin(qMin(cur[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost); } prev.swap(cur); } return prev[m]; } bool fuzzyMatch(const QString &word, const QString &needle) { if (word.isEmpty() || needle.isEmpty()) return false; const QString w = word.toLower(); const QString n = needle.toLower(); if (w.contains(n) || n.contains(w)) return true; // Допускаем до 20% ошибок (но не меньше 1) — Tesseract на «Подтверждаю» иногда // даёт «Подтверждак»/«Подтверждак)» из-за оранжевого фона и сглаживания. const int tolerance = qMax(1, n.size() / 5); return editDistance(w, n) <= tolerance; } } // namespace namespace OcrUtils { QPoint findTextCenter(const QByteArray &png, const QString &needle, int yMin, int yMax) { if (png.isEmpty() || needle.isEmpty()) return {-1, -1}; QElapsedTimer timer; timer.start(); Pix *pix = pixReadMem(reinterpret_cast(png.constData()), static_cast(png.size())); if (!pix) { qWarning() << "[OcrUtils] pixReadMem failed"; return {-1, -1}; } const int fullW = pixGetWidth(pix); const int fullH = pixGetHeight(pix); int cropY = 0; Pix *target = pix; Pix *cropped = nullptr; if (yMin > 0 || (yMax > 0 && yMax < fullH)) { const int y0 = qMax(0, yMin); const int y1 = (yMax > 0 && yMax <= fullH) ? yMax : fullH; if (y1 > y0) { BOX *box = boxCreate(0, y0, fullW, y1 - y0); cropped = pixClipRectangle(pix, box, nullptr); boxDestroy(&box); if (cropped) { target = cropped; cropY = y0; } } } // Гоняем OCR двумя проходами: оригинал + инверсия. Текст бывает разный — // тёмный на светлом (заголовки/поля) и светлый на цветном (sticky-action // кнопка, оранжевая/синяя). Инверсия белого-на-оранжевом превращает её в // тёмное-на-голубом, что Tesseract разбирает на порядок надёжнее. auto runPass = [&](Pix *src, bool invert) -> QPoint { Pix *gray = pixConvertTo8(src, 0); if (!gray) return {-1, -1}; Pix *prepared = gray; Pix *inverted = nullptr; if (invert) { inverted = pixInvert(nullptr, gray); if (inverted) prepared = inverted; } const QByteArray &td = tessdataBytes(); if (td.isEmpty()) { if (inverted) pixDestroy(&inverted); pixDestroy(&gray); return {-1, -1}; } auto *api = new tesseract::TessBaseAPI(); // Init(data, size, lang, ...) — грузим traineddata из памяти, чтобы // обойти fopen на путях с кириллицей (Windows ANSI-API). if (api->Init(td.constData(), td.size(), "rus", tesseract::OEM_DEFAULT, nullptr, 0, nullptr, nullptr, false, nullptr)) { qWarning() << "[OcrUtils] Tesseract Init(memory) failed —" << "traineddata повреждён или несовместимая версия"; delete api; if (inverted) pixDestroy(&inverted); pixDestroy(&gray); return {-1, -1}; } api->SetImage(prepared); api->Recognize(nullptr); QPoint hit(-1, -1); tesseract::ResultIterator *ri = api->GetIterator(); if (ri) { do { const char *raw = ri->GetUTF8Text(tesseract::RIL_WORD); if (!raw) continue; const QString word = QString::fromUtf8(raw).trimmed(); delete[] raw; if (!fuzzyMatch(word, needle)) continue; int x1, y1, x2, y2; ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2); const int cx = (x1 + x2) / 2; const int cy = (y1 + y2) / 2 + cropY; qDebug() << "[OcrUtils] found" << needle << "→" << word << (invert ? "[inverted]" : "[normal]") << "at (" << cx << "," << cy << ")"; hit = QPoint(cx, cy); break; } while (ri->Next(tesseract::RIL_WORD)); } api->End(); delete api; if (inverted) pixDestroy(&inverted); pixDestroy(&gray); return hit; }; QPoint result = runPass(target, /*invert=*/false); if (result.x() < 0) { result = runPass(target, /*invert=*/true); } if (cropped) pixDestroy(&cropped); pixDestroy(&pix); if (result.x() < 0) { qDebug() << "[OcrUtils]" << needle << "not found in" << timer.elapsed() << "ms"; } else { qDebug() << "[OcrUtils]" << needle << "located in" << timer.elapsed() << "ms"; } return result; } QString recognizeText(const QByteArray &pngData) { auto *api = new tesseract::TessBaseAPI(); const QByteArray &td = tessdataBytes(); int initResult; if (td.isEmpty()) { const QByteArray appDirBytes = QCoreApplication::applicationDirPath().toUtf8(); initResult = api->Init(appDirBytes.constData(), "rus+eng"); } else { initResult = api->Init(td.constData(), td.size(), "rus+eng", tesseract::OEM_DEFAULT, nullptr, 0, nullptr, nullptr, false, nullptr); } if (initResult) { qWarning() << "[OcrUtils::recognizeText] Failed to init Tesseract"; delete api; return {}; } Pix *pix = pixReadMem(reinterpret_cast(pngData.constData()), static_cast(pngData.size())); if (!pix) { qWarning() << "[OcrUtils::recognizeText] Failed to read image"; api->End(); delete api; return {}; } Pix *gray = pixConvertRGBToGray(pix, 0.0f, 0.0f, 0.0f); pixDestroy(&pix); if (!gray) { qWarning() << "[OcrUtils::recognizeText] Failed to convert to grayscale"; api->End(); delete api; return {}; } api->SetImage(gray); api->SetSourceResolution(300); char *text = api->GetUTF8Text(); QString result = QString::fromUtf8(text).trimmed(); delete[] text; pixDestroy(&gray); api->End(); delete api; return result; } QString extractTransactionId(const QString &ocrText) { QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-z0-9\-]+(?:[^\S\n]+[A-Za-z0-9\-]+)?))"); if (auto m = reId.match(ocrText); m.hasMatch()) { QString id = m.captured(1); id.remove(' '); id.remove(QChar(0x00A0)); return id; } QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))"); if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1); return {}; } } // namespace OcrUtils