diff --git a/.gitignore b/.gitignore index a638c70..9383389 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ minamo-eval.json datasets *.log app/model/* -!app/model/.gitkeep \ No newline at end of file +!app/model/.gitkeep +map_images +data/result.json \ No newline at end of file diff --git a/data/package.json b/data/package.json index bbbb21c..b859ef9 100644 --- a/data/package.json +++ b/data/package.json @@ -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" } } diff --git a/data/pnpm-lock.yaml b/data/pnpm-lock.yaml index 8bb93f9..9cb3ab8 100644 --- a/data/pnpm-lock.yaml +++ b/data/pnpm-lock.yaml @@ -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 diff --git a/data/src/auto.ts b/data/src/auto.ts new file mode 100644 index 0000000..3d90afc --- /dev/null +++ b/data/src/auto.ts @@ -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 = { + 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}`); +})(); diff --git a/data/src/auto/auto.ts b/data/src/auto/auto.ts new file mode 100644 index 0000000..a89fa28 --- /dev/null +++ b/data/src/auto/auto.ts @@ -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; +} diff --git a/data/src/auto/info.ts b/data/src/auto/info.ts new file mode 100644 index 0000000..9906b51 --- /dev/null +++ b/data/src/auto/info.ts @@ -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> { + 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(); + result.forEach(v => { + map.set(v.name, v); + }); + return map; +} + +function count(map: number[], set: Set) { + 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, + 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; +} diff --git a/data/src/auto/tower.ts b/data/src/auto/tower.ts new file mode 100644 index 0000000..903fb05 --- /dev/null +++ b/data/src/auto/tower.ts @@ -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 = { + 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({ length: width }).fill(0) + ); + + /** 键表示怪物位置,值表示怪物的生命乘以攻加防 */ + const enemyMap = new Map(); + + /** 键表示道具位置,值表示道具增加的血、攻、防、盾属性 */ + const itemMap = new Map(); + // 这些是为了区分宝石,一个地图只有两种宝石的话当然没必要把三种宝石都用上 + const itemHpSet = new Set(); + const itemAtkSet = new Set(); + const itemDefSet = new Set(); + const itemMdefSet = new Set(); + + const heroStatus: Record = { + 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 + }; +} diff --git a/data/src/auto/types.ts b/data/src/auto/types.ts new file mode 100644 index 0000000..236cd96 --- /dev/null +++ b/data/src/auto/types.ts @@ -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; + }; +} + +export interface INeededItemData { + readonly cls: string; + readonly useItemEvent?: any; + readonly itemEffect?: string; + readonly canUseItemEffect?: string; + readonly equip?: { + readonly value?: Record; + readonly percentage?: Record; + }; +} + +export interface INeededFloorData { + readonly floorId: string; + readonly map: number[][]; + readonly bgmap?: number[][]; + readonly bg2map?: number[][]; + readonly fgmap?: number[][]; + readonly fg2map?: number[][]; + readonly changeFloor: Record; +} + +export interface ICodeRunResult { + issue: string[]; + data: INeededCoreData; + enemy: Record; + map: Record; + item: Record; + main: { + floors: Record; + }; +} diff --git a/data/src/shared.ts b/data/src/shared.ts new file mode 100644 index 0000000..d346f32 --- /dev/null +++ b/data/src/shared.ts @@ -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); diff --git a/data/src/topology/graph.ts b/data/src/topology/graph.ts index db62d62..7f38dd8 100644 --- a/data/src/topology/graph.ts +++ b/data/src/topology/graph.ts @@ -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() diff --git a/data/src/topology/interface.ts b/data/src/topology/interface.ts index c038946..229a7bc 100644 --- a/data/src/topology/interface.ts +++ b/data/src/topology/interface.ts @@ -1,43 +1,59 @@ +export const enum NodeType { + Branch, + Resource +} + export interface ResourceArea { - type: 'resource'; - resources: Map; - members: Set; - neighbor: Set; + /** 节点类型 */ + readonly type: NodeType.Resource; + /** 每种资源对应的数量 */ + readonly resources: Map; + /** 资源区域包含的所有资源图块坐标索引 */ + readonly members: Set; + /** 资源区域的邻居节点 */ + readonly neighbor: Set; } export interface BranchNode { - type: 'branch'; - neighbor: Set; - tile: number; + /** 节点类型 */ + readonly type: NodeType.Branch; + /** 分支节点的邻居节点 */ + readonly neighbor: Set; + /** 分支节点图块 */ + readonly tile: number; } export interface ResourceNode { - type: 'resource'; - resourceType: number; - neighbor: Set; - resourceArea: ResourceArea; + /** 节点类型 */ + readonly type: NodeType.Resource; + /** 资源类型 */ + readonly resourceType: number; + /** 邻居节点 */ + readonly neighbor: Set; + /** 资源节点所属的资源区域 */ + readonly resourceArea: ResourceArea; } export type GinkaNode = BranchNode | ResourceNode; export interface GinkaGraph { /** 拓扑图内容,键表示位置,值表示这一点的节点 */ - graph: Map; + readonly graph: Map; /** 资源指针,键表示位置,值表示这一点对应的资源节点在 areaMap 的索引 */ - resourceMap: Map; + readonly resourceMap: Map; /** 资源区域列表 */ - areaMap: ResourceArea[]; + readonly areaMap: ResourceArea[]; /** 这个拓扑图包含的入口位置 */ - visitedEntrance: Set; + readonly visitedEntrance: Set; /** 这个拓扑图能够造访的所有位置 */ - visited: Set; + readonly visited: Set; } export interface GinkaTopologicalGraphs { /** 这个地图包含的所有独立的图 */ - graphs: GinkaGraph[]; + readonly graphs: GinkaGraph[]; /** 每个入口对应哪个图 */ - entranceMap: Map; + readonly entranceMap: Map; /** 这个图从入口开始的不可到达区域 */ - unreachable: Set; + readonly unreachable: Set; } diff --git a/data/src/topology/similarity.ts b/data/src/topology/similarity.ts index 9275d3c..b10e0a5 100644 --- a/data/src/topology/similarity.ts +++ b/data/src/topology/similarity.ts @@ -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}`; @@ -59,7 +59,7 @@ function weisfeilerLehmanIteration( const neighborLabels = node.neighbors .map(n => n.currentLabel) .sort(); - + const compositeLabel = `${node.currentLabel}|${neighborLabels.join( ',' )}`.slice(0, 8192); diff --git a/ginka/train_wgan.py b/ginka/train_wgan.py index 70d6bd7..cce18e1 100644 --- a/ginka/train_wgan.py +++ b/ginka/train_wgan.py @@ -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 diff --git a/shared/visual.py b/shared/visual.py new file mode 100644 index 0000000..0db6f1e --- /dev/null +++ b/shared/visual.py @@ -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 尺寸 + ) \ No newline at end of file