Implement tooltip-based market item collection

This commit is contained in:
2026-08-11 17:11:44 +03:00
parent f9271d4ab9
commit d6e0ba466b
9 changed files with 458 additions and 172 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ product
## Product Purpose ## Product Purpose
Приложение подключает одно или несколько выбранных окон, позволяет откалибровать области распознавания на снимке и показывает извлечённые названия, цены и количества. Успех прототипа означает, что весь путь от выбора окна до видимого результата работает локально на macOS и Windows. Приложение подключает одно или несколько выбранных окон, подтверждает открытое окно продажи по его нижнему элементу, читает имя торговца и накапливает названия и цены из tooltip при ручном наведении на предметы. Успех прототипа означает, что весь путь от выбора окна до видимого результата работает локально на macOS и Windows.
## Brand Personality ## Brand Personality
+11 -6
View File
@@ -1,6 +1,6 @@
# L2 Market Parser # L2 Market Parser
Кроссплатформенный локальный прототип для захвата нескольких окон Lineage II, калибровки областей на скриншоте и OCR-распознавания результата. Кроссплатформенный локальный прототип для захвата нескольких окон Lineage II и сбора предметов из tooltip торгового окна.
## Запуск ## Запуск
@@ -19,9 +19,13 @@ npm run dev
1. В разделе «Источники» нажми «Добавить окно» для каждого окна игры. 1. В разделе «Источники» нажми «Добавить окно» для каждого окна игры.
2. В разделе «Калибровка» сделай снимок или загрузи готовый скриншот. 2. В разделе «Калибровка» сделай снимок или загрузи готовый скриншот.
3. Выдели маленький неизменяемый элемент торгового окна как якорь. 3. Выдели постоянный нижний элемент продажи, например `Adena + Confirm`, как маркер продажи. По возможности не захватывай изменяемые числа.
4. Выдели области названия, цены и количества. 4. Выдели имя торговца в верхней панели цели.
5. Сохрани калибровку и нажми «Распознать все». 5. Выдели широкую область всех возможных положений tooltip, не захватывая нижний `Price` основного окна.
6. Внутри tooltip обведи только постоянную надпись `Price :`.
7. Отдельно выдели точную строку названия и точную строку цены. Для названия лучше использовать длинный предмет и оставить запас справа.
8. Сохрани калибровку, нажми «Начать сбор» и вернись в игру.
9. Наводи курсор на каждый предмет. Уникальные пары `название + цена` будут накапливаться в таблице.
Калибровка хранится в `localStorage` текущего браузера. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере. Калибровка хранится в `localStorage` текущего браузера. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере.
@@ -29,8 +33,9 @@ npm run dev
- Источники выбираются вручную после каждого обновления страницы. - Источники выбираются вручную после каждого обновления страницы.
- Одна калибровка рассчитана на один масштаб интерфейса Lineage II. - Одна калибровка рассчитана на один масштаб интерфейса Lineage II.
- Распознавание запускается вручную, фонового сканирования и отправки на сервер пока нет. - Сбор работает, пока открыта страница прототипа и он не остановлен кнопкой «Остановить сбор».
- Для нескольких строк товара нужно отдельно выделить поля каждой строки. - Курсор перемещает пользователь, приложение не управляет игрой и не эмулирует ввод.
- Отправки на сервер пока нет.
## Проверки ## Проверки
BIN
View File
Binary file not shown.
+12 -15
View File
@@ -48,7 +48,7 @@
<div> <div>
<p class="eyebrow">Образец интерфейса</p> <p class="eyebrow">Образец интерфейса</p>
<h2 id="calibration-title">Калибровка</h2> <h2 id="calibration-title">Калибровка</h2>
<p>Сначала отметь небольшой неизменяемый якорь, затем области с названием и ценой.</p> <p>Отметь окно продажи, верхнее имя торговца и точные строки всплывающего описания предмета.</p>
</div> </div>
<div class="button-row"> <div class="button-row">
<select id="calibration-source" aria-label="Окно для снимка"></select> <select id="calibration-source" aria-label="Окно для снимка"></select>
@@ -73,20 +73,16 @@
<div class="field-stack"> <div class="field-stack">
<label for="region-type">Что выделяем</label> <label for="region-type">Что выделяем</label>
<select id="region-type"> <select id="region-type">
<option value="anchor">Якорь окна торговли</option> <option value="saleAnchor">Маркер продажи</option>
<option value="item">Название предмета</option> <option value="merchant">Имя торговца</option>
<option value="price">Цена</option> <option value="tooltipSearch">Зона поиска tooltip</option>
<option value="quantity">Количество</option> <option value="tooltipAnchor">Якорь Price :</option>
<option value="text">Другой текст</option> <option value="itemName">Название предмета</option>
<option value="itemPrice">Строка цены</option>
</select> </select>
</div> </div>
<div class="field-stack"> <p id="draw-hint" class="hint">Выдели постоянный нижний фрагмент окна продажи, например подпись Adena и кнопку Confirm. Числа лучше не включать.</p>
<label for="region-label">Подпись поля</label>
<input id="region-label" type="text" placeholder="Например, Цена 1" />
</div>
<p id="draw-hint" class="hint">Протяни рамку вокруг небольшого постоянного элемента торгового окна.</p>
<div class="field-stack"> <div class="field-stack">
<div class="range-label"> <div class="range-label">
@@ -116,12 +112,13 @@
<div class="section-heading"> <div class="section-heading">
<div> <div>
<p class="eyebrow">Проверка пайплайна</p> <p class="eyebrow">Проверка пайплайна</p>
<h2 id="results-title">Распознанные данные</h2> <h2 id="results-title">Собранные предметы</h2>
<p>Для каждого окна ищется якорь, затем OCR читает откалиброванные области.</p> <p>Запусти сбор и наведи мышь на предметы в игре. Каждый новый tooltip будет добавлен в таблицу.</p>
</div> </div>
<div class="button-row"> <div class="button-row">
<button id="copy-results" class="button" type="button" disabled>Копировать JSON</button> <button id="copy-results" class="button" type="button" disabled>Копировать JSON</button>
<button id="recognize-all" class="button button--primary" type="button">Распознать все</button> <button id="clear-results" class="button button--quiet" type="button">Очистить</button>
<button id="recognize-all" class="button button--primary" type="button">Начать сбор</button>
</div> </div>
</div> </div>
<div id="results-list" class="results-list"></div> <div id="results-list" class="results-list"></div>
+338 -135
View File
@@ -2,32 +2,46 @@ import "./style.css";
import { import {
isCalibrationReady, isCalibrationReady,
isRectInside, isRectInside,
parseMerchantText,
parseTooltipText,
rectFromPoints, rectFromPoints,
resolveFieldRect, resolveFieldRect,
resolveScreenRect,
} from "./parser.js"; } from "./parser.js";
import { import {
findAnchor, findAnchor,
onOcrProgress, onOcrProgress,
recognizeField, recognizeText,
terminateVision, terminateVision,
} from "./vision.js"; } from "./vision.js";
const STORAGE_KEY = "l2-market-parser.calibration.v1"; const STORAGE_KEY = "l2-market-parser.calibration.v3";
const fieldNames = { const regionNames = {
item: "Название", saleAnchor: "Маркер продажи",
price: "Цена", merchant: "Имя торговца",
quantity: "Количество", tooltipSearch: "Зона поиска tooltip",
text: "Текст", tooltipAnchor: "Якорь Price :",
itemName: "Название предмета",
itemPrice: "Строка цены",
}; };
function emptyCalibration() { 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() { function loadCalibration() {
try { try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
return saved?.anchor && Array.isArray(saved.fields) return saved?.saleAnchor
? { ...emptyCalibration(), ...saved } ? { ...emptyCalibration(), ...saved }
: emptyCalibration(); : emptyCalibration();
} catch { } catch {
@@ -42,7 +56,7 @@ const state = {
calibration: loadCalibration(), calibration: loadCalibration(),
drawing: null, drawing: null,
results: [], results: [],
busy: false, collecting: false,
}; };
const elements = { const elements = {
@@ -57,7 +71,6 @@ const elements = {
canvasEmpty: document.querySelector("#canvas-empty"), canvasEmpty: document.querySelector("#canvas-empty"),
canvas: document.querySelector("#calibration-canvas"), canvas: document.querySelector("#calibration-canvas"),
regionType: document.querySelector("#region-type"), regionType: document.querySelector("#region-type"),
regionLabel: document.querySelector("#region-label"),
drawHint: document.querySelector("#draw-hint"), drawHint: document.querySelector("#draw-hint"),
threshold: document.querySelector("#match-threshold"), threshold: document.querySelector("#match-threshold"),
thresholdValue: document.querySelector("#threshold-value"), thresholdValue: document.querySelector("#threshold-value"),
@@ -66,6 +79,7 @@ const elements = {
saveCalibration: document.querySelector("#save-calibration"), saveCalibration: document.querySelector("#save-calibration"),
clearCalibration: document.querySelector("#clear-calibration"), clearCalibration: document.querySelector("#clear-calibration"),
recognizeAll: document.querySelector("#recognize-all"), recognizeAll: document.querySelector("#recognize-all"),
clearResults: document.querySelector("#clear-results"),
copyResults: document.querySelector("#copy-results"), copyResults: document.querySelector("#copy-results"),
resultsList: document.querySelector("#results-list"), resultsList: document.querySelector("#results-list"),
}; };
@@ -337,45 +351,92 @@ function cropCanvas(source, rect) {
function addRegion(rect) { function addRegion(rect) {
const type = elements.regionType.value; const type = elements.regionType.value;
state.calibration.referenceSize = {
width: state.referenceCanvas.width,
height: state.referenceCanvas.height,
};
if (type === "anchor") { if (type === "saleAnchor") {
state.calibration.anchor = { state.calibration.saleAnchor = {
...rect, ...rect,
image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"), image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"),
}; };
state.calibration.fields = []; state.calibration.tooltipSearchRegion = null;
elements.regionType.value = "item"; elements.regionType.value = "merchant";
updateDrawHint(); setStatus("Маркер продажи выбран. Теперь выдели имя торговца", "success");
setStatus("Якорь выбран. Теперь отметь поля", "success"); } else if (type === "merchant") {
} else { state.calibration.merchantRegion = {
const anchor = state.calibration.anchor; xRatio: rect.x / state.referenceCanvas.width,
if (!anchor) { yRatio: rect.y / state.referenceCanvas.height,
setStatus("Сначала выдели якорь", "error"); 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; return;
} }
const sameTypeCount = state.calibration.fields.filter((field) => field.type === type).length; state.calibration.tooltipSearchRegion = {
state.calibration.fields.push({ offsetX: rect.x - state.calibration.saleAnchor.x,
id: crypto.randomUUID(), offsetY: rect.y - state.calibration.saleAnchor.y,
type,
label: elements.regionLabel.value.trim() || `${fieldNames[type]} ${sameTypeCount + 1}`,
offsetX: rect.x - anchor.x,
offsetY: rect.y - anchor.y,
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
}); };
elements.regionLabel.value = ""; elements.regionType.value = "tooltipAnchor";
setStatus("Область добавлена", "success"); 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(); renderCalibration();
} }
function fieldAbsoluteRect(field) { function referenceMerchantRect() {
return resolveFieldRect( const size = state.calibration.referenceSize;
{ x: state.calibration.anchor.x, y: state.calibration.anchor.y }, return size && state.calibration.merchantRegion
field, ? 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) { 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.clearRect(0, 0, elements.canvas.width, elements.canvas.height);
context.drawImage(state.referenceCanvas, 0, 0); context.drawImage(state.referenceCanvas, 0, 0);
if (state.calibration.anchor) { if (state.calibration.saleAnchor) {
drawBox(context, state.calibration.anchor, "Якорь", "oklch(0.72 0.16 65)"); drawBox(context, state.calibration.saleAnchor, "Продажа", "oklch(0.72 0.16 65)");
state.calibration.fields.forEach((field) => { }
drawBox(context, fieldAbsoluteRect(field), field.label, "oklch(0.65 0.17 250)"); 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) { if (state.drawing) {
@@ -418,11 +501,15 @@ function drawCalibration() {
function updateDrawHint() { function updateDrawHint() {
const hints = { const hints = {
anchor: "Протяни рамку вокруг небольшого постоянного элемента торгового окна.", saleAnchor:
item: "Выдели только строку с названием предмета.", "Выдели постоянный нижний фрагмент окна продажи, например подпись Adena и кнопку Confirm. Числа лучше не включать.",
price: "Выдели цену вместе с разделителями разрядов.", merchant: "Выдели только имя персонажа в верхней панели цели, без уровня клана.",
quantity: "Выдели количество предметов.", tooltipSearch:
text: "Выдели одну строку текста.", "Выдели широкую область всех возможных положений tooltip. Нижний блок Price окна продажи не включай.",
tooltipAnchor: "Обведи только постоянную надпись Price : внутри tooltip, без изменяемого числа.",
itemName:
"Выдели точную верхнюю строку названия. Возьми скриншот с длинным названием и оставь запас справа.",
itemPrice: "Выдели точную строку Price вместе с числом и словом Adena.",
}; };
elements.drawHint.textContent = hints[elements.regionType.value]; elements.drawHint.textContent = hints[elements.regionType.value];
} }
@@ -430,41 +517,42 @@ function updateDrawHint() {
function renderCalibration() { function renderCalibration() {
elements.threshold.value = String(state.calibration.threshold); elements.threshold.value = String(state.calibration.threshold);
elements.thresholdValue.value = state.calibration.threshold.toFixed(2); 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( elements.regionCount.textContent = String(
state.calibration.fields.length + (state.calibration.anchor ? 1 : 0), regions.filter(([, key]) => state.calibration[key]).length,
); );
elements.regionList.replaceChildren(); elements.regionList.replaceChildren();
if (!state.calibration.anchor) { regions.forEach(([type, key, description]) => {
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 = "<span><strong>Якорь</strong><small>Шаблон поиска</small></span>";
elements.regionList.append(anchor);
}
state.calibration.fields.forEach((field) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "region-row"; row.className = "region-row";
const copy = document.createElement("span"); const copy = document.createElement("span");
const name = document.createElement("strong"); const name = document.createElement("strong");
name.textContent = field.label; name.textContent = regionNames[type];
const type = document.createElement("small"); const detail = document.createElement("small");
type.textContent = fieldNames[field.type]; detail.textContent = state.calibration[key] ? description : "Не выбрано";
copy.append(name, type); copy.append(name, detail);
const remove = document.createElement("button"); const remove = document.createElement("button");
remove.className = "icon-button"; remove.className = "icon-button";
remove.type = "button"; remove.type = "button";
remove.textContent = "×"; remove.textContent = "×";
remove.setAttribute("aria-label", `Удалить ${field.label}`); remove.disabled = !state.calibration[key];
remove.setAttribute("aria-label", `Удалить ${regionNames[type]}`);
remove.addEventListener("click", () => { 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(); renderCalibration();
drawCalibration();
}); });
row.append(copy, remove); row.append(copy, remove);
elements.regionList.append(row); elements.regionList.append(row);
@@ -475,7 +563,7 @@ function renderCalibration() {
function saveCalibration() { function saveCalibration() {
if (!isCalibrationReady(state.calibration)) { if (!isCalibrationReady(state.calibration)) {
setStatus("Для сохранения нужны якорь и хотя бы одно поле", "error"); setStatus("Нужно заполнить все 6 областей калибровки", "error");
return; return;
} }
@@ -488,6 +576,8 @@ function saveCalibration() {
} }
function clearCalibration() { function clearCalibration() {
state.collecting = false;
elements.recognizeAll.textContent = "Начать сбор";
state.calibration = emptyCalibration(); state.calibration = emptyCalibration();
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
renderCalibration(); renderCalibration();
@@ -511,7 +601,7 @@ function imageToCanvas(dataUrl) {
function renderResults() { function renderResults() {
elements.resultsList.replaceChildren(); elements.resultsList.replaceChildren();
elements.copyResults.disabled = !state.results.length; elements.copyResults.disabled = !state.results.some((result) => result.items.length);
if (!state.results.length) { if (!state.results.length) {
const empty = document.createElement("div"); const empty = document.createElement("div");
@@ -519,7 +609,7 @@ function renderResults() {
const title = document.createElement("strong"); const title = document.createElement("strong");
title.textContent = "Результатов пока нет"; title.textContent = "Результатов пока нет";
const text = document.createElement("span"); const text = document.createElement("span");
text.textContent = "Подключи окна, сохрани калибровку и запусти распознавание."; text.textContent = "Запусти сбор, вернись в игру и наведи курсор на предмет.";
empty.append(title, text); empty.append(title, text);
elements.resultsList.append(empty); elements.resultsList.append(empty);
return; return;
@@ -533,14 +623,14 @@ function renderResults() {
const title = document.createElement("h3"); const title = document.createElement("h3");
title.textContent = result.source; title.textContent = result.source;
const meta = document.createElement("p"); const meta = document.createElement("p");
meta.textContent = result.found meta.textContent = result.saleOpen
? `Якорь найден, совпадение ${(result.score * 100).toFixed(1)}%` ? `Торговец: ${result.merchant || "распознаётся"}, маркер ${(result.score * 100).toFixed(1)}%`
: `Якорь не найден, лучшее совпадение ${(result.score * 100).toFixed(1)}%`; : `Окно продажи не найдено, лучшее совпадение ${(result.score * 100).toFixed(1)}%`;
heading.append(title, meta); heading.append(title, meta);
const badge = document.createElement("span"); const badge = document.createElement("span");
badge.className = "result-badge"; badge.className = "result-badge";
badge.dataset.tone = result.found ? "success" : "error"; badge.dataset.tone = result.saleOpen ? "success" : "error";
badge.textContent = result.found ? "Распознано" : "Не найдено"; badge.textContent = result.saleOpen ? `${result.items.length} предметов` : "Нет продажи";
header.append(heading, badge); header.append(heading, badge);
section.append(header); section.append(header);
@@ -549,49 +639,180 @@ function renderResults() {
error.className = "result-error"; error.className = "result-error";
error.textContent = result.error; error.textContent = result.error;
section.append(error); section.append(error);
} else if (result.fields?.length) { } else if (result.saleOpen && result.items.length) {
const table = document.createElement("table"); const table = document.createElement("table");
table.innerHTML = "<thead><tr><th>Поле</th><th>Значение</th><th>OCR</th><th>Уверенность</th></tr></thead>"; table.innerHTML = "<thead><tr><th>Предмет</th><th>Цена</th><th>Последний раз</th><th>Уверенность OCR</th></tr></thead>";
const body = document.createElement("tbody"); const body = document.createElement("tbody");
result.fields.forEach((field) => { result.items.forEach((item) => {
const row = document.createElement("tr"); const row = document.createElement("tr");
const label = document.createElement("td"); const name = document.createElement("td");
label.textContent = field.label; name.textContent = item.name;
const value = document.createElement("td"); const price = document.createElement("td");
value.textContent = price.textContent = `${item.priceAdena.toLocaleString("ru-RU")} Adena`;
typeof field.value === "number" ? field.value.toLocaleString("ru-RU") : field.value || "Не распознано"; const seen = document.createElement("td");
const raw = document.createElement("td"); seen.textContent = new Date(item.seenAt).toLocaleTimeString("ru-RU");
raw.textContent = field.text || "пусто";
const confidence = document.createElement("td"); const confidence = document.createElement("td");
confidence.textContent = `${field.confidence}%`; confidence.textContent = `${item.confidence}%`;
row.append(label, value, raw, confidence); row.append(name, price, seen, confidence);
body.append(row); body.append(row);
}); });
table.append(body); table.append(body);
section.append(table); 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); 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); const activeSources = state.sources.filter((source) => source.active);
if (!activeSources.length) { if (!activeSources.length) {
setStatus("Сначала добавь хотя бы одно окно", "error"); setStatus("Сначала добавь хотя бы одно окно", "error");
return; return;
} }
if (!isCalibrationReady(state.calibration)) { if (!isCalibrationReady(state.calibration)) {
setStatus("Сначала сохрани якорь и области распознавания", "error"); setStatus("Сначала откалибруй продажу, торговца и tooltip", "error");
return; return;
} }
if (state.busy) return;
state.busy = true; state.collecting = true;
elements.recognizeAll.disabled = true; elements.recognizeAll.textContent = "Остановить сбор";
elements.recognizeAll.textContent = "Распознаю…"; setStatus("Через 2 секунды начнётся сбор. Вернись в игру", "working");
state.results = [];
renderResults();
onOcrProgress((progress) => { onOcrProgress((progress) => {
if (progress.status === "recognizing text") { if (progress.status === "recognizing text") {
@@ -600,55 +821,36 @@ async function recognizeAll() {
}); });
try { 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) { while (state.collecting) {
setStatus(`Ищу торговое окно: ${source.name}`, "working"); const sources = state.sources.filter((source) => source.active);
const frame = await captureSource(source); for (const source of sources) {
const match = await findAnchor(frame, template); if (!state.collecting) break;
const result = { setStatus(`Сканирую: ${source.name}`, "working");
source: source.name, await scanSource(source, saleTemplate, tooltipTemplate);
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)) });
}
} }
state.results.push(result);
renderResults(); renderResults();
if (state.collecting) await delay(700);
} }
setStatus("Распознавание завершено", "success");
} catch (error) { } catch (error) {
setStatus(error.message || "Ошибка распознавания", "error"); setStatus(error.message || "Ошибка распознавания", "error");
} finally { } finally {
state.busy = false; state.collecting = false;
elements.recognizeAll.disabled = false; elements.recognizeAll.textContent = "Начать сбор";
elements.recognizeAll.textContent = "Распознать все";
} }
} }
function clearResults() {
state.results = [];
renderResults();
setStatus("Результаты очищены", "neutral");
}
async function copyResults() { async function copyResults() {
try { try {
await navigator.clipboard.writeText(JSON.stringify(state.results, null, 2)); 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.saveCalibration.addEventListener("click", saveCalibration);
elements.clearCalibration.addEventListener("click", clearCalibration); 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.copyResults.addEventListener("click", copyResults);
elements.canvas.addEventListener("pointerdown", (event) => { elements.canvas.addEventListener("pointerdown", (event) => {
+47 -4
View File
@@ -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) { export function normalizeOcrText(text) {
return text.replace(/\s+/g, " ").trim(); return text.replace(/\s+/g, " ").trim();
} }
@@ -31,12 +40,46 @@ export function parseFieldValue(type, text) {
return digits ? Number(digits) : null; 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) { export function isCalibrationReady(calibration) {
return Boolean( return Boolean(
calibration?.anchor?.image && calibration?.saleAnchor?.image &&
calibration.anchor.width >= 4 && calibration.saleAnchor.width >= 4 &&
calibration.anchor.height >= 4 && calibration.saleAnchor.height >= 4 &&
calibration.fields?.length, calibration.merchantRegion &&
calibration.tooltipSearchRegion &&
calibration.tooltipAnchor?.image &&
calibration.itemNameRegion &&
calibration.itemPriceRegion,
); );
} }
+6
View File
@@ -599,6 +599,12 @@ input[type="range"] {
color: var(--error); color: var(--error);
} }
.result-hint {
margin: 0;
padding: 22px 20px;
color: var(--text-muted);
}
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
+5 -7
View File
@@ -1,5 +1,5 @@
import { createWorker, PSM } from "tesseract.js"; import { createWorker, PSM } from "tesseract.js";
import { normalizeOcrText, parseFieldValue } from "./parser.js"; import { normalizeOcrText } from "./parser.js";
let cvPromise; let cvPromise;
let workerPromise; let workerPromise;
@@ -95,23 +95,21 @@ function prepareOcrCrop(sourceCanvas, rect) {
return canvas; return canvas;
} }
export async function recognizeField(sourceCanvas, rect, type) { export async function recognizeText(sourceCanvas, rect, { singleLine = false } = {}) {
const worker = await getOcrWorker(); const worker = await getOcrWorker();
const numbersOnly = type === "price" || type === "quantity";
await worker.setParameters({ await worker.setParameters({
tessedit_pageseg_mode: PSM.SINGLE_LINE, tessedit_pageseg_mode: singleLine ? PSM.SINGLE_LINE : PSM.SINGLE_BLOCK,
tessedit_char_whitelist: numbersOnly ? "0123456789,. " : "", tessedit_char_whitelist: "",
preserve_interword_spaces: "1", preserve_interword_spaces: "1",
}); });
const image = prepareOcrCrop(sourceCanvas, rect); const image = prepareOcrCrop(sourceCanvas, rect);
const { data } = await worker.recognize(image); const { data } = await worker.recognize(image);
const text = normalizeOcrText(data.text); const text = singleLine ? normalizeOcrText(data.text) : data.text.trim();
return { return {
text, text,
value: parseFieldValue(type, text),
confidence: Math.round(data.confidence ?? 0), confidence: Math.round(data.confidence ?? 0),
}; };
} }
+38 -4
View File
@@ -3,9 +3,12 @@ import assert from "node:assert/strict";
import { import {
isCalibrationReady, isCalibrationReady,
isRectInside, isRectInside,
parseMerchantText,
parseFieldValue, parseFieldValue,
parseTooltipText,
rectFromPoints, rectFromPoints,
resolveFieldRect, resolveFieldRect,
resolveScreenRect,
} from "../src/parser.js"; } from "../src/parser.js";
test("normalizes a reverse drag into a positive rectangle", () => { 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"); assert.equal(parseFieldValue("item", " Soulshot: S-grade\n"), "Soulshot: S-grade");
}); });
test("requires an anchor image and at least one field", () => { test("requires both anchors and exact OCR regions", () => {
assert.equal(isCalibrationReady({ anchor: null, fields: [] }), false); assert.equal(isCalibrationReady({ saleAnchor: null }), false);
assert.equal( assert.equal(
isCalibrationReady({ isCalibrationReady({
anchor: { image: "data:image/png;base64,x", width: 20, height: 10 }, saleAnchor: { image: "data:image/png;base64,x", width: 20, height: 10 },
fields: [{ id: "price" }], 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, 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: 10, y: 10, width: 20, height: 20 }, 100, 100), true);
assert.equal(isRectInside({ x: 90, y: 10, width: 20, height: 20 }, 100, 100), false); 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");
});