Skip to content

Commit 92051f5

Browse files
authored
js: CD-Aware-AutoGather: 使用OCR识别CountInventoryItem尚不支持的材料 (#2738)
其他改进: - 改进背包扫描重试机制 - 包装`captureGameRegion`为带上下文管理的函数
1 parent e562e75 commit 92051f5

File tree

10 files changed

+408
-183
lines changed

10 files changed

+408
-183
lines changed

repo/js/CD-Aware-AutoGather/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
**由于使用了尚处于测试版BetterGI中的API,使用稳定版BetterGI的用户请等待`0.54.1`或更高的版本发布后再订阅此脚本**
2-
31
(在脚本仓库页面阅读此文档,会比在BGI的已订阅脚本界面获得更好的渲染效果)
42

53
# 功能特点
@@ -11,6 +9,8 @@
119
- 可设置一个或多个不运行的时间段
1210
- 采集过程自动切换合适的队伍
1311

12+
**若脚本有问题,可[点击此处进行反馈](https://github.com/babalae/bettergi-scripts-list/issues/new?template=bug_report.yml&script-name=CD-Aware-AutoGather:2.1.0&additional-info=保留此行以便通知作者:%20@Patrick-Ze%0A%0A---%0A)**
13+
1414
# 使用前准备
1515

1616
**双击运行脚本所在目录下的`SymLink.bat`文件,以创建符号链接。**
@@ -171,6 +171,8 @@
171171

172172
- 感谢[this-Fish](https://github.com/this-Fish)的改进,基于坐标判断是否更新记录、将材料是否刷新的检查提前都是沿袭的TA的思路
173173

174+
- 参考了[吉吉喵](https://github.com/JJMdzh)的背包扫描,增加了使用补充OCR的方式识别物品数量的机制
175+
174176
最后,要特别感谢绫华,是她陪伴了我的提瓦特之旅。在弃坑之后,唯有这份牵挂,支撑着我重新回到这里。
175177

176178
没有她就没有今天的这个脚本。
9.55 KB
Loading
8.3 KB
Loading
8.07 KB
Loading
8.25 KB
Loading
8.96 KB
Loading

repo/js/CD-Aware-AutoGather/lib/inventory.js

Lines changed: 221 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
let scriptContext = {
10-
version: "1.0",
10+
version: "1.1",
1111
};
1212

1313
// 原本是csv格式,但是为了方便js重用,还是内置在代码中
@@ -81,6 +81,8 @@ const csvText = `物品,刷新机制,背包分类
8181
幽光星星,46小时,材料
8282
月莲,46小时,材料
8383
月落银,46小时,材料
84+
冬凌草,46小时,材料
85+
松珀香,46小时,材料
8486
云岩裂叶,46小时,材料
8587
灼灼彩菊,46小时,材料
8688
子探测单元,46小时,材料
@@ -130,6 +132,9 @@ const csvText = `物品,刷新机制,背包分类
130132
烛伞蘑菇,每天0点,食物
131133
`
132134

135+
const renameMap = { "晶蝶": "晶核", "「冷鲜肉」": "冷鲜肉", "白铁矿": "白铁块", "铁矿": "铁块" };
136+
137+
const supportFile = "native_supported.json";
133138
const materialMetadata = {};
134139

135140
function parseCsvTextToDict() {
@@ -178,13 +183,110 @@ function parseCsvTextToDict() {
178183
return resultDict;
179184
}
180185

186+
/**
187+
* 获取需要手动扫描的材料及其对应路径
188+
* @returns {Object} 格式为 { "材料名": "文件完整路径" } 的对象
189+
*/
190+
function getManualOcrMaterials() {
191+
const scanDir = "assets/images/CustomScan";
192+
const items = file.ReadPathSync(scanDir);
193+
194+
// 1. 读取本地记录文件,获取已支持列表
195+
let native_supported = {};
196+
try {
197+
const content = readTextSync(supportFile);
198+
if (content) {
199+
native_supported = JSON.parse(content);
200+
}
201+
} catch (error) {
202+
log.debug(`读取记录文件失败: ${error.toString()}`);
203+
}
204+
205+
// 2. 核心逻辑:建立材料名与路径的映射关系,并过滤已支持材料
206+
const manualDict = {};
207+
for (const itemPath of items) {
208+
// 提取材料名(不含扩展名)
209+
const itemName = splitExt(basename(itemPath))[0];
210+
// 差集计算:只有当 BetterGI 不支持该材料时,才加入待扫描字典
211+
if (!native_supported.hasOwnProperty(itemName)) {
212+
manualDict[itemName] = itemPath;
213+
}
214+
}
215+
216+
return manualDict;
217+
}
218+
219+
const manualOcrMaterials = getManualOcrMaterials();
220+
221+
async function manualOcr(normalizedItemList, shouldStop, sharedResults) {
222+
// 1. 预加载:将所有待识别材料的模板初始化并存储,避免在循环中重复读取文件
223+
const templates = {};
224+
let remainingItems = [];
225+
226+
for (const itemName of normalizedItemList) {
227+
const mat = file.readImageMatSync(manualOcrMaterials[itemName]);
228+
const ro = RecognitionObject.TemplateMatch(mat);
229+
ro.threshold = 0.85;
230+
templates[itemName] = ro;
231+
remainingItems.push(itemName); // 加入待扫描清单
232+
}
233+
234+
const _parseInt = (value, defaultValue = 0) => {
235+
const parsed = parseInt(value.trim(), 10);
236+
return Number.isNaN(parsed) ? defaultValue : parsed;
237+
};
238+
if (remainingItems.length > 0) {
239+
log.info(`将通过补充OCR识别{0}等{1}类物品的数量`, remainingItems[0], remainingItems.length);
240+
}
241+
242+
// 2. 扫描循环:直到所有材料都找到,或者当前画面不再有新目标
243+
while (remainingItems.length > 0 && !shouldStop()) {
244+
// 使用 withCapture 确保每一帧 region 都能正确 dispose
245+
const foundInThisFrame = await withCapture(async (region) => {
246+
const foundList = [];
247+
for (const itemName of remainingItems) {
248+
const result = region.find(templates[itemName]);
249+
if (result.isExist()) {
250+
// 计算 OCR 区域坐标, 抄了 吉吉喵 大佬的方法
251+
const tolerance = 1;
252+
const rect = {
253+
x: result.x - tolerance,
254+
y: result.y + 97 - tolerance,
255+
width: 66 + 2 * tolerance,
256+
height: 22 + 2 * tolerance,
257+
};
258+
// drawRegion(rect); // 调试用
259+
let ocrResult = region.find(RecognitionObject.ocr(rect.x, rect.y, rect.width, rect.height));
260+
if (ocrResult && ocrResult.text) {
261+
const count = _parseInt(ocrResult.text, -2);
262+
sharedResults[itemName] = count;
263+
log.info("{0}: {1}", itemName, count);
264+
foundList.push(itemName); // 记录本帧识别到的条目
265+
}
266+
}
267+
}
268+
return foundList; // 将本帧找到的列表返回给外部
269+
});
270+
271+
// 3. 更新剩余待扫描列表
272+
if (foundInThisFrame.length > 0) {
273+
remainingItems = remainingItems.filter((item) => !foundInThisFrame.includes(item));
274+
}
275+
if (remainingItems.length > 0 && !shouldStop()) {
276+
await sleep(100); // 如果仍有剩余材料,短暂停顿
277+
} else {
278+
break; // 全部找到,退出循环
279+
}
280+
}
281+
282+
return sharedResults;
283+
}
284+
181285
/**
182286
* 获取背包中物品的数量。
183287
* 如果没有找到,则为-1;如果找到了但数字识别失败,则为-2
184-
*
185-
* 暂不支持 冷鲜肉, 红果果菇, 奇异的「牙齿」
186288
*/
187-
async function getItemCount(itemList=null, retry=true) {
289+
async function getItemCount(itemList=null) {
188290
if (Object.keys(materialMetadata).length === 0) {
189291
Object.assign(materialMetadata, parseCsvTextToDict());
190292
}
@@ -194,17 +296,105 @@ async function getItemCount(itemList=null, retry=true) {
194296
} else if (itemList == null || itemList.length === 0) {
195297
itemList = Object.keys(materialMetadata);
196298
}
197-
const renameMap = {"晶蝶": "晶核", "「冷鲜肉」":"冷鲜肉"};
299+
const normalizedItemList = itemList.map((itemName) => {
300+
return renameMap[itemName] || itemName;
301+
});
302+
const result = await mainScan(normalizedItemList);
303+
return result;
304+
}
305+
306+
/**
307+
* 合并扫描结果并实现特征库自动更新(自愈)
308+
* @param {Object} nativeResults - BetterGI 返回的结果 { "鸣草": 10, ... }
309+
* @param {Object} manualResults - 手动 OCR 返回的结果 { "新材料": 5, ... }
310+
* @param {string[]} targetManualList - 本次扫描中,原本判定为需要“手动处理”的材料名清单
311+
* @returns {Object} 合并后的最终清单
312+
*/
313+
function mergeAndAutoRepair(nativeResults, manualResults, manualCandidateList) {
314+
// 1. 以原生结果为基础,合并手动结果
315+
const finalData = { ...nativeResults };
316+
const newlySupported = [];
317+
318+
for (const name in manualResults) {
319+
// 如果 nativeResults 里没有这个材料,采用手动结果
320+
if (!finalData[name] || finalData[name] < 0) {
321+
finalData[name] = manualResults[name];
322+
}
323+
}
324+
325+
// 2. 自愈检查:遍历原本以为需要手动的列表
326+
for (const name of manualCandidateList) {
327+
// 如果 BetterGI 的返回结果里包含了这个材料说明特征库更新了
328+
if (nativeResults.hasOwnProperty(name) && nativeResults[name] > 0) {
329+
newlySupported.push(name);
330+
}
331+
}
332+
333+
// 3. 更新本地记录文件
334+
if (newlySupported.length > 0) {
335+
updateNativeSupportedJson(newlySupported);
336+
}
337+
338+
return finalData;
339+
}
340+
341+
/**
342+
* 更新本地受支持列表的持久化存储
343+
*/
344+
function updateNativeSupportedJson(newItems) {
345+
let registry = {};
346+
try {
347+
registry = JSON.parse(readTextSync(supportFile) || "{}");
348+
} catch(e) {}
349+
350+
const today = new Date().toISOString().split('T')[0];
351+
newItems.forEach(item => {
352+
registry[item] = today;
353+
log.info(`发现{0}已被内置接口支持,未来将跳过它的补充OCR`, item);
354+
});
355+
356+
file.WriteTextSync(supportFile, JSON.stringify(registry, null, 2));
357+
}
358+
359+
async function mainScan(normalizedItemList) {
360+
const sharedOcrResults = {}; // 创建共享对象
361+
let isApiFinished = false;
362+
363+
const manualList = normalizedItemList.filter(name => manualOcrMaterials.hasOwnProperty(name));
364+
365+
// 并发启动
366+
const apiPromise = getItemCountWithApi(normalizedItemList, sharedOcrResults)
367+
.finally(() => isApiFinished = true);
368+
const manualPromise = manualOcr(manualList, () => isApiFinished, sharedOcrResults);
369+
370+
// 等待全部完成
371+
const [apiResults, _] = await Promise.all([apiPromise, manualPromise]);
372+
373+
// 最后合并结果(此时 sharedOcrResults 已经包含了所有 OCR 成功识别的内容)
374+
const results = mergeAndAutoRepair(apiResults, sharedOcrResults, manualList);
375+
const finalResults = {};
376+
const reverseRenameMap = Object.entries(renameMap).reduce((acc, [key, value]) => {
377+
acc[value] = key;
378+
return acc;
379+
}, {});
380+
for (const itemName of normalizedItemList) {
381+
// 如果某个元素没有找到,则不会存在对应的键值,赋值为-1以保持与单个物品查找时一致的行为
382+
const originName = reverseRenameMap[itemName] || itemName;
383+
finalResults[originName] = results[itemName] ?? -1;
384+
}
385+
return finalResults;
386+
}
387+
388+
async function getItemCountWithApi(normalizedItemList, sharedOcrResults) {
198389
const groupByType = {};
199-
for (const itemName of itemList) {
390+
for (const itemName of normalizedItemList) {
200391
const metadata = materialMetadata[itemName];
201392
const itemType = metadata?.type ?? "Materials";
202393
if (!metadata?.type) {
203394
log.warn("未查找到{0}所属的背包分类,默认它是{1}", itemName, itemType);
204395
}
205-
const normalizedName = renameMap[itemName] || itemName;
206396
groupByType[itemType] = groupByType[itemType] || [];
207-
groupByType[itemType].push(normalizedName);
397+
groupByType[itemType].push(itemName);
208398
}
209399

210400
let results = {};
@@ -216,26 +406,38 @@ async function getItemCount(itemList=null, retry=true) {
216406
}));
217407
Object.assign(results, countResult);
218408
}
219-
if (retry && itemList.some(item => !(item in results))) {
409+
410+
// 逻辑:既不在 API 的结果里,也不在 OCR 已经实时识别到的结果里
411+
const missingItems = normalizedItemList.filter(name =>
412+
!(name in results) && !(name in sharedOcrResults)
413+
);
414+
if (missingItems.length > 0) {
220415
// 即使在白天,大多数情况也能识别成功,因此不作为常态机制,仅在失败时使用
221-
log.info("部分物品识别失败,调整时间和视角后重试");
416+
log.info(`${missingItems.length}个物品识别失败,调整时间和视角后重试`);
222417
await genshin.returnMainUi();
223418
await genshin.setTime(0, 0);
224419
await sleep(300);
225420
moveMouseBy(0, 9280);
226421
await sleep(300);
227-
const retryResults = await getItemCount(itemList, false);
422+
423+
// 只针对缺失的物品进行重试
424+
for (const type in groupByType) {
425+
const namesToRetry = groupByType[type].filter(name => missingItems.includes(name));
426+
if (namesToRetry.length > 0) {
427+
const retryCountResult = await dispatcher.runTask(new SoloTask("CountInventoryItem", {
428+
"gridScreenName": type,
429+
"itemNames": namesToRetry,
430+
}));
431+
// 将重试结果合并到原始 results 中
432+
Object.assign(results, retryCountResult);
433+
}
434+
}
435+
436+
// 恢复视角
228437
await genshin.returnMainUi();
229438
keyPress("MBUTTON");
230-
return retryResults;
231439
}
232-
const finalResults = {};
233-
for (const itemName of itemList) {
234-
const normalizedName = renameMap[itemName] || itemName;
235-
// 如果某个元素没有找到,则不会存在对应的键值,赋值为-1以保持与单个物品查找时一致的行为
236-
finalResults[itemName] = results[normalizedName] ?? -1;
237-
}
238-
return finalResults;
440+
return results;
239441
}
240442

241443
function getItemCD(itemName) {

0 commit comments

Comments
 (0)