feat: 自动标注过滤

This commit is contained in:
unanmed 2025-12-10 19:54:10 +08:00
parent 5586ea1039
commit 224005b44b
14 changed files with 1417 additions and 31 deletions

2
.gitignore vendored
View File

@ -9,3 +9,5 @@ datasets
*.log
app/model/*
!app/model/.gitkeep
map_images
data/result.json

View File

@ -9,6 +9,7 @@
"merge": "tsx ./src/merge.ts",
"review": "tsx ./src/review.ts",
"eval": "tsx ./src/eval.ts",
"auto": "tsx ./src/auto.ts",
"test:topo": "tsx ./src/topology/test.ts",
"test:vision": "tsx ./src/vision/test.ts"
},
@ -19,6 +20,7 @@
"devDependencies": {
"@types/cli-progress": "^3.11.6",
"@types/fs-extra": "^11.0.4",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.13.10",
"tsx": "^4.19.3",
"vitest": "^3.0.8"
@ -26,6 +28,8 @@
"dependencies": {
"cli-progress": "^3.12.0",
"fs-extra": "^11.3.0",
"lodash-es": "^4.17.21",
"vm2": "^3.10.0",
"why-is-node-running": "^3.2.2"
}
}

View File

@ -14,6 +14,12 @@ importers:
fs-extra:
specifier: ^11.3.0
version: 11.3.0
lodash-es:
specifier: ^4.17.21
version: 4.17.21
vm2:
specifier: ^3.10.0
version: 3.10.0
why-is-node-running:
specifier: ^3.2.2
version: 3.2.2
@ -24,6 +30,9 @@ importers:
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
'@types/lodash-es':
specifier: ^4.17.12
version: 4.17.12
'@types/node':
specifier: ^22.13.10
version: 22.13.10
@ -306,6 +315,12 @@ packages:
'@types/jsonfile@6.1.4':
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
'@types/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
'@types/lodash@4.17.21':
resolution: {integrity: sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==}
'@types/node@22.13.10':
resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==}
@ -338,6 +353,15 @@ packages:
'@vitest/utils@3.0.8':
resolution: {integrity: sha512-nkBC3aEhfX2PdtQI/QwAWp8qZWwzASsU4Npbcd5RdMPBSSLCpkZp52P3xku3s3uA0HIEhGvEcF8rNkBsz9dQ4Q==}
acorn-walk@8.3.4:
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
engines: {node: '>=0.4.0'}
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
hasBin: true
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@ -415,6 +439,9 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
loupe@3.1.3:
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
@ -575,6 +602,11 @@ packages:
jsdom:
optional: true
vm2@3.10.0:
resolution: {integrity: sha512-3ggF4Bs0cw4M7Rxn19/Cv3nJi04xrgHwt4uLto+zkcZocaKwP/nKP9wPx6ggN2X0DSXxOOIc63BV1jvES19wXQ==}
engines: {node: '>=6.0'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@ -736,6 +768,12 @@ snapshots:
dependencies:
'@types/node': 22.13.10
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.21
'@types/lodash@4.17.21': {}
'@types/node@22.13.10':
dependencies:
undici-types: 6.20.0
@ -780,6 +818,12 @@ snapshots:
loupe: 3.1.3
tinyrainbow: 2.0.0
acorn-walk@8.3.4:
dependencies:
acorn: 8.15.0
acorn@8.15.0: {}
ansi-regex@5.0.1: {}
assertion-error@2.0.1: {}
@ -867,6 +911,8 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
lodash-es@4.17.21: {}
loupe@3.1.3: {}
magic-string@0.30.17:
@ -1024,6 +1070,11 @@ snapshots:
- tsx
- yaml
vm2@3.10.0:
dependencies:
acorn: 8.15.0
acorn-walk: 8.3.4
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0

131
data/src/auto.ts Normal file
View File

@ -0,0 +1,131 @@
import { writeFile } from 'fs/promises';
import { autoLabelTowers } from './auto/auto';
import { IAutoLabelConfig, TowerColor } from './auto/types';
import { GinkaDataset, GinkaTrainData } from './types';
const [, , output, towerInfo, ...folders] = process.argv;
// 根据结果手动屏蔽一些满足条件但是不适合的塔
const ignoredTower: string[] = [
'blossom',
'cxzl_imprisoned',
'hpxwzwy',
'jianchuan1',
'MC2D',
'minecraft',
'waterfultowerx',
'xieyuhouxu'
];
const ignoredFloor: Record<string, string[]> = {
cxzl2: ['MN5', 'MN7', 'MN8', 'MN49', 'MN57', 'MQ77', 'MT85', 'MZ75'],
cxzl: ['MT63', 'MT186', 'MT256', 'MT304', 'MT313', 'MT314', 'MT334'],
gl: ['MH2'],
gooTower2: ['MT96'],
levelstower: ['LV0c'],
lzlltete: ['MT0'],
myslmdrydtwo: ['MT49'],
OneStepToHell: ['MT02', 'MT05', 'MT07', 'MT09', 'MT44'],
ousha: ['MT152', 'MT158', 'MT160'],
Pacificwar: ['MT102', 'MT131'],
tiandijinghz: ['MT54', 'MT41', 'A21', 'MT50', 'MT110'],
wdg3: ['MT37'],
xiangyaochumo: ['A4', 'A8', 'A9', 'MT62'],
xinxin2: ['B02'],
yishizhishen: ['MT8'],
zd1: ['MH9', 'MT10'],
zhenshishenghuo: ['MT52', 'MT131', 'MT132']
};
const labelConfig: IAutoLabelConfig = {
allowedSize: [[13, 13]],
allowUselessBranch: false,
maxWallDensityStd: 0.25,
minEnemyRatio: 0.02,
maxEnemyRatio: 0.3,
minWallRatio: 0.1,
maxWallRatio: 0.6,
minResourceRatio: 0.02,
maxResourceRatio: 0.3,
minDoorRatio: 0,
maxDoorRatio: 0.2,
minFishCount: 0,
maxFishCount: 2,
minEntryCount: 1,
maxEntryCount: 4,
ignoreIssues: true,
customTowerFilter: info => {
if (info.color !== TowerColor.Blue && info.color !== TowerColor.Green) {
return false;
}
if (info.people < 1000) {
return false;
}
if (info.name.startsWith('51') && info.name.length > 2) {
return Math.random() > 0.98;
}
if (ignoredTower.includes(info.name)) {
return false;
}
return true;
},
customFloorFilter: floor => {
if (floor.info.topo.graphs.length > 1) {
return false;
}
if (floor.data.hasCannotInOut) {
return false;
}
if (floor.info.topo.unreachable.size > 5) {
return false;
}
if (ignoredFloor[floor.tower.name]?.includes(floor.mapId)) {
return false;
}
return true;
}
};
(async () => {
const result = await autoLabelTowers(towerInfo, folders, labelConfig);
// 转换格式并写入文件
const dataset: GinkaDataset = {
datasetId: Math.floor(Math.random() * 1e12),
data: {}
};
result.forEach(tower => {
tower.maps.forEach(floor => {
const id = `${tower.tower.name}::${floor.mapId}`;
const width = floor.data.map[0].length;
const height = floor.data.map.length;
const info = floor.info;
const data: GinkaTrainData = {
map: floor.data.map,
size: [width, height],
tag: Array(64).fill(0),
val: [
info.globalDensity,
info.wallDensity,
0,
info.doorDensity,
info.enemyDensity,
info.resourceDensity,
info.gemDensity,
info.potionDensity,
info.keyDensity,
info.itemDensity,
info.entryCount,
info.specialDoorCount,
info.fishCount,
0,
0,
0
]
};
dataset.data[id] = data;
});
});
await writeFile(output, JSON.stringify(dataset), 'utf-8');
console.log(`结果已写入 ${output}`);
})();

205
data/src/auto/auto.ts Normal file
View File

@ -0,0 +1,205 @@
import { readdir, readFile } from 'fs/promises';
import { parseFloorInfo, parseTowerInfo } from './info';
import { IAutoLabelConfig, IConvertedMapInfo, ITowerInfo } from './types';
import { join } from 'path';
import { Presets, SingleBar } from 'cli-progress';
import { convertTowerMap, runTowerCode } from './tower';
export interface ILabelResult {
/** 塔信息列表 */
readonly tower: ITowerInfo;
/** 转换后的楼层列表 */
readonly maps: IConvertedMapInfo[];
}
function addIssuePrefix(maxLength: number, path: string, content: string) {
return `${path}: ${' '.repeat(maxLength - path.length)}${content}`;
}
/**
*
* @param towerInfo
* @param pathList
* @param config
*/
export async function autoLabelTowers(
towerInfo: string,
pathList: string[],
config: IAutoLabelConfig
) {
const labelResult: ILabelResult[] = [];
// 统计被不同规则过滤掉的楼层
let ignoredFloorsSize = 0;
let ignoredFloorsEnemy = 0;
let ignoredFloorsWall = 0;
let ignoredFloorsResource = 0;
let ignoredFloorsDoor = 0;
let ignoredFloorsFish = 0;
let ignoredFloorsEntry = 0;
let ignoredFloorsCustom = 0;
let ignoredFloorsUseless = 0;
let ignoredFloorsStd = 0;
const towers = await parseTowerInfo(towerInfo);
const paths: string[] = [];
await Promise.all(
pathList.map(async path => {
const dir = await readdir(path);
paths.push(...dir.map(v => join(path, v)));
})
);
const issues: [string, string][] = [];
const progress = new SingleBar({}, Presets.shades_classic);
progress.start(paths.length, 0);
let i = 0;
for (const path of paths) {
progress.update(++i);
let project: string;
let floors: string;
try {
project = await readFile(
join(path, 'project', 'project.min.js'),
'utf-8'
);
floors = await readFile(
join(path, 'project', 'floors.min.js'),
'utf-8'
);
} catch {
issues.push([path, '读取塔信息失败']);
continue;
}
const result = runTowerCode(project, floors);
if (result.issue.length > 0) {
issues.push(...result.issue.map<[string, string]>(v => [path, v]));
continue;
}
const info = towers.get(result.data.firstData.name);
if (!info) continue;
const customPass = config.customTowerFilter?.(info) ?? true;
if (!customPass) continue;
const convertedMaps: IConvertedMapInfo[] = [];
// 处理每个塔的每个楼层
for (const [name, floor] of Object.entries(result.main.floors)) {
const width = floor.map[0].length;
const height = floor.map.length;
// 尺寸不匹配
const sizePass = config.allowedSize.some(
([w, h]) => w === width && h === height
);
if (!sizePass) {
ignoredFloorsSize++;
continue;
}
// 转换楼层
const converted = convertTowerMap(result, floor);
const floorInfo = parseFloorInfo(info, converted.map);
const floorData: IConvertedMapInfo = {
data: converted,
tower: info,
mapId: name,
info: floorInfo
};
// 配置过滤楼层
if (
floorInfo.enemyDensity < config.minEnemyRatio ||
floorInfo.enemyDensity > config.maxEnemyRatio
) {
ignoredFloorsEnemy++;
continue;
}
if (
floorInfo.wallDensity < config.minWallRatio ||
floorInfo.wallDensity > config.maxWallRatio
) {
ignoredFloorsWall++;
continue;
}
if (
floorInfo.resourceDensity < config.minResourceRatio ||
floorInfo.resourceDensity > config.maxResourceRatio
) {
ignoredFloorsResource++;
continue;
}
if (
floorInfo.doorDensity < config.minDoorRatio ||
floorInfo.doorDensity > config.maxDoorRatio
) {
ignoredFloorsDoor++;
continue;
}
if (
floorInfo.fishCount < config.minFishCount ||
floorInfo.fishCount > config.maxFishCount
) {
ignoredFloorsFish++;
continue;
}
if (
floorInfo.entryCount < config.minEntryCount ||
floorInfo.entryCount > config.maxEntryCount
) {
ignoredFloorsEntry++;
continue;
}
if (!config.allowUselessBranch && floorInfo.hasUselessBranch) {
ignoredFloorsUseless++;
continue;
}
if (floorInfo.wallDensityStd > config.maxWallDensityStd) {
ignoredFloorsStd++;
continue;
}
// 自定义过滤楼层
const customPass = config.customFloorFilter?.(floorData) ?? true;
if (!customPass) {
ignoredFloorsCustom++;
continue;
}
// 楼层过滤通过
convertedMaps.push(floorData);
}
labelResult.push({ tower: info, maps: convertedMaps });
}
progress.stop();
if (!config.ignoreIssues) {
const maxLength = Math.max(...issues.map(v => v[0].length));
issues.forEach(v => {
console.log(addIssuePrefix(maxLength, v[0], v[1]));
});
}
const totalFilted =
ignoredFloorsSize +
ignoredFloorsEnemy +
ignoredFloorsWall +
ignoredFloorsResource +
ignoredFloorsDoor +
ignoredFloorsFish +
ignoredFloorsEntry +
ignoredFloorsUseless +
ignoredFloorsCustom;
console.log(
`已处理 ${labelResult.length} 个塔,共 ${labelResult.reduce(
(prev, curr) => prev + curr.maps.length,
0
)} ${totalFilted} :`
);
console.log(`尺寸过滤:${ignoredFloorsSize}`);
console.log(`怪物过滤:${ignoredFloorsEnemy}`);
console.log(`墙壁过滤:${ignoredFloorsWall}`);
console.log(`资源过滤:${ignoredFloorsResource}`);
console.log(`门过滤:${ignoredFloorsDoor}`);
console.log(`咸鱼过滤:${ignoredFloorsFish}`);
console.log(`入口过滤:${ignoredFloorsEntry}`);
console.log(`无用节点过滤:${ignoredFloorsUseless}`);
console.log(`标准差过滤:${ignoredFloorsStd}`);
console.log(`自定义过滤:${ignoredFloorsCustom}`);
return labelResult;
}

234
data/src/auto/info.ts Normal file
View File

@ -0,0 +1,234 @@
import { readFile } from 'fs/promises';
import { IFloorInfo, ITowerInfo, TowerColor } from './types';
import { buildTopologicalGraph } from 'src/topology/graph';
import {
commonDoorTiles,
doorTiles,
enemyTiles,
entryTiles,
gemTiles,
itemTiles,
keyTiles,
nonEmptyTiles,
potionTiles,
resourceTiles,
specialDoorTiles,
wallTiles
} from 'src/shared';
import { NodeType } from 'src/topology/interface';
interface IRawTowerInfo {
/** 作者 id */
readonly authorId: string;
/** 塔颜色 */
readonly color: string;
/** 评论数量 */
readonly comment: string;
/** 是否是比赛塔 */
readonly competition: string;
/** 楼层数量 */
readonly floors: string;
/** 塔的数字 id */
readonly id: string;
/** 塔的英文名 */
readonly name: string;
/** 塔的游玩量 */
readonly people: string;
/** 塔标签,每一项是对应的标签名 */
readonly tag: string;
/** 塔的名称 */
readonly title: string;
/** 测试员列表 */
readonly topuser: string;
/** 通关人数 */
readonly win: string;
/** 精美评分,第一项是评分结果,后面五项是选择每个评分的人数 */
readonly designrate: number[];
/** 难度评分,第一项是评分结果,后面五项是选择每个评分的人数 */
readonly hardrate: number[];
}
/**
*
* @param path
* @returns
*/
export async function parseTowerInfo(
path: string
): Promise<Map<string, ITowerInfo>> {
const file = await readFile(path, 'utf-8');
const data = JSON.parse(file) as IRawTowerInfo[];
const result: ITowerInfo[] = data.map(v => {
return {
authorId: parseInt(v.authorId),
color: parseInt(v.color) as TowerColor,
comment: parseInt(v.comment),
competition: parseInt(v.competition) === 1,
floors: parseInt(v.floors),
id: parseInt(v.id),
name: v.name,
people: parseInt(v.people),
tag: v.tag.split('|').slice(0, -1),
title: v.title,
topuser: JSON.parse(v.topuser),
win: parseInt(v.win),
designrate: v.designrate.slice(),
hardrate: v.hardrate.slice()
};
});
const map = new Map<string, ITowerInfo>();
result.forEach(v => {
map.set(v.name, v);
});
return map;
}
function count(map: number[], set: Set<number>) {
let count = 0;
map.forEach(v => {
if (set.has(v)) count++;
});
return count;
}
/**
*
* @param map number[][]
* @param wallSet tile ID
* @param kernel 5 5x5
*/
export function computeWallDensityStd(
map: number[][],
wallSet: Set<number>,
kernel: number,
stride: number = Math.floor(kernel / 2)
): number {
const rows = map.length;
const cols = map[0]?.length ?? 0;
if (rows === 0 || cols === 0) return 0;
const densities: number[] = [];
// 遍历 kernel window 左上角坐标
for (let sy = 0; sy < rows; sy += stride) {
for (let sx = 0; sx < cols; sx += stride) {
let countWall = 0;
let countTotal = 0;
// 扫描 kernel 范围内的实际格子
for (let y = sy; y < sy + kernel && y < rows; y++) {
for (let x = sx; x < sx + kernel && x < cols; x++) {
countTotal++;
if (wallSet.has(map[y][x])) {
countWall++;
}
}
}
// 避免除零
if (countTotal > 0) {
const density = countWall / countTotal;
densities.push(density);
}
}
}
if (densities.length === 0) return 0;
// 求均值
const mean = densities.reduce((a, b) => a + b, 0) / densities.length;
// 求方差 (总体方差)
const variance =
densities.reduce((a, b) => a + (b - mean) ** 2, 0) / densities.length;
// 标准差
return Math.sqrt(variance);
}
/**
*
* @param tower
* @param map
*/
export function parseFloorInfo(tower: ITowerInfo, map: number[][]): IFloorInfo {
const topo = buildTopologicalGraph(map);
const flattened = map.flat();
const area = flattened.length;
let hasUselessBranch = false;
// 统计咸鱼门数量
let fishCount = 0;
topo.graphs.forEach(graph => {
// 其实就是判断纯血瓶钥匙的资源节点的邻居是不是全都是门,是的话就判定为咸鱼门
// 这么做虽然会有一定的误差,但是也大差不差了
// 两个门对一个也判定为一个咸鱼门
graph.areaMap.forEach(v => {
const res = [...v.resources.entries()];
const onlyPotion = res.every(([tile, value]) => {
if (!potionTiles.has(tile) && !keyTiles.has(tile)) {
return value <= 0;
}
return true;
});
if (!onlyPotion) {
// 包含血瓶钥匙之外的不考虑
return;
}
let branchCount = 0;
let noneBranchCount = 0;
v.neighbor.forEach(value => {
const node = graph.graph.get(value);
if (!node) {
noneBranchCount++;
return;
}
if (node.type === NodeType.Branch) {
if (!commonDoorTiles.has(node.tile)) {
branchCount++;
}
} else {
noneBranchCount++;
}
});
if (noneBranchCount >= 0 && branchCount === 0) {
fishCount++;
}
});
graph.graph.forEach(v => {
if (v.type === NodeType.Branch) {
if (v.neighbor.size === 1) {
hasUselessBranch = true;
}
}
});
});
const floorInfo: IFloorInfo = {
tower,
topo,
map,
globalDensity: count(flattened, nonEmptyTiles) / area,
wallDensity: count(flattened, wallTiles) / area,
doorDensity: count(flattened, doorTiles) / area,
enemyDensity: count(flattened, enemyTiles) / area,
resourceDensity: count(flattened, resourceTiles) / area,
gemDensity: count(flattened, gemTiles) / area,
potionDensity: count(flattened, potionTiles) / area,
keyDensity: count(flattened, keyTiles) / area,
itemDensity: count(flattened, itemTiles) / area,
entryCount: count(flattened, entryTiles),
specialDoorCount: count(flattened, specialDoorTiles),
fishCount,
hasUselessBranch,
wallDensityStd: computeWallDensityStd(map, wallTiles, 5)
};
return floorInfo;
}

427
data/src/auto/tower.ts Normal file
View File

@ -0,0 +1,427 @@
import { ICodeRunResult, IConvertedMap, INeededFloorData } from './types';
/**
*
* @param project project.min.js
* @param floors floors.min.js
*/
export function runTowerCode(project: string, floors: string): ICodeRunResult {
const result: Partial<ICodeRunResult> = {
issue: [],
main: {
floors: {}
}
};
const projectCode =
project +
`
result.data = data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d;
result.enemy = enemys_fcae963b_31c9_42b4_b48c_bb48d09f3f80;
result.map = maps_90f36752_8815_4be8_b32b_d7fad1d0542e;
result.item = items_296f5d02_12fd_4166_a7c1_b5e830c9ee3a;
`;
const main = result.main!;
try {
eval(projectCode);
eval(floors);
if (Object.keys(main.floors).length === 0) {
result.issue?.push(`楼层信息为空`);
}
} catch {
result.issue?.push(`代码运行错误`);
}
return result as ICodeRunResult;
}
function edge(x: number, y: number, width: number, height: number) {
return x === 0 || y === 0 || x === width - 1 || y === height - 1;
}
export function convertTowerMap(
result: ICodeRunResult,
floor: INeededFloorData
): IConvertedMap {
const width = floor.map[0].length;
const height = floor.map.length;
let hasCannotInOut = false;
const converted: number[][] = Array.from({ length: height }, () =>
Array.from<number>({ length: width }).fill(0)
);
/** 键表示怪物位置,值表示怪物的生命乘以攻加防 */
const enemyMap = new Map<number, number>();
/** 键表示道具位置,值表示道具增加的血、攻、防、盾属性 */
const itemMap = new Map<number, [number, number, number, number]>();
// 这些是为了区分宝石,一个地图只有两种宝石的话当然没必要把三种宝石都用上
const itemHpSet = new Set<number>();
const itemAtkSet = new Set<number>();
const itemDefSet = new Set<number>();
const itemMdefSet = new Set<number>();
const heroStatus: Record<string, number> = {
hp: 0,
atk: 0,
def: 0,
mdef: 0
};
const thisMap = {
ratio: 1
};
// 给后面的 eval 用的
const core = {
values: new Proxy(result.data.values, {
set() {
// 防止被修改
return true;
}
}),
status: {
hero: new Proxy(heroStatus, {
set(target, p: string, newValue) {
if (typeof newValue !== 'number') return true;
if (
p !== 'hp' &&
p !== 'atk' &&
p !== 'def' &&
p !== 'mdef'
) {
return true;
}
target[p] = newValue;
return true;
}
}),
thisMap: new Proxy(thisMap, {
set() {
// 防止被修改
return true;
}
})
}
};
core.status.hero.hp = 0;
core.status.hero.atk = 0;
core.status.hero.def = 0;
core.status.hero.mdef = 0;
for (let nx = 0; nx < width; nx++) {
for (let ny = 0; ny < height; ny++) {
const num = floor.map[ny][nx];
if (num === 0) {
converted[ny][nx] = 0;
continue;
} else if (num === 17) {
hasCannotInOut = true;
converted[ny][nx] = 0;
continue;
}
const loc = `${nx},${ny}`;
if (floor.changeFloor[loc]) {
converted[ny][nx] = 29;
continue;
}
const block = result.map[num];
if (!block) {
// 图块不存在说明是额外素材中的内容,默认不可通行,视为墙壁
converted[ny][nx] = 1;
continue;
}
// 怪物处理
if (block.cls === 'enemys' || block.cls === 'enemy48') {
const enemy = result.enemy[block.id];
if (!enemy) {
converted[ny][nx] = 0;
continue;
}
const value = enemy.hp * (enemy.atk + enemy.def);
enemyMap.set(ny * width + nx, value);
continue;
}
// 道具处理
if (block.cls === 'items') {
const item = result.item[block.id];
if (!item) {
converted[ny][nx] = 0;
continue;
}
// 先清空内容
heroStatus.hp = 0;
heroStatus.atk = 0;
heroStatus.def = 0;
heroStatus.mdef = 0;
if (block.id === 'pickaxe') {
converted[ny][nx] = 24;
continue;
} else if (block.id === 'bomb') {
converted[ny][nx] = 24;
continue;
} else if (block.id === 'centerFly') {
converted[ny][nx] = 23;
continue;
} else if (block.id === 'yellowKey') {
converted[ny][nx] = 7;
continue;
} else if (block.id === 'blueKey') {
converted[ny][nx] = 8;
continue;
} else if (block.id === 'redKey') {
converted[ny][nx] = 9;
continue;
}
// 执行道具效果
if (item.cls === 'items' && item.itemEffect) {
try {
eval(item.itemEffect);
} catch {
// 执行失败就清空一下防止被误识别为宝石血瓶
heroStatus.hp = 0;
heroStatus.atk = 0;
heroStatus.def = 0;
heroStatus.mdef = 0;
}
}
const arr: [number, number, number, number] = [
heroStatus.hp,
heroStatus.atk,
heroStatus.def,
heroStatus.mdef
];
let isResouce = false;
// 对每个属性进行判断
if (heroStatus.hp > 0) {
isResouce = true;
itemHpSet.add(heroStatus.hp);
}
if (heroStatus.atk > 0) {
isResouce = true;
itemAtkSet.add(heroStatus.atk);
}
if (heroStatus.def > 0) {
isResouce = true;
itemDefSet.add(heroStatus.def);
}
if (heroStatus.mdef > 0) {
isResouce = true;
itemMdefSet.add(heroStatus.mdef);
}
if (isResouce) {
itemMap.set(ny * width + nx, arr);
continue;
} else {
converted[ny][nx] = 0;
continue;
}
}
// 门信息,这种处理方式只能处理 2.7+ 的塔,老塔估计处理不了,不过老塔占比也不大,忽略就好了
if (block.doorInfo && Object.keys(block.doorInfo.keys).length > 0) {
if (block.id === 'specialDoor') {
converted[ny][nx] = 6;
continue;
} else if (
'redKey' in block.doorInfo.keys ||
'greenKey' in block.doorInfo.keys
) {
converted[ny][nx] = 5;
continue;
} else if ('blueKey' in block.doorInfo.keys) {
converted[ny][nx] = 4;
continue;
} else if ('yellowKey' in block.doorInfo.keys) {
converted[ny][nx] = 3;
continue;
} else {
// 其他门一律视为黄门
converted[ny][nx] = 3;
continue;
}
}
// 不可入和不可出现在还没办法处理,这两个还需要同时考虑背景前景
const bgNum = floor.bgmap?.[ny]?.[nx] ?? 0;
const bg2Num = floor.bg2map?.[ny]?.[nx] ?? 0;
const fgNum = floor.fgmap?.[ny]?.[nx] ?? 0;
const fg2Num = floor.fg2map?.[ny]?.[nx] ?? 0;
const bgBlock = result.map[bgNum];
const bg2Block = result.map[bg2Num];
const fgBlock = result.map[fgNum];
const fg2Block = result.map[fg2Num];
if (
block.cannotIn ||
block.cannotOut ||
bgBlock?.cannotIn ||
bgBlock?.cannotOut ||
bg2Block?.cannotIn ||
bg2Block?.cannotOut ||
fgBlock?.cannotIn ||
fgBlock?.cannotOut ||
fg2Block?.cannotIn ||
fg2Block?.cannotOut
) {
converted[ny][nx] = 0;
hasCannotInOut = true;
continue;
}
// 墙壁处理
if (block.canPass) {
converted[ny][nx] = 0;
continue;
} else {
converted[ny][nx] = 1;
}
}
}
// 处理怪物
const minEnemyValue = Math.min(...enemyMap.values());
const maxEnemyValue = Math.max(...enemyMap.values());
const enemyValueDelta = maxEnemyValue - minEnemyValue;
if (enemyValueDelta <= 0) {
// 如果怪物战斗力都一样的话...
enemyMap.forEach((value, pos) => {
const nx = pos % width;
const ny = Math.floor(pos / width);
converted[ny][nx] = 26;
});
} else {
enemyMap.forEach((value, pos) => {
const nx = pos % width;
const ny = Math.floor(pos / width);
const ratio = (value - minEnemyValue) / enemyValueDelta;
const n = Math.min(Math.floor(ratio * 3), 2);
converted[ny][nx] = 26 + n;
});
}
// 处理宝石血瓶
const minHpValue = Math.min(...itemHpSet);
const maxHpValue = Math.max(...itemHpSet);
const minAtkValue = Math.min(...itemAtkSet);
const maxAtkValue = Math.max(...itemAtkSet);
const minDefValue = Math.min(...itemDefSet);
const maxDefValue = Math.max(...itemDefSet);
const minMdefValue = Math.min(...itemMdefSet);
const maxMdefValue = Math.max(...itemMdefSet);
const hpValueDelta = maxHpValue - minHpValue;
const atkValueDelta = maxAtkValue - minAtkValue;
const defValueDelta = maxDefValue - minDefValue;
const mdefValueDelta = maxMdefValue - minMdefValue;
itemMap.forEach(([hp, atk, def, mdef], pos) => {
const nx = pos % width;
const ny = Math.floor(pos / width);
// 资源判定为占比最大的那个
// 如果只有一种资源且道具包含这种属性,全部使用最低的资源种类
if (minHpValue === maxHpValue && hp > 0) {
converted[ny][nx] = 19;
return;
}
if (minAtkValue === maxAtkValue && atk > 0) {
converted[ny][nx] = 10;
return;
}
if (minDefValue === maxDefValue && def > 0) {
converted[ny][nx] = 13;
return;
}
if (minMdefValue === maxMdefValue && mdef > 0) {
converted[ny][nx] = 16;
return;
}
const hpRatio = (hp - minHpValue) / hpValueDelta;
const atkRatio = (atk - minAtkValue) / atkValueDelta;
const defRatio = (def - minDefValue) / defValueDelta;
const mdefRatio = (mdef - minMdefValue) / mdefValueDelta;
// 判断资源种类
const arr = [hpRatio, atkRatio, defRatio, mdefRatio];
let maxIndex = 0;
let maxRatio = 0;
for (let i = 0; i < arr.length; i++) {
if (arr[i] > maxRatio) {
maxRatio = arr[i];
maxIndex = i;
}
}
// 转换图块,对于宝石来说,一共有三个级别的宝石,如果数值只有两种,那么需要额外判断下使用哪两个级别的宝石
// 倍数差距大于 3 的就使用一级和三级宝石,小于等于 3 的就使用一级和二级宝石
// 血瓶不做这个处理
switch (maxIndex) {
case 0: {
// 血瓶
const n = Math.min(Math.floor(hpRatio * 4), 3);
converted[ny][nx] = 19 + n;
break;
}
case 1: {
// 红宝石,这里不可能只有一种数值了,不需要判断
if (itemAtkSet.size === 2) {
if (atkRatio <= 0.5) {
// 小宝石
converted[ny][nx] = 10;
} else {
// 大宝石
if (maxAtkValue / minAtkValue > 3) {
converted[nx][nx] = 12;
} else {
converted[ny][nx] = 11;
}
}
} else {
const n = Math.min(Math.floor(atkRatio * 3), 2);
converted[ny][nx] = 10 + n;
}
break;
}
case 2: {
// 蓝宝石,这里不可能只有一种数值了,不需要判断
if (itemDefSet.size === 2) {
if (defRatio <= 0.5) {
// 小宝石
converted[ny][nx] = 13;
} else {
// 大宝石
if (maxDefValue / minDefValue > 3) {
converted[nx][nx] = 15;
} else {
converted[ny][nx] = 14;
}
}
} else {
const n = Math.min(Math.floor(defRatio * 3), 2);
converted[ny][nx] = 13 + n;
}
break;
}
case 2: {
// 绿宝石,这里不可能只有一种数值了,不需要判断
if (itemMdefSet.size === 2) {
if (mdefRatio <= 0.5) {
// 小宝石
converted[ny][nx] = 16;
} else {
// 大宝石
if (maxMdefValue / minMdefValue > 3) {
converted[nx][nx] = 18;
} else {
converted[ny][nx] = 17;
}
}
} else {
const n = Math.min(Math.floor(mdefRatio * 3), 2);
converted[ny][nx] = 16 + n;
}
break;
}
}
});
return {
map: converted,
hasCannotInOut
};
}

214
data/src/auto/types.ts Normal file
View File

@ -0,0 +1,214 @@
import { GinkaTopologicalGraphs } from 'src/topology/interface';
export const enum TowerColor {
White,
Orange,
Blue,
Green,
Red,
Purple
}
export interface IConvertedMap {
/** 地图矩阵 */
readonly map: number[][];
/** 是否包含不可入不可出图块 */
readonly hasCannotInOut: boolean;
}
export interface IConvertedMapInfo {
/** 转换后的地图 */
readonly data: IConvertedMap;
/** 地图信息 */
readonly info: IFloorInfo;
/** 地图所属塔信息 */
readonly tower: ITowerInfo;
/** 地图 id */
readonly mapId: string;
}
export interface ITowerInfo {
/** 作者 id */
readonly authorId: number;
/** 塔颜色 */
readonly color: TowerColor;
/** 评论数量 */
readonly comment: number;
/** 是否是比赛塔 */
readonly competition: boolean;
/** 楼层数量 */
readonly floors: number;
/** 塔的数字 id */
readonly id: number;
/** 塔的英文名 */
readonly name: string;
/** 塔的游玩量 */
readonly people: number;
/** 塔标签,每一项是对应的标签名 */
readonly tag: string[];
/** 塔的名称 */
readonly title: string;
/** 测试员列表 */
readonly topuser: number[];
/** 通关人数 */
readonly win: number;
/** 精美评分,第一项是评分结果,后面五项是选择每个评分的人数 */
readonly designrate: number[];
/** 难度评分,第一项是评分结果,后面五项是选择每个评分的人数 */
readonly hardrate: number[];
}
export interface IFloorInfo {
/** 楼层所属的塔信息 */
readonly tower: ITowerInfo;
/** 楼层拓扑图 */
readonly topo: GinkaTopologicalGraphs;
/** 地图矩阵 */
readonly map: number[][];
/** 地图整体密度,非空白图块/地图面积 */
readonly globalDensity: number;
/** 墙壁密度,墙壁数量/地图面积 */
readonly wallDensity: number;
/** 门密度,门数量/地图面积 */
readonly doorDensity: number;
/** 怪物密度,怪物数量/地图面积 */
readonly enemyDensity: number;
/** 资源密度,资源数量/地图面积,资源包括宝石、血瓶、道具、钥匙 */
readonly resourceDensity: number;
/** 宝石密度,宝石数量/地图面积 */
readonly gemDensity: number;
/** 血瓶密度,血瓶数量/地图面积 */
readonly potionDensity: number;
/** 钥匙密度,钥匙数量/地图面积 */
readonly keyDensity: number;
/** 道具密度,道具数量/地图面积,道具指破炸飞这些内容 */
readonly itemDensity: number;
/** 入口数量 */
readonly entryCount: number;
/** 机关门数量 */
readonly specialDoorCount: number;
/** 咸鱼门数量,多层咸鱼门算一个 */
readonly fishCount: number;
/** 是否包含只连接了一个节点的分支节点。这种节点相当于门或怪物后面什么都不加,多数是无用的。 */
readonly hasUselessBranch: boolean;
/** 墙壁密度标准差 */
readonly wallDensityStd: number;
}
export interface IAutoLabelConfig {
/** 地图允许大小 */
readonly allowedSize: [number, number][];
/** 是否允许无用节点 */
readonly allowUselessBranch: boolean;
/** 最小怪物占比 */
readonly minEnemyRatio: number;
/** 最大怪物占比 */
readonly maxEnemyRatio: number;
/** 最小墙壁占比 */
readonly minWallRatio: number;
/** 最大墙壁占比 */
readonly maxWallRatio: number;
/** 血瓶+宝石+道具+钥匙之和最小占比 */
readonly minResourceRatio: number;
/** 血瓶+宝石+道具+钥匙之和最小占比 */
readonly maxResourceRatio: number;
/** 最小门占比 */
readonly minDoorRatio: number;
/** 最大门占比 */
readonly maxDoorRatio: number;
/** 最小咸鱼门数量,多层咸鱼门算一个 */
readonly minFishCount: number;
/** 最大咸鱼门数量,多层咸鱼门算一个 */
readonly maxFishCount: number;
/** 最小入口数量 */
readonly minEntryCount: number;
/** 最大入口数量 */
readonly maxEntryCount: number;
/** 最大墙壁密度标准差,用于描述一个地图墙壁分布是否均匀的,较大的时候可能是特殊地图,不符合要求 */
readonly maxWallDensityStd: number;
/** 是否忽略问题 */
readonly ignoreIssues: boolean;
/**
*
* @param info
*/
customTowerFilter?: (info: ITowerInfo) => boolean;
/**
*
* @param floor
*/
customFloorFilter?: (floor: IConvertedMapInfo) => boolean;
}
export interface INeededCoreData {
readonly main: {
readonly floorIds: readonly string[];
};
readonly firstData: {
readonly name: string;
};
readonly values: {
readonly redGem: number;
readonly blueGem: number;
readonly greenGem: number;
readonly redPotion: number;
readonly bluePotion: number;
readonly greenPotion: number;
readonly yellowPotion: number;
};
}
export interface INeededEnemyData {
readonly hp: number;
readonly atk: number;
readonly def: number;
}
export interface INeededMapData {
readonly cls: string;
readonly id: string;
readonly canPass?: boolean;
readonly trigger?: string;
readonly script?: string;
readonly cannotOut?: string[];
readonly cannotIn?: string[];
readonly doorInfo?: {
readonly keys: Record<string, number>;
};
}
export interface INeededItemData {
readonly cls: string;
readonly useItemEvent?: any;
readonly itemEffect?: string;
readonly canUseItemEffect?: string;
readonly equip?: {
readonly value?: Record<string, number>;
readonly percentage?: Record<string, number>;
};
}
export interface INeededFloorData {
readonly floorId: string;
readonly map: number[][];
readonly bgmap?: number[][];
readonly bg2map?: number[][];
readonly fgmap?: number[][];
readonly fg2map?: number[][];
readonly changeFloor: Record<string, unknown>;
}
export interface ICodeRunResult {
issue: string[];
data: INeededCoreData;
enemy: Record<string, INeededEnemyData>;
map: Record<number, INeededMapData>;
item: Record<string, INeededItemData>;
main: {
floors: Record<string, INeededFloorData>;
};
}

27
data/src/shared.ts Normal file
View File

@ -0,0 +1,27 @@
// 基本图块定义
export const emptyTiles = new Set([0]);
export const wallTiles = new Set([1]);
export const decorationTiles = new Set([2]);
export const commonDoorTiles = new Set([3, 4, 5]);
export const specialDoorTiles = new Set([6]);
export const keyTiles = new Set([7, 8, 9]);
export const redGemTiles = new Set([10, 11, 12]);
export const blueGemTiles = new Set([13, 14, 15]);
export const greenGemTiles = new Set([16, 17, 18]);
export const potionTiles = new Set([19, 20, 21, 22]);
export const itemTiles = new Set([23, 24, 25]);
export const enemyTiles = new Set([26, 27, 28]);
export const entryTiles = new Set([29]);
// 组合图块定义
export const doorTiles = commonDoorTiles.union(specialDoorTiles);
export const gemTiles = redGemTiles.union(blueGemTiles).union(greenGemTiles);
export const resourceTiles = keyTiles
.union(gemTiles)
.union(potionTiles)
.union(itemTiles);
export const nonEmptyTiles = wallTiles
.union(doorTiles)
.union(resourceTiles)
.union(enemyTiles)
.union(enemyTiles);

View File

@ -3,7 +3,8 @@ import {
GinkaGraph,
BranchNode,
GinkaTopologicalGraphs,
ResourceNode
ResourceNode,
NodeType
} from './interface';
export const tileType = new Set(
@ -76,7 +77,7 @@ function buildGraphFromEntrance(
const ny = Math.floor(v / width);
if (!graph.get(v)) {
graph.set(v, {
type: 'branch',
type: NodeType.Branch,
neighbor: new Set(),
tile: map[ny][nx]
});
@ -115,7 +116,7 @@ function buildGraphFromEntrance(
const tile = map[ny][nx];
if (tile === 0) return;
const node: ResourceNode = {
type: 'resource',
type: NodeType.Resource,
resourceType: tile,
neighbor: v.neighbor,
resourceArea: v
@ -145,7 +146,7 @@ function findResourceNodes(map: number[][]) {
const queue: [number, number][] = [];
queue.push([nx, ny]);
const area: ResourceArea = {
type: 'resource',
type: NodeType.Resource,
resources: new Map([[tile, 1]]),
members: new Set([index]),
neighbor: new Set()

View File

@ -1,43 +1,59 @@
export const enum NodeType {
Branch,
Resource
}
export interface ResourceArea {
type: 'resource';
resources: Map<number, number>;
members: Set<number>;
neighbor: Set<number>;
/** 节点类型 */
readonly type: NodeType.Resource;
/** 每种资源对应的数量 */
readonly resources: Map<number, number>;
/** 资源区域包含的所有资源图块坐标索引 */
readonly members: Set<number>;
/** 资源区域的邻居节点 */
readonly neighbor: Set<number>;
}
export interface BranchNode {
type: 'branch';
neighbor: Set<number>;
tile: number;
/** 节点类型 */
readonly type: NodeType.Branch;
/** 分支节点的邻居节点 */
readonly neighbor: Set<number>;
/** 分支节点图块 */
readonly tile: number;
}
export interface ResourceNode {
type: 'resource';
resourceType: number;
neighbor: Set<number>;
resourceArea: ResourceArea;
/** 节点类型 */
readonly type: NodeType.Resource;
/** 资源类型 */
readonly resourceType: number;
/** 邻居节点 */
readonly neighbor: Set<number>;
/** 资源节点所属的资源区域 */
readonly resourceArea: ResourceArea;
}
export type GinkaNode = BranchNode | ResourceNode;
export interface GinkaGraph {
/** 拓扑图内容,键表示位置,值表示这一点的节点 */
graph: Map<number, GinkaNode>;
readonly graph: Map<number, GinkaNode>;
/** 资源指针,键表示位置,值表示这一点对应的资源节点在 areaMap 的索引 */
resourceMap: Map<number, number>;
readonly resourceMap: Map<number, number>;
/** 资源区域列表 */
areaMap: ResourceArea[];
readonly areaMap: ResourceArea[];
/** 这个拓扑图包含的入口位置 */
visitedEntrance: Set<number>;
readonly visitedEntrance: Set<number>;
/** 这个拓扑图能够造访的所有位置 */
visited: Set<number>;
readonly visited: Set<number>;
}
export interface GinkaTopologicalGraphs {
/** 这个地图包含的所有独立的图 */
graphs: GinkaGraph[];
readonly graphs: GinkaGraph[];
/** 每个入口对应哪个图 */
entranceMap: Map<number, GinkaGraph>;
readonly entranceMap: Map<number, GinkaGraph>;
/** 这个图从入口开始的不可到达区域 */
unreachable: Set<number>;
readonly unreachable: Set<number>;
}

View File

@ -1,5 +1,5 @@
import { cosineSimilarity } from 'src/utils';
import { GinkaGraph, GinkaTopologicalGraphs } from './interface';
import { GinkaGraph, GinkaTopologicalGraphs, NodeType } from './interface';
interface WLNode {
originalPos: number;
@ -16,7 +16,7 @@ function encodeNodeLabels(graph: GinkaGraph) {
let label: string;
// 编码为唯一哈希值用字符串就行V8 会自动帮你算哈希)
if (node.type === 'branch') {
if (node.type === NodeType.Branch) {
label = `B:${node.tile}`;
} else {
label = `R:${node.resourceType}`;

View File

@ -15,12 +15,16 @@ from .generator.loss import WGANGinkaLoss
from .critic.model import MinamoModel2
from shared.image import matrix_to_image_cv
# 标签定义:
# 手工标注标签定义:
# 0. 蓝海, 1. 红海, 2: 室内, 3. 野外, 4. 左右对称, 5. 上下对称, 6. 伪对称, 7. 咸鱼层,
# 8. 剧情层, 9. 水层, 10. 爽塔, 11. Boss层, 12. 纯Boss层, 13. 多房间, 14. 多走廊, 15. 道具风
# 16. 区域入口, 17. 区域连接, 18. 有机关门, 19. 道具层, 20. 斜向对称, 21. 左右通道, 22. 上下通道, 23. 多机关门
# 24. 中心对称, 25. 部分对称, 26. 鱼骨
# 自动标注标签定义:
# 0. 左右对称, 1. 上下对称, 2. 中心对称, 3. 斜向对称, 4. 伪对称, 5. 多房间, 6. 多走廊
# 32. 平面塔, 33. 转换塔, 34. 道具塔
# 标量值定义:
# 0. 整体密度,非空白图块/地图面积,空白图块还包括装饰图块
# 1. 墙体密度,墙壁/地图面积
@ -31,8 +35,10 @@ from shared.image import matrix_to_image_cv
# 6. 宝石密度,宝石数量/地图面积
# 7. 血瓶密度,血瓶数量/地图面积
# 8. 钥匙密度,钥匙数量/地图面积
# 9. 道具数量
# 9. 道具密度,道具数量/地图面积
# 10. 入口数量
# 11. 机关门数量
# 12. 咸鱼门数量(多层咸鱼门只算一个)
# 图块定义:
# 0. 空地, 1. 墙壁, 2. 装饰(用于野外装饰,视为空地),
@ -44,8 +50,7 @@ from shared.image import matrix_to_image_cv
# 19-22. 四种等级的血瓶
# 23-25. 三种等级的道具
# 26-28. 三种等级的怪物
# 29. 楼梯入口
# 30. 箭头入口
# 29. 入口,不区分楼梯和箭头
BATCH_SIZE = 6

69
shared/visual.py Normal file
View File

@ -0,0 +1,69 @@
import os
import cv2
import json
import numpy as np
from tqdm import tqdm
from .image import matrix_to_image_cv
# -------------------------
# 加载 tile 图块
# -------------------------
def load_tiles(tile_folder):
tile_dict = {}
for file in os.listdir(tile_folder):
name, _ = os.path.splitext(file)
img = cv2.imread(os.path.join(tile_folder, file), cv2.IMREAD_UNCHANGED)
if img is None:
print(f"[WARN] Tile image {file} 读取失败,跳过")
continue
tile_dict[name] = img
print(f"加载了 {len(tile_dict)} 个图块")
return tile_dict
# -------------------------
# 主处理逻辑
# -------------------------
def convert_dataset_to_images(
json_path,
tile_folder,
output_folder,
tile_size=32
):
# 输出路径
os.makedirs(output_folder, exist_ok=True)
# 加载 tiles
tile_dict = load_tiles(tile_folder)
# 读取 json
with open(json_path, "r", encoding="utf-8") as f:
dataset = json.load(f)
data = dataset["data"]
for map_id, train_data in tqdm(data.items()):
map_matrix = np.array(train_data["map"])
try:
img = matrix_to_image_cv(map_matrix, tile_dict, tile_size)
except Exception as e:
print(f"[ERROR] 地图 {map_id} 转换失败: {e}")
continue
out_path = os.path.join(output_folder, f"{map_id.replace('::', '-')}.png")
cv2.imwrite(out_path, img)
print('地图处理完毕!')
# -------------------------
# 执行
# -------------------------
if __name__ == "__main__":
convert_dataset_to_images(
json_path="data/result.json", # 数据集文件
tile_folder="tiles", # 贴图文件夹
output_folder="map_images", # 输出文件夹
tile_size=32 # tile 尺寸
)