NFT 制作保姆级进阶教程:批量图片(盲盒、头像、IP 造型等)合成
系列文章
- NFT 制作生成简单入门——批量道具藏品生成
- NFT 制作生成进阶:男女性别区分+特殊款形象/头像完整项目
- NFT 制作保姆级进阶教程:批量图片(盲盒、头像、IP 造型等)合成
- NFT 生成物及素材展示(小熊、性别形象、道具)
微信联系
背景说明
无论是这样的:
还是这样的:
其实都是由背景、角色、组件拼接而成的图片。
假设用以下素材去生成 NFT 盲盒:
- 背景: 10 种不同的颜色
- 角色: 10 种不同的肤色
- 配饰:
- 10 种帽子
- 10 种衣服
- 10 种手持物品
这么简单的 50 个素材,就已经可以拼接成 10*10*10*10*10 = 10 万种不同的组合。
保姆级教程
以虚拟项目案例手把手入门。示例项目代码下载: https://download.csdn.net/download/jslygwx/77829790
假设项目情况:
- 共计合成 10 万张图片
- 有 8 种类型素材(背景、角色、肤色、物品、配饰之类的,共计 8 种)
- 每种类型再提供 10 张素材图片
- 稀有程度设定(总计概率 100%)
1 / 100,
2 / 100,
3 / 100,
4 / 100,
10 / 100,
10 / 100,
16 / 100,
17 / 100,
18 / 100,
19 / 100
就随便设置这么样个概率作为演示。(后续进阶教程再说特定稀有程度设定和个性化素材配置的相关内容)
生成组合
从简单例子着手,比如说 8 种部件类型,就按 0~7 的顺序分别建立文件夹,示例:
- 0 背景图
- 1 角色
- 2 角色肤色
- 3 角色衣服
- 4 角色帽子
- 。。。。。。
// 设置总数 10 万
const TOTAL = 1e5;
// 类型定义,分别为 0 1 2 3 4 5 6 7
const TYPE = new Array(8).fill(0).map((_, i) => i);
// 部件定义,分别为 0 1 2 3 ... 9
// 如果超过 10 ,可以用字母 a b c d e 继续排序
const COMP = new Array(10).fill(0).map((_, i) => i);
// 对应 0 ~ 9 号部件生成的概率
const PR = [
1 / 100,
2 / 100,
3 / 100,
4 / 100,
10 / 100,
10 / 100,
16 / 100,
17 / 100,
18 / 100,
19 / 100
];
const START_TIME = new Date();
const RESULTS = new Set<string>();
// 打乱并生成
const END_TIME = new Date();
console.log(`Spent: ${END_TIME.getTime() - START_TIME.getTime()}ms`);
console.log(JSON.stringify([...RESULTS], null, 2));
以上算法(见项目示例代码),生成 500 万条数据应该也是在 1s 以内。
执行结果:
Spent: 34ms
[
"99696789",
"64766443",
"44467446",
// 。。。 10 万条数据
]
为什么使用随机种子。因为这样能保证所有的组合,都正好符合概率,而且保证了程序运行的性能。
如果觉得生成的结果不够乱序,可以在上述标记排序的位置修改排序规则,或者多执行几次 sort()
打乱。
生成图片 (单一,一张)
其中以 99696789
为例说明:
- 背景:9 ——第 10 种普通素材
- 角色:9 ——第 10 种普通素材
- 肤色:6 ——第 7 种普通素材
- ……
将各个素材从底向上拼接成图片,一个 NFT 即为生成。
生成图片:
node generate.js 99001122
生成单张图片,大约在 0.5~1.5 秒左右。
批量生成 NFT 图片
这里我们可以用到多线程和消息队列。
本地先启动 Redis(可以清空 db0)。跑一遍该脚本,把 10 万条任务插入到队列:
import * as fs from 'fs';
import * as path from 'path';
import { WhiteQ } from 'whiteq';
const wq = new WhiteQ();
const file = fs.readFileSync(path.join(__dirname, '../100k.log'), 'utf-8');
const tasks = file.split('\n');
wq.addJobs(
'tasks',
tasks.map((t) => ({ name: t, data: t }))
)
.then(() => {
process.exit(0);
})
.catch(console.error);
然后用 PM2 执行该脚本去多线程执行生成任务:
const { WhiteQ } = require('whiteq');
const { execSync } = require('child_process');
const path = require('path');
const wq = new WhiteQ();
wq.worker('tasks', async (job) => {
console.log(job.name);
const result = execSync(`node ${path.join(__dirname, 'generate.js')} ${job.name}`, { encoding: 'utf-8' });
if (result.includes('Time spent')) {
return true;
}
console.log(result);
return false;
});
实测 10 万张图片生成的话,大约 5 个小时左右;一万张图片大约不到半个小时。
图片压缩
可以把原有 Redis 记录清空,并重新插入任务队列。
使用 pngquant 压缩,能将原本 3MB 左右的 png图片压缩到 100~300KB。
批量重命名
压缩过的图片都带有 -fs8.png
的文件后缀,如果想要去掉,则可以使用批量重命名的方式:
const { WhiteQ } = require('whiteq');
const { execSync } = require('child_process');
const path = require('path');
const wq = new WhiteQ();
wq.worker('tasks', async (job) => {
console.log(job.name);
execSync(`rm -rf ${path.join(__dirname, `../output/${job.name}.png`)}`);
execSync(
`mv ${path.join(__dirname, `../output/${job.name}-fs8.png`)} ${path.join(__dirname, `../output/${job.name}.png`)}`
);
return true;
});
进阶教程
你可以对于部件的类型,每种部件元素的数量,以及各个元素掉落概率都有定制化的要求,那么则可以参考这个生成脚本来生成组合:
const TOTAL = 1e5;
const START_TIME = new Date();
const PR = {
'1background': [
8.8 / 100,
9.0 / 100,
8.7 / 100,
9.1 / 100,
9.0 / 100,
8.9 / 100,
9.2 / 100,
8.8 / 100,
8.5 / 100,
10 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100
],
'2skin': [
9.1 / 100,
9.2 / 100,
9.3 / 100,
9.0 / 100,
9.1 / 100,
8.4 / 100,
9.7 / 100,
8.5 / 100,
9.9 / 100,
8.8 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100
],
'3clothes': [
9.1 / 100,
9.2 / 100,
9.3 / 100,
9.0 / 100,
9.1 / 100,
8.4 / 100,
9.7 / 100,
8.5 / 100,
9.9 / 100,
8.8 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100
],
'4necklace': [
9.1 / 100,
10.5 / 100,
10.1 / 100,
10.6 / 100,
9.1 / 100,
9.0 / 100,
10.7 / 100,
9.2 / 100,
9.9 / 100,
10.8 / 100,
1 / 100
],
'5bag': [
9.6 / 100,
10.5 / 100,
10.1 / 100,
10.6 / 100,
9.1 / 100,
9.5 / 100,
10.7 / 100,
9.2 / 100,
9.9 / 100,
10.8 / 100
],
'6head': [
9.1 / 100,
9.3 / 100,
10.1 / 100,
9.4 / 100,
9.1 / 100,
9.0 / 100,
9.4 / 100,
9.2 / 100,
9.0 / 100,
9.4 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100
],
'7glasses': [
9.1 / 100,
9.2 / 100,
9.3 / 100,
9.2 / 100,
9.1 / 100,
8.8 / 100,
9.7 / 100,
8.9 / 100,
8.9 / 100,
8.8 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100
],
'8accessories': [
8.1 / 100,
9.2 / 100,
8.3 / 100,
9.0 / 100,
9.1 / 100,
8.4 / 100,
9.7 / 100,
8.5 / 100,
8.9 / 100,
8.8 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100,
1 / 100
]
};
const END_TIME = new Date();
console.log(`Spent: ${END_TIME.getTime() - START_TIME.getTime()}ms`);
console.log([...RESULTS].join('\n'));
其实比较看一下代码的差异就知道了。很容易理解。
生成成功后,发布即可。