NFT 制作保姆级进阶教程:批量图片(盲盒、头像、IP 造型等)合成

in #nft3 months ago

系列文章

在这里插入图片描述

微信联系

背景说明

无论是这样的:
在这里插入图片描述
还是这样的:
在这里插入图片描述
其实都是由背景、角色、组件拼接而成的图片。

假设用以下素材去生成 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'));

其实比较看一下代码的差异就知道了。很容易理解。

生成成功后,发布即可。