Implement tooltip-based market item collection
This commit is contained in:
+1
-1
@@ -10,7 +10,7 @@ product
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Приложение подключает одно или несколько выбранных окон, позволяет откалибровать области распознавания на снимке и показывает извлечённые названия, цены и количества. Успех прототипа означает, что весь путь от выбора окна до видимого результата работает локально на macOS и Windows.
|
||||
Приложение подключает одно или несколько выбранных окон, подтверждает открытое окно продажи по его нижнему элементу, читает имя торговца и накапливает названия и цены из tooltip при ручном наведении на предметы. Успех прототипа означает, что весь путь от выбора окна до видимого результата работает локально на macOS и Windows.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
|
||||
@@ -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.
|
||||
- Распознавание запускается вручную, фонового сканирования и отправки на сервер пока нет.
|
||||
- Для нескольких строк товара нужно отдельно выделить поля каждой строки.
|
||||
- Сбор работает, пока открыта страница прототипа и он не остановлен кнопкой «Остановить сбор».
|
||||
- Курсор перемещает пользователь, приложение не управляет игрой и не эмулирует ввод.
|
||||
- Отправки на сервер пока нет.
|
||||
|
||||
## Проверки
|
||||
|
||||
|
||||
Binary file not shown.
+12
-15
@@ -48,7 +48,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Образец интерфейса</p>
|
||||
<h2 id="calibration-title">Калибровка</h2>
|
||||
<p>Сначала отметь небольшой неизменяемый якорь, затем области с названием и ценой.</p>
|
||||
<p>Отметь окно продажи, верхнее имя торговца и точные строки всплывающего описания предмета.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<select id="calibration-source" aria-label="Окно для снимка"></select>
|
||||
@@ -73,20 +73,16 @@
|
||||
<div class="field-stack">
|
||||
<label for="region-type">Что выделяем</label>
|
||||
<select id="region-type">
|
||||
<option value="anchor">Якорь окна торговли</option>
|
||||
<option value="item">Название предмета</option>
|
||||
<option value="price">Цена</option>
|
||||
<option value="quantity">Количество</option>
|
||||
<option value="text">Другой текст</option>
|
||||
<option value="saleAnchor">Маркер продажи</option>
|
||||
<option value="merchant">Имя торговца</option>
|
||||
<option value="tooltipSearch">Зона поиска tooltip</option>
|
||||
<option value="tooltipAnchor">Якорь Price :</option>
|
||||
<option value="itemName">Название предмета</option>
|
||||
<option value="itemPrice">Строка цены</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field-stack">
|
||||
<label for="region-label">Подпись поля</label>
|
||||
<input id="region-label" type="text" placeholder="Например, Цена 1" />
|
||||
</div>
|
||||
|
||||
<p id="draw-hint" class="hint">Протяни рамку вокруг небольшого постоянного элемента торгового окна.</p>
|
||||
<p id="draw-hint" class="hint">Выдели постоянный нижний фрагмент окна продажи, например подпись Adena и кнопку Confirm. Числа лучше не включать.</p>
|
||||
|
||||
<div class="field-stack">
|
||||
<div class="range-label">
|
||||
@@ -116,12 +112,13 @@
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Проверка пайплайна</p>
|
||||
<h2 id="results-title">Распознанные данные</h2>
|
||||
<p>Для каждого окна ищется якорь, затем OCR читает откалиброванные области.</p>
|
||||
<h2 id="results-title">Собранные предметы</h2>
|
||||
<p>Запусти сбор и наведи мышь на предметы в игре. Каждый новый tooltip будет добавлен в таблицу.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<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 id="results-list" class="results-list"></div>
|
||||
|
||||
+338
-135
@@ -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 = "<span><strong>Якорь</strong><small>Шаблон поиска</small></span>";
|
||||
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 = "<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");
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
setStatus(`${source.name}: ${field.label}`, "working");
|
||||
result.fields.push({ ...field, ...(await recognizeField(frame, rect, field.type)) });
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
||||
+47
-4
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+5
-7
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
+38
-4
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user