diff --git a/PRODUCT.md b/PRODUCT.md index dcb3e03..40d0301 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -10,7 +10,7 @@ product ## Product Purpose -Приложение подключает одно или несколько выбранных окон, позволяет откалибровать области распознавания на снимке и показывает извлечённые названия, цены и количества. Успех прототипа означает, что весь путь от выбора окна до видимого результата работает локально на macOS и Windows. +Приложение подключает одно или несколько выбранных окон, подтверждает открытое окно продажи по его нижнему элементу, читает имя торговца и накапливает названия и цены из tooltip при ручном наведении на предметы. Успех прототипа означает, что весь путь от выбора окна до видимого результата работает локально на macOS и Windows. ## Brand Personality diff --git a/README.md b/README.md index 5fca666..fdd6ec2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # L2 Market Parser -Кроссплатформенный локальный прототип для захвата нескольких окон Lineage II, калибровки областей на скриншоте и OCR-распознавания результата. +Кроссплатформенный локальный прототип для захвата нескольких окон Lineage II и сбора предметов из tooltip торгового окна. ## Запуск @@ -19,9 +19,13 @@ npm run dev 1. В разделе «Источники» нажми «Добавить окно» для каждого окна игры. 2. В разделе «Калибровка» сделай снимок или загрузи готовый скриншот. -3. Выдели маленький неизменяемый элемент торгового окна как якорь. -4. Выдели области названия, цены и количества. -5. Сохрани калибровку и нажми «Распознать все». +3. Выдели постоянный нижний элемент продажи, например `Adena + Confirm`, как маркер продажи. По возможности не захватывай изменяемые числа. +4. Выдели имя торговца в верхней панели цели. +5. Выдели широкую область всех возможных положений tooltip, не захватывая нижний `Price` основного окна. +6. Внутри tooltip обведи только постоянную надпись `Price :`. +7. Отдельно выдели точную строку названия и точную строку цены. Для названия лучше использовать длинный предмет и оставить запас справа. +8. Сохрани калибровку, нажми «Начать сбор» и вернись в игру. +9. Наводи курсор на каждый предмет. Уникальные пары `название + цена` будут накапливаться в таблице. Калибровка хранится в `localStorage` текущего браузера. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере. @@ -29,8 +33,9 @@ npm run dev - Источники выбираются вручную после каждого обновления страницы. - Одна калибровка рассчитана на один масштаб интерфейса Lineage II. -- Распознавание запускается вручную, фонового сканирования и отправки на сервер пока нет. -- Для нескольких строк товара нужно отдельно выделить поля каждой строки. +- Сбор работает, пока открыта страница прототипа и он не остановлен кнопкой «Остановить сбор». +- Курсор перемещает пользователь, приложение не управляет игрой и не эмулирует ввод. +- Отправки на сервер пока нет. ## Проверки diff --git a/eng.traineddata b/eng.traineddata new file mode 100644 index 0000000..6d11002 Binary files /dev/null and b/eng.traineddata differ diff --git a/index.html b/index.html index 465c944..ba152d7 100644 --- a/index.html +++ b/index.html @@ -48,7 +48,7 @@

Образец интерфейса

Калибровка

-

Сначала отметь небольшой неизменяемый якорь, затем области с названием и ценой.

+

Отметь окно продажи, верхнее имя торговца и точные строки всплывающего описания предмета.

@@ -73,20 +73,16 @@
-
- - -
- -

Протяни рамку вокруг небольшого постоянного элемента торгового окна.

+

Выдели постоянный нижний фрагмент окна продажи, например подпись Adena и кнопку Confirm. Числа лучше не включать.

@@ -116,12 +112,13 @@

Проверка пайплайна

-

Распознанные данные

-

Для каждого окна ищется якорь, затем OCR читает откалиброванные области.

+

Собранные предметы

+

Запусти сбор и наведи мышь на предметы в игре. Каждый новый tooltip будет добавлен в таблицу.

- + +
diff --git a/src/main.js b/src/main.js index eaa7e14..8b8a714 100644 --- a/src/main.js +++ b/src/main.js @@ -2,32 +2,46 @@ import "./style.css"; import { isCalibrationReady, isRectInside, + parseMerchantText, + parseTooltipText, rectFromPoints, resolveFieldRect, + resolveScreenRect, } from "./parser.js"; import { findAnchor, onOcrProgress, - recognizeField, + recognizeText, terminateVision, } from "./vision.js"; -const STORAGE_KEY = "l2-market-parser.calibration.v1"; -const fieldNames = { - item: "Название", - price: "Цена", - quantity: "Количество", - text: "Текст", +const STORAGE_KEY = "l2-market-parser.calibration.v3"; +const regionNames = { + saleAnchor: "Маркер продажи", + merchant: "Имя торговца", + tooltipSearch: "Зона поиска tooltip", + tooltipAnchor: "Якорь Price :", + itemName: "Название предмета", + itemPrice: "Строка цены", }; function emptyCalibration() { - return { anchor: null, fields: [], threshold: 0.8 }; + return { + saleAnchor: null, + merchantRegion: null, + tooltipSearchRegion: null, + tooltipAnchor: null, + itemNameRegion: null, + itemPriceRegion: null, + referenceSize: null, + threshold: 0.8, + }; } function loadCalibration() { try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); - return saved?.anchor && Array.isArray(saved.fields) + return saved?.saleAnchor ? { ...emptyCalibration(), ...saved } : emptyCalibration(); } catch { @@ -42,7 +56,7 @@ const state = { calibration: loadCalibration(), drawing: null, results: [], - busy: false, + collecting: false, }; const elements = { @@ -57,7 +71,6 @@ const elements = { canvasEmpty: document.querySelector("#canvas-empty"), canvas: document.querySelector("#calibration-canvas"), regionType: document.querySelector("#region-type"), - regionLabel: document.querySelector("#region-label"), drawHint: document.querySelector("#draw-hint"), threshold: document.querySelector("#match-threshold"), thresholdValue: document.querySelector("#threshold-value"), @@ -66,6 +79,7 @@ const elements = { saveCalibration: document.querySelector("#save-calibration"), clearCalibration: document.querySelector("#clear-calibration"), recognizeAll: document.querySelector("#recognize-all"), + clearResults: document.querySelector("#clear-results"), copyResults: document.querySelector("#copy-results"), resultsList: document.querySelector("#results-list"), }; @@ -337,45 +351,92 @@ function cropCanvas(source, rect) { function addRegion(rect) { const type = elements.regionType.value; + state.calibration.referenceSize = { + width: state.referenceCanvas.width, + height: state.referenceCanvas.height, + }; - if (type === "anchor") { - state.calibration.anchor = { + if (type === "saleAnchor") { + state.calibration.saleAnchor = { ...rect, image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"), }; - state.calibration.fields = []; - elements.regionType.value = "item"; - updateDrawHint(); - setStatus("Якорь выбран. Теперь отметь поля", "success"); - } else { - const anchor = state.calibration.anchor; - if (!anchor) { - setStatus("Сначала выдели якорь", "error"); + state.calibration.tooltipSearchRegion = null; + elements.regionType.value = "merchant"; + setStatus("Маркер продажи выбран. Теперь выдели имя торговца", "success"); + } else if (type === "merchant") { + state.calibration.merchantRegion = { + xRatio: rect.x / state.referenceCanvas.width, + yRatio: rect.y / state.referenceCanvas.height, + widthRatio: rect.width / state.referenceCanvas.width, + heightRatio: rect.height / state.referenceCanvas.height, + }; + elements.regionType.value = "tooltipSearch"; + setStatus("Имя торговца выбрано. Теперь выдели зону появления tooltip", "success"); + } else if (type === "tooltipSearch") { + if (!state.calibration.saleAnchor) { + setStatus("Сначала выдели маркер продажи", "error"); return; } - const sameTypeCount = state.calibration.fields.filter((field) => field.type === type).length; - state.calibration.fields.push({ - id: crypto.randomUUID(), - type, - label: elements.regionLabel.value.trim() || `${fieldNames[type]} ${sameTypeCount + 1}`, - offsetX: rect.x - anchor.x, - offsetY: rect.y - anchor.y, + state.calibration.tooltipSearchRegion = { + offsetX: rect.x - state.calibration.saleAnchor.x, + offsetY: rect.y - state.calibration.saleAnchor.y, width: rect.width, height: rect.height, - }); - elements.regionLabel.value = ""; - setStatus("Область добавлена", "success"); + }; + elements.regionType.value = "tooltipAnchor"; + setStatus("Зона выбрана. Теперь обведи только постоянную надпись Price :", "success"); + } else if (type === "tooltipAnchor") { + state.calibration.tooltipAnchor = { + ...rect, + image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"), + }; + state.calibration.itemNameRegion = null; + state.calibration.itemPriceRegion = null; + elements.regionType.value = "itemName"; + setStatus("Якорь Price выбран. Теперь выдели точную строку названия", "success"); + } else if (type === "itemName" || type === "itemPrice") { + if (!state.calibration.tooltipAnchor) { + setStatus("Сначала выдели якорь Price :", "error"); + return; + } + + state.calibration[type === "itemName" ? "itemNameRegion" : "itemPriceRegion"] = { + offsetX: rect.x - state.calibration.tooltipAnchor.x, + offsetY: rect.y - state.calibration.tooltipAnchor.y, + width: rect.width, + height: rect.height, + }; + if (type === "itemName") { + elements.regionType.value = "itemPrice"; + setStatus("Название выбрано. Теперь выдели строку Price вместе с ценой", "success"); + } else { + setStatus("Точная область цены выбрана", "success"); + } } + updateDrawHint(); renderCalibration(); } -function fieldAbsoluteRect(field) { - return resolveFieldRect( - { x: state.calibration.anchor.x, y: state.calibration.anchor.y }, - field, - ); +function referenceMerchantRect() { + const size = state.calibration.referenceSize; + return size && state.calibration.merchantRegion + ? resolveScreenRect(state.calibration.merchantRegion, size.width, size.height) + : null; +} + +function referenceTooltipSearchRect() { + return state.calibration.saleAnchor && state.calibration.tooltipSearchRegion + ? resolveFieldRect(state.calibration.saleAnchor, state.calibration.tooltipSearchRegion) + : null; +} + +function referenceTooltipFieldRect(region) { + return state.calibration.tooltipAnchor && region + ? resolveFieldRect(state.calibration.tooltipAnchor, region) + : null; } function drawBox(context, rect, label, color, dashed = false) { @@ -403,11 +464,33 @@ function drawCalibration() { context.clearRect(0, 0, elements.canvas.width, elements.canvas.height); context.drawImage(state.referenceCanvas, 0, 0); - if (state.calibration.anchor) { - drawBox(context, state.calibration.anchor, "Якорь", "oklch(0.72 0.16 65)"); - state.calibration.fields.forEach((field) => { - drawBox(context, fieldAbsoluteRect(field), field.label, "oklch(0.65 0.17 250)"); - }); + if (state.calibration.saleAnchor) { + drawBox(context, state.calibration.saleAnchor, "Продажа", "oklch(0.72 0.16 65)"); + } + if (referenceMerchantRect()) { + drawBox(context, referenceMerchantRect(), "Торговец", "oklch(0.65 0.17 250)"); + } + if (referenceTooltipSearchRect()) { + drawBox(context, referenceTooltipSearchRect(), "Поиск tooltip", "oklch(0.64 0.14 155)"); + } + if (state.calibration.tooltipAnchor) { + drawBox(context, state.calibration.tooltipAnchor, "Price :", "oklch(0.68 0.17 25)"); + } + if (referenceTooltipFieldRect(state.calibration.itemNameRegion)) { + drawBox( + context, + referenceTooltipFieldRect(state.calibration.itemNameRegion), + "Название", + "oklch(0.66 0.14 300)", + ); + } + if (referenceTooltipFieldRect(state.calibration.itemPriceRegion)) { + drawBox( + context, + referenceTooltipFieldRect(state.calibration.itemPriceRegion), + "Цена", + "oklch(0.67 0.14 210)", + ); } if (state.drawing) { @@ -418,11 +501,15 @@ function drawCalibration() { function updateDrawHint() { const hints = { - anchor: "Протяни рамку вокруг небольшого постоянного элемента торгового окна.", - item: "Выдели только строку с названием предмета.", - price: "Выдели цену вместе с разделителями разрядов.", - quantity: "Выдели количество предметов.", - text: "Выдели одну строку текста.", + saleAnchor: + "Выдели постоянный нижний фрагмент окна продажи, например подпись Adena и кнопку Confirm. Числа лучше не включать.", + merchant: "Выдели только имя персонажа в верхней панели цели, без уровня клана.", + tooltipSearch: + "Выдели широкую область всех возможных положений tooltip. Нижний блок Price окна продажи не включай.", + tooltipAnchor: "Обведи только постоянную надпись Price : внутри tooltip, без изменяемого числа.", + itemName: + "Выдели точную верхнюю строку названия. Возьми скриншот с длинным названием и оставь запас справа.", + itemPrice: "Выдели точную строку Price вместе с числом и словом Adena.", }; elements.drawHint.textContent = hints[elements.regionType.value]; } @@ -430,41 +517,42 @@ function updateDrawHint() { function renderCalibration() { elements.threshold.value = String(state.calibration.threshold); elements.thresholdValue.value = state.calibration.threshold.toFixed(2); + const regions = [ + ["saleAnchor", "saleAnchor", "Шаблон поиска окна продажи"], + ["merchant", "merchantRegion", "OCR только после продажи"], + ["tooltipSearch", "tooltipSearchRegion", "Ограничивает поиск всплывающего окна"], + ["tooltipAnchor", "tooltipAnchor", "Постоянная надпись Price :"], + ["itemName", "itemNameRegion", "Точная строка OCR"], + ["itemPrice", "itemPriceRegion", "Точная строка OCR"], + ]; elements.regionCount.textContent = String( - state.calibration.fields.length + (state.calibration.anchor ? 1 : 0), + regions.filter(([, key]) => state.calibration[key]).length, ); elements.regionList.replaceChildren(); - if (!state.calibration.anchor) { - const note = document.createElement("p"); - note.className = "muted-copy"; - note.textContent = "Якорь ещё не выбран."; - elements.regionList.append(note); - } else { - const anchor = document.createElement("div"); - anchor.className = "region-row"; - anchor.innerHTML = "ЯкорьШаблон поиска"; - elements.regionList.append(anchor); - } - - state.calibration.fields.forEach((field) => { + regions.forEach(([type, key, description]) => { const row = document.createElement("div"); row.className = "region-row"; const copy = document.createElement("span"); const name = document.createElement("strong"); - name.textContent = field.label; - const type = document.createElement("small"); - type.textContent = fieldNames[field.type]; - copy.append(name, type); + name.textContent = regionNames[type]; + const detail = document.createElement("small"); + detail.textContent = state.calibration[key] ? description : "Не выбрано"; + copy.append(name, detail); const remove = document.createElement("button"); remove.className = "icon-button"; remove.type = "button"; remove.textContent = "×"; - remove.setAttribute("aria-label", `Удалить ${field.label}`); + remove.disabled = !state.calibration[key]; + remove.setAttribute("aria-label", `Удалить ${regionNames[type]}`); remove.addEventListener("click", () => { - state.calibration.fields = state.calibration.fields.filter((item) => item.id !== field.id); + state.calibration[key] = null; + if (key === "saleAnchor") state.calibration.tooltipSearchRegion = null; + if (key === "tooltipAnchor") { + state.calibration.itemNameRegion = null; + state.calibration.itemPriceRegion = null; + } renderCalibration(); - drawCalibration(); }); row.append(copy, remove); elements.regionList.append(row); @@ -475,7 +563,7 @@ function renderCalibration() { function saveCalibration() { if (!isCalibrationReady(state.calibration)) { - setStatus("Для сохранения нужны якорь и хотя бы одно поле", "error"); + setStatus("Нужно заполнить все 6 областей калибровки", "error"); return; } @@ -488,6 +576,8 @@ function saveCalibration() { } function clearCalibration() { + state.collecting = false; + elements.recognizeAll.textContent = "Начать сбор"; state.calibration = emptyCalibration(); localStorage.removeItem(STORAGE_KEY); renderCalibration(); @@ -511,7 +601,7 @@ function imageToCanvas(dataUrl) { function renderResults() { elements.resultsList.replaceChildren(); - elements.copyResults.disabled = !state.results.length; + elements.copyResults.disabled = !state.results.some((result) => result.items.length); if (!state.results.length) { const empty = document.createElement("div"); @@ -519,7 +609,7 @@ function renderResults() { const title = document.createElement("strong"); title.textContent = "Результатов пока нет"; const text = document.createElement("span"); - text.textContent = "Подключи окна, сохрани калибровку и запусти распознавание."; + text.textContent = "Запусти сбор, вернись в игру и наведи курсор на предмет."; empty.append(title, text); elements.resultsList.append(empty); return; @@ -533,14 +623,14 @@ function renderResults() { const title = document.createElement("h3"); title.textContent = result.source; const meta = document.createElement("p"); - meta.textContent = result.found - ? `Якорь найден, совпадение ${(result.score * 100).toFixed(1)}%` - : `Якорь не найден, лучшее совпадение ${(result.score * 100).toFixed(1)}%`; + meta.textContent = result.saleOpen + ? `Торговец: ${result.merchant || "распознаётся"}, маркер ${(result.score * 100).toFixed(1)}%` + : `Окно продажи не найдено, лучшее совпадение ${(result.score * 100).toFixed(1)}%`; heading.append(title, meta); const badge = document.createElement("span"); badge.className = "result-badge"; - badge.dataset.tone = result.found ? "success" : "error"; - badge.textContent = result.found ? "Распознано" : "Не найдено"; + badge.dataset.tone = result.saleOpen ? "success" : "error"; + badge.textContent = result.saleOpen ? `${result.items.length} предметов` : "Нет продажи"; header.append(heading, badge); section.append(header); @@ -549,49 +639,180 @@ function renderResults() { error.className = "result-error"; error.textContent = result.error; section.append(error); - } else if (result.fields?.length) { + } else if (result.saleOpen && result.items.length) { const table = document.createElement("table"); - table.innerHTML = "ПолеЗначениеOCRУверенность"; + table.innerHTML = "ПредметЦенаПоследний разУверенность OCR"; const body = document.createElement("tbody"); - result.fields.forEach((field) => { + result.items.forEach((item) => { const row = document.createElement("tr"); - const label = document.createElement("td"); - label.textContent = field.label; - const value = document.createElement("td"); - value.textContent = - typeof field.value === "number" ? field.value.toLocaleString("ru-RU") : field.value || "Не распознано"; - const raw = document.createElement("td"); - raw.textContent = field.text || "пусто"; + const name = document.createElement("td"); + name.textContent = item.name; + const price = document.createElement("td"); + price.textContent = `${item.priceAdena.toLocaleString("ru-RU")} Adena`; + const seen = document.createElement("td"); + seen.textContent = new Date(item.seenAt).toLocaleTimeString("ru-RU"); const confidence = document.createElement("td"); - confidence.textContent = `${field.confidence}%`; - row.append(label, value, raw, confidence); + confidence.textContent = `${item.confidence}%`; + row.append(name, price, seen, confidence); body.append(row); }); table.append(body); section.append(table); + } else if (result.saleOpen) { + const hint = document.createElement("p"); + hint.className = "result-hint"; + hint.textContent = "Окно продажи найдено. Наводи курсор на иконки предметов и задерживай его до появления tooltip."; + section.append(hint); } elements.resultsList.append(section); }); } -async function recognizeAll() { +function resultFor(source) { + let result = state.results.find((item) => item.sourceId === source.id); + if (!result) { + result = { + sourceId: source.id, + source: source.name, + saleOpen: false, + score: 0, + merchant: "", + items: [], + merchantScan: 0, + misses: 0, + error: null, + }; + state.results.push(result); + } + return result; +} + +function delay(milliseconds) { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +} + +async function scanSource(source, saleTemplate, tooltipTemplate) { + const result = resultFor(source); + + try { + const frame = await captureSource(source); + const match = await findAnchor(frame, saleTemplate); + result.score = match.score; + result.error = null; + + if (match.score < state.calibration.threshold) { + result.misses += 1; + if (result.misses >= 3) { + result.saleOpen = false; + result.merchant = ""; + result.items = []; + } + return; + } + + result.misses = 0; + result.saleOpen = true; + + const merchantRect = resolveScreenRect( + state.calibration.merchantRegion, + frame.width, + frame.height, + ); + const tooltipSearchRect = resolveFieldRect( + match, + state.calibration.tooltipSearchRegion, + ); + + if ( + !isRectInside(merchantRect, frame.width, frame.height) || + !isRectInside(tooltipSearchRect, frame.width, frame.height) + ) { + result.error = "Одна из областей вышла за границы кадра. Сделай калибровку заново."; + return; + } + + result.merchantScan += 1; + if (!result.merchant || result.merchantScan % 10 === 0) { + const merchantOcr = await recognizeText(frame, merchantRect, { singleLine: true }); + const merchant = parseMerchantText(merchantOcr.text); + if (merchant && result.merchant && merchant !== result.merchant) result.items = []; + if (merchant) result.merchant = merchant; + } + + const tooltipSearch = cropCanvas(frame, tooltipSearchRect); + const tooltipMatch = await findAnchor(tooltipSearch, tooltipTemplate); + if (tooltipMatch.score < state.calibration.threshold) return; + + const tooltipPosition = { + x: tooltipSearchRect.x + tooltipMatch.x, + y: tooltipSearchRect.y + tooltipMatch.y, + }; + const nameRect = resolveFieldRect( + tooltipPosition, + state.calibration.itemNameRegion, + ); + const priceRect = resolveFieldRect( + tooltipPosition, + state.calibration.itemPriceRegion, + ); + + if ( + !isRectInside(nameRect, frame.width, frame.height) || + !isRectInside(priceRect, frame.width, frame.height) + ) { + result.error = "Строки tooltip вышли за границы кадра. Проверь их калибровку."; + return; + } + + const nameOcr = await recognizeText(frame, nameRect, { singleLine: true }); + const priceOcr = await recognizeText(frame, priceRect, { singleLine: true }); + const item = parseTooltipText(`${nameOcr.text}\n${priceOcr.text}`); + if (!item) return; + const confidence = Math.round((nameOcr.confidence + priceOcr.confidence) / 2); + + const key = `${item.name.toLocaleLowerCase("en-US")}:${item.priceAdena}`; + const existing = result.items.find((entry) => entry.key === key); + const seenAt = new Date().toISOString(); + + if (existing) { + existing.seenAt = seenAt; + existing.confidence = confidence; + } else { + result.items.push({ + ...item, + key, + confidence, + seenAt, + }); + setStatus(`Добавлен предмет: ${item.name}`, "success"); + } + } catch (error) { + result.error = error.message || "Ошибка распознавания"; + } +} + +async function toggleCollection() { + if (state.collecting) { + state.collecting = false; + elements.recognizeAll.textContent = "Начать сбор"; + setStatus("Сбор остановлен", "neutral"); + return; + } + const activeSources = state.sources.filter((source) => source.active); if (!activeSources.length) { setStatus("Сначала добавь хотя бы одно окно", "error"); return; } if (!isCalibrationReady(state.calibration)) { - setStatus("Сначала сохрани якорь и области распознавания", "error"); + setStatus("Сначала откалибруй продажу, торговца и tooltip", "error"); return; } - if (state.busy) return; - state.busy = true; - elements.recognizeAll.disabled = true; - elements.recognizeAll.textContent = "Распознаю…"; - state.results = []; - renderResults(); + state.collecting = true; + elements.recognizeAll.textContent = "Остановить сбор"; + setStatus("Через 2 секунды начнётся сбор. Вернись в игру", "working"); onOcrProgress((progress) => { if (progress.status === "recognizing text") { @@ -600,55 +821,36 @@ async function recognizeAll() { }); try { - const template = await imageToCanvas(state.calibration.anchor.image); + await delay(2000); + const [saleTemplate, tooltipTemplate] = await Promise.all([ + imageToCanvas(state.calibration.saleAnchor.image), + imageToCanvas(state.calibration.tooltipAnchor.image), + ]); - for (const source of activeSources) { - setStatus(`Ищу торговое окно: ${source.name}`, "working"); - const frame = await captureSource(source); - const match = await findAnchor(frame, template); - const result = { - source: source.name, - capturedAt: new Date().toISOString(), - score: match.score, - found: match.score >= state.calibration.threshold, - fields: [], - }; - - if (!result.found) { - result.error = "Проверь масштаб интерфейса, выбранный якорь или уменьши порог совпадения."; - } else { - for (const field of state.calibration.fields) { - const rect = resolveFieldRect(match, field); - if (!isRectInside(rect, frame.width, frame.height)) { - result.fields.push({ - ...field, - text: "", - value: null, - confidence: 0, - error: "Область вышла за границы кадра", - }); - continue; - } - - setStatus(`${source.name}: ${field.label}`, "working"); - result.fields.push({ ...field, ...(await recognizeField(frame, rect, field.type)) }); - } + while (state.collecting) { + const sources = state.sources.filter((source) => source.active); + for (const source of sources) { + if (!state.collecting) break; + setStatus(`Сканирую: ${source.name}`, "working"); + await scanSource(source, saleTemplate, tooltipTemplate); } - - state.results.push(result); renderResults(); + if (state.collecting) await delay(700); } - - setStatus("Распознавание завершено", "success"); } catch (error) { setStatus(error.message || "Ошибка распознавания", "error"); } finally { - state.busy = false; - elements.recognizeAll.disabled = false; - elements.recognizeAll.textContent = "Распознать все"; + state.collecting = false; + elements.recognizeAll.textContent = "Начать сбор"; } } +function clearResults() { + state.results = []; + renderResults(); + setStatus("Результаты очищены", "neutral"); +} + async function copyResults() { try { await navigator.clipboard.writeText(JSON.stringify(state.results, null, 2)); @@ -670,7 +872,8 @@ elements.threshold.addEventListener("input", (event) => { }); elements.saveCalibration.addEventListener("click", saveCalibration); elements.clearCalibration.addEventListener("click", clearCalibration); -elements.recognizeAll.addEventListener("click", recognizeAll); +elements.recognizeAll.addEventListener("click", toggleCollection); +elements.clearResults.addEventListener("click", clearResults); elements.copyResults.addEventListener("click", copyResults); elements.canvas.addEventListener("pointerdown", (event) => { diff --git a/src/parser.js b/src/parser.js index 43bb600..f5ac74f 100644 --- a/src/parser.js +++ b/src/parser.js @@ -16,6 +16,15 @@ export function resolveFieldRect(anchorPosition, field) { }; } +export function resolveScreenRect(region, width, height) { + return { + x: Math.round(region.xRatio * width), + y: Math.round(region.yRatio * height), + width: Math.round(region.widthRatio * width), + height: Math.round(region.heightRatio * height), + }; +} + export function normalizeOcrText(text) { return text.replace(/\s+/g, " ").trim(); } @@ -31,12 +40,46 @@ export function parseFieldValue(type, text) { return digits ? Number(digits) : null; } +export function parseMerchantText(text) { + return text + .split(/\r?\n/) + .map(normalizeOcrText) + .find(Boolean) ?? ""; +} + +export function parseTooltipText(text) { + const lines = text + .split(/\r?\n/) + .map(normalizeOcrText) + .filter(Boolean); + const priceIndex = lines.findIndex((line) => /pr[i1l]ce\s*[:;]?/i.test(line)); + + if (priceIndex < 0) return null; + + const priceLine = lines[priceIndex]; + const priceMatch = priceLine.match(/pr[i1l]ce\s*[:;]?\s*([\d\s,.'`]+)\s*adena/i); + const digits = priceMatch?.[1].replace(/\D/g, "") ?? ""; + let name = lines[priceIndex - 1] ?? ""; + + if (!name) { + name = priceLine.slice(0, priceLine.search(/pr[i1l]ce/i)).trim(); + } + + if (!name || !digits) return null; + + return { name, priceAdena: Number(digits), rawText: lines.join("\n") }; +} + export function isCalibrationReady(calibration) { return Boolean( - calibration?.anchor?.image && - calibration.anchor.width >= 4 && - calibration.anchor.height >= 4 && - calibration.fields?.length, + calibration?.saleAnchor?.image && + calibration.saleAnchor.width >= 4 && + calibration.saleAnchor.height >= 4 && + calibration.merchantRegion && + calibration.tooltipSearchRegion && + calibration.tooltipAnchor?.image && + calibration.itemNameRegion && + calibration.itemPriceRegion, ); } diff --git a/src/style.css b/src/style.css index b407f5b..ca4b677 100644 --- a/src/style.css +++ b/src/style.css @@ -599,6 +599,12 @@ input[type="range"] { color: var(--error); } +.result-hint { + margin: 0; + padding: 22px 20px; + color: var(--text-muted); +} + table { width: 100%; border-collapse: collapse; diff --git a/src/vision.js b/src/vision.js index e412e7d..fd6d37d 100644 --- a/src/vision.js +++ b/src/vision.js @@ -1,5 +1,5 @@ import { createWorker, PSM } from "tesseract.js"; -import { normalizeOcrText, parseFieldValue } from "./parser.js"; +import { normalizeOcrText } from "./parser.js"; let cvPromise; let workerPromise; @@ -95,23 +95,21 @@ function prepareOcrCrop(sourceCanvas, rect) { return canvas; } -export async function recognizeField(sourceCanvas, rect, type) { +export async function recognizeText(sourceCanvas, rect, { singleLine = false } = {}) { const worker = await getOcrWorker(); - const numbersOnly = type === "price" || type === "quantity"; await worker.setParameters({ - tessedit_pageseg_mode: PSM.SINGLE_LINE, - tessedit_char_whitelist: numbersOnly ? "0123456789,. " : "", + tessedit_pageseg_mode: singleLine ? PSM.SINGLE_LINE : PSM.SINGLE_BLOCK, + tessedit_char_whitelist: "", preserve_interword_spaces: "1", }); const image = prepareOcrCrop(sourceCanvas, rect); const { data } = await worker.recognize(image); - const text = normalizeOcrText(data.text); + const text = singleLine ? normalizeOcrText(data.text) : data.text.trim(); return { text, - value: parseFieldValue(type, text), confidence: Math.round(data.confidence ?? 0), }; } diff --git a/test/parser.test.js b/test/parser.test.js index d500849..743e35f 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -3,9 +3,12 @@ import assert from "node:assert/strict"; import { isCalibrationReady, isRectInside, + parseMerchantText, parseFieldValue, + parseTooltipText, rectFromPoints, resolveFieldRect, + resolveScreenRect, } from "../src/parser.js"; test("normalizes a reverse drag into a positive rectangle", () => { @@ -33,12 +36,16 @@ test("parses formatted prices and rejects empty numeric OCR", () => { assert.equal(parseFieldValue("item", " Soulshot: S-grade\n"), "Soulshot: S-grade"); }); -test("requires an anchor image and at least one field", () => { - assert.equal(isCalibrationReady({ anchor: null, fields: [] }), false); +test("requires both anchors and exact OCR regions", () => { + assert.equal(isCalibrationReady({ saleAnchor: null }), false); assert.equal( isCalibrationReady({ - anchor: { image: "data:image/png;base64,x", width: 20, height: 10 }, - fields: [{ id: "price" }], + saleAnchor: { image: "data:image/png;base64,x", width: 20, height: 10 }, + merchantRegion: { xRatio: 0.1, yRatio: 0.1, widthRatio: 0.2, heightRatio: 0.1 }, + tooltipSearchRegion: { offsetX: -100, offsetY: -300, width: 300, height: 100 }, + tooltipAnchor: { image: "data:image/png;base64,y", width: 30, height: 12 }, + itemNameRegion: { offsetX: 0, offsetY: -18, width: 220, height: 18 }, + itemPriceRegion: { offsetX: 0, offsetY: 0, width: 160, height: 18 }, }), true, ); @@ -48,3 +55,30 @@ test("detects OCR rectangles outside the captured frame", () => { assert.equal(isRectInside({ x: 10, y: 10, width: 20, height: 20 }, 100, 100), true); assert.equal(isRectInside({ x: 90, y: 10, width: 20, height: 20 }, 100, 100), false); }); + +test("resolves the merchant region against the current window size", () => { + assert.deepEqual( + resolveScreenRect( + { xRatio: 0.25, yRatio: 0.1, widthRatio: 0.5, heightRatio: 0.08 }, + 1200, + 800, + ), + { x: 300, y: 80, width: 600, height: 64 }, + ); +}); + +test("parses a hovered item tooltip", () => { + assert.deepEqual( + parseTooltipText("Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)"), + { + name: "Tears of Eva", + priceAdena: 3000000, + rawText: "Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)", + }, + ); + assert.equal(parseTooltipText("Purchase List\nAdena 5,403,160"), null); +}); + +test("takes the first OCR line as merchant name", () => { + assert.equal(parseMerchantText(" Dwa \nVagabond\n"), "Dwa"); +});