@@ -116,12 +112,13 @@
Проверка пайплайна
-
Распознанные данные
-
Для каждого окна ищется якорь, затем OCR читает откалиброванные области.
+
Собранные предметы
+
Запусти сбор и наведи мышь на предметы в игре. Каждый новый tooltip будет добавлен в таблицу.
Копировать JSON
- Распознать все
+ Очистить
+ Начать сбор
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");
+});