ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

NFT艺术品随机生成器源码详解:图层合成、权重与DNA机制

NFT艺术品随机生成器源码详解:图层合成、权重与DNA机制

简介:面向NFT开发者与Java图像处理学习者,这套基于Java的NFT艺术品随机生成器源码,通过随机组合背景、身体、饰品、头部、头发、眼镜等元素,自动生成独一无二的动物主题NFT图片,可清晰理解随机算法与图像合成流程。压缩包共55个文件,含36张PNG素材、9个Java源文件及配置、说明文档等,约22.5MB。PNG提供各图层元素,Java实现加载与绘制,配合说明文档可快速上手。已有4314人学习下载。源码中可直接学习BufferedImage与Graphics2D实战用法,并作为模板扩展新元素或随机策略,适合数字艺术方向的开发实践。

1. 随机生成器源码:PFP 类 NFT 项目绕不开的生产工具

一个朋友花三周画了 40 张部件素材,想凑成 6000 张头像去发 NFT 项目,结果手工拖图拼了三天才拼出 200 张,还全是重复组合。NFT 艺术品随机生成器源码要解决的就是这件事:把图层叠加、随机组合、权重稀有度、元数据导出串成一条流水线,一条命令产出整套集合。适合做 PFP 头像类项目的小团队,也适合手里有一套分层素材、想批量生成藏品的独立作者。整条链路并不复杂,核心就是「图层规则 + 固定随机种子 + 批量合成 + 元数据上传」,但每一步都有几个让新手反复翻车的细节。

2. 先定规则再做图:图层、权重与 DNA 种子机制

生成艺术的核心不是「怎么画」,而是「怎么组合」。拿到素材先别急着写脚本,把图层依赖、画布尺寸、权重目标这三件事定清楚,后面所有代码都是在兑现这几条规则。

2.1 图层依赖与绘制顺序:谁先画谁决定画面最终效果

Pillow 合成时,后画的层会盖住先画的层,所以图层顺序直接决定最终观感。常见的 PFP 绘制顺序是背景 → 底板 → 身体 → 衣服 → 眼睛 → 嘴巴 → 头饰 → 特效,这个顺序必须严格遵守,比如帽子只能盖在头发上面,不能把眼睛也压住。我一般会把图层顺序写成一个显式的layers列表,而不是用一个字典靠遍历顺序去猜,因为字典顺序在代码维护时很容易被误改,一旦倒过来,头发就会盖住整张脸。

配置文件的基准结构长这样:

{ "size": [1024, 1024], "layers": ["background", "body", "eyes", "mouth", "hat"], "traits": { "background": { "items": [ {"name": "blue", "weight": 50}, {"name": "red", "weight": 30}, {"name": "gold", "weight": 20} ] }, "body": { "items": [ {"name": "skeleton", "weight": 50}, {"name": "robot", "weight": 30}, {"name": "ghost", "weight": 20} ] }, "eyes": { "items": [ {"name": "normal", "weight": 60}, {"name": "laser", "weight": 25}, {"name": "x", "weight": 15} ] }, "mouth": { "items": [ {"name": "smile", "weight": 60}, {"name": "grin", "weight": 40} ] }, "hat": { "items": [ {"name": "none", "weight": 40}, {"name": "cap", "weight": 30}, {"name": "crown", "weight": 15}, {"name": "halo", "weight": 15} ] } }, "output": { "count": 5000, "seed_range": [1, 1000000] } }

这里的size是画布尺寸,所有图层素材都必须按这个尺寸设计,不然后期合成会错位。layers是合成顺序,traits里每个分类下是素材名和权重。素材命名建议统一用英文小写加下划线,别用中文和空格,否则跨平台跑脚本时会出现各种诡异路径错误。

2.2 DNA 种子机制:让每一张图都可复现

随机生成器和「真随机」相反,它要的是「可复现的随机」。同一个种子必须永远生成同一张图,否则发布后用户发现自己买到的图和别人买到的不一样,这个项目就彻底失去信任了。常见做法是引入 DNA 概念:给每条 token 分配一个固定整数种子,再用random.Random(seed)创建独立 RNG(随机数生成器),所有图层选择都从这一个 RNG 里取,不碰全局随机状态。

import random import json def load_config(path: str) -> dict: with open(path, encoding="utf-8") as f: return json.load(f) def draw_dna(config: dict, seed: int) -> dict: """ 给定一个整数种子,返回该 NFT 的 DNA。 DNA 的每一项就是某个 trait 下选中的素材名。 """ rng = random.Random(seed) # 独立的 RNG,不受全局 random 状态影响 dna = {} for trait_name in config["layers"]: trait = config["traits"][trait_name] expanded = [] for item in trait["items"]: weight = item.get("weight", 1) expanded.extend([item["name"]] * weight) dna[trait_name] = rng.choice(expanded) return dna if __name__ == "__main__": cfg = load_config("traits_config.json") seed = 20241207 print(draw_dna(cfg, seed))

按权重展开列表再choice,是最直观的加权随机写法,素材量小时没有任何问题。但如果某个 trait 有几百个素材,展开会让列表膨胀,这时可以改成按权重累计值做二分查找。关键点是random.Random(seed)每次传入同一个seed,结果永远一样;一旦你用了全局random.choice,脚本多跑一次结果就变了。

每个 token 的seed建议单独存一份清单,后续按token_id反查 DNA 做校验时要用。

2.3 权重配置:手工配平与稀有度目标

权重直接决定整个集合的稀有度分布。比如hat里给none权重 40,就是希望约四成的头没有装饰;给crown权重 15,约 14% 会出现皇冠。手工配平的问题是:单个 trait 看着对,组合起来概率会被稀释。一个组合的概率是各 trait 概率的乘积,某个具体组合(比如「金色背景 + 幽灵身体 + 激光眼 + 皇冠」)出现的理论概率可能只有千分之几。

所以配权重前先明确稀有度目标:我期望的 1% 稀有款是哪几个组合?想清楚后再反推权重。同时算一下理论组合数,公式是每个 trait 的权重和相乘。如果组合数小于输出数,必然产生大量重复,这时候要么降低输出数量,要么把权重压得更平。配置里的seed_range也要远大于count,不然日志里的 seed 看起来就像连着编号,调试时很难定位。

3. 用 Pillow 批量合成:从 DNA 到图片和元数据

规则定好后,核心代码就是合成流水线。这一段要处理三个问题:单张图片怎么叠出来、批量生成时怎么去重、每张图的元数据 JSON 怎么生成。

3.1 合成单张图:RGBA 画布与 alpha_composite

NFT 头像素材普遍带透明通道,所以合成统一用 RGBA 模式,等所有层叠完再转成 RGB 存 PNG。Pillow 的Image.new创建透明画布,然后按config["layers"]的顺序依次叠加,用Image.alpha_composite而不是paste,因为paste会直接覆盖像素,无法正确处理半透明边缘。

from PIL import Image def compose_image(config: dict, dna: dict, output_path: str) -> None: size = config["size"] canvas = Image.new("RGBA", (size[0], size[1]), (0, 0, 0, 0)) for layer_name in config["layers"]: asset_name = dna[layer_name] asset_path = f"assets/{layer_name}/{asset_name}.png" layer_img = Image.open(asset_path).convert("RGBA") # alpha_composite 要求两张图尺寸一致,素材必须按画布大小设计 canvas = Image.alpha_composite(canvas, layer_img) canvas.convert("RGB").save(output_path, "PNG")

convert("RGBA")是必须的,因为部分素材可能是 P 模式调色板 PNG,直接合成会报模式不匹配。alpha_composite对尺寸敏感,图层素材如果比画布小,合成时会直接报错或错位。素材统一按 1024×1024 设计是最省心的做法;如果确实有零散尺寸的素材,需要单独维护一张坐标表,每次合成时按坐标 pasted,这里先不展开。

3.2 批量合成与去重:DNA 哈希过滤相同组合

批量生成的主循环里,最容易被忽略的是「去重」。按权重随机组合,重复是大概率事件。同一个 DNA 对应完全相同的图,市场上出现两张一模一样的头像会直接拉低项目信誉。去重逻辑很简单:把 DNA 转成字符串作为集合键,遇到重复就跳过并换个 seed。

import json import os from PIL import Image cfg = load_config("traits_config.json") os.makedirs("output/images", exist_ok=True) seen = set() token_id = 0 max_retry = 20 # 连续撞车这么多次就停,防止死循环 for seed in range(cfg["output"]["seed_range"][0], cfg["output"]["seed_range"][0] + cfg["output"]["count"] * 10): dna = draw_dna(cfg, seed) dna_key = json.dumps(dna, sort_keys=True) # 按 key 排序后转字符串 if dna_key in seen: retry += 1 if retry > max_retry: break continue seen.add(dna_key) retry = 0 image_path = f"output/images/{token_id:04d}.png" compose_image(cfg, dna, image_path) with open(f"output/dnas/{token_id:04d}.json", "w", encoding="utf-8") as f: json.dump({"seed": seed, "dna": dna}, f, ensure_ascii=False, indent=2) token_id += 1

token_id和seed是两套编号系统:seed是随机过程的输入,token_id是最终作品的编号。去重命中时token_id不能跟着 seed 走,否则图片文件编号会出现空洞或错位。DNA 快照文件建议单独存一份,它是后续校验和补图的后悔药。

3.3 元数据 JSON:给每一张图配上链下资料

图片只是资产的一半,另一半是元数据。主流 NFT 市场的元数据标准包含name、description、image和attributes几个字段,其中attributes必须是数组,每个元素至少包含trait_type和value两个键。这个 JSON 最终要上传到分布式存储,并在合约的tokenURI里被引用。

def make_metadata(dna: dict, token_id: int, image_cid: str) -> dict: attributes = [] for trait_name, value in dna.items(): attributes.append({ "trait_type": trait_name, "value": value }) return { "name": f"Random Art #{token_id:04d}", "description": "A generative art NFT rendered from layered assets.", "image": f"ipfs://{image_cid}/{token_id:04d}.png", "attributes": attributes }

attributes的排序建议固定成layers的顺序,不要用字典默认顺序,否则同一个 DNA 在不同脚本版本里生成的 JSON 会乱序。image字段在第一次生成时通常先留占位 CID,等图片目录传到 IPFS 拿到真实 CID 后再批量回填。注意ipfs://<CID>/<文件名>这种写法是标准格式,不要画蛇添足写成ipfs://ipfs/<CID>,很多解析器不认。

4. 这里最容易翻车:随机生成器的 5 个高频踩坑记录

这套流水线写起来不难,但跑起来问题不少。下面几条都是实际项目中反复出现的状况,按「现象 → 原因 → 解决」的顺序整理出来。

4.1 重跑脚本得到完全不同的图片

现象:同一个脚本、同一个seed,两次运行生成的图片不一样,甚至每次都不一样。

原因:大概率是脚本里混用了全局随机函数,比如random.choice或random.randint,这些函数会读取系统熵作为初始状态;或者是draw_dna内部用了random.choice但外层没有固定random.seed()。更隐蔽的情况是字典遍历顺序在不同 Python 版本间不稳定,导致dna的生成顺序变化,进而影响随机序列的消费顺序。

解决:draw_dna里严格只使用random.Random(seed)创建的独立 rng,同时把layers列表作为所有循环的遍历依据,不依赖traits字典自身的顺序。跑全量前先拿 5 个 seed 试跑,生成 DNA 快照,再重跑同一批 seed 做文件哈希对比,确认逐字节一致再放开。

4.2 图层顺序错误导致脸部被头发盖住

现象:合成图里背景看不见了,或者眼睛图层被帽子压住,整个头像只剩半张脸。

原因:layers配置顺序写反,或者代码里用for trait_name in dna.keys()遍历,而 DNA 字典顺序恰好和期望的绘制顺序不一致。还有一个小坑是某些素材文件命名带数字前缀(比如01_face.png),排序时被排到最后,间接改变了合成顺序。

解决:合成循环只认config["layers"],不认dna的键顺序。图层配置改成显式列表后,再给合成函数加一行调试输出,打印当前正在合成的层名,跑测试时肉眼确认顺序。

4.3 图片和元数据文件错位

现象:0001.png对应的0001.json里记录的 attributes 是另一张图的。

原因:去重逻辑里token_id没有和实际写入的文件名同步。比如seed冲突被跳过时,token_id依然自增,或者反之,token_id没自增导致后面的文件覆盖前面的。

解决:统一写法是只有调用compose_image写文件时,token_id才自增,同时json文件名必须直接取材自图片文件名。生成完后做一次完整性校验:遍历output/images下的每个 PNG,按文件名读对应 JSON,确认 JSON 内容里的 attributes 与重新用该文件对应 seed 跑出来的 DNA 一致。

4.4 权重看似合理但稀有度分布失控

现象:crown目标权重是 2%,实际生成 5000 张后统计出来占了 5%,偏差大得离谱。

原因:样本量小的波动是常见原因,5000 张里 2% 的期望数是 100,实际落在 80~120 之间都正常;但如果偏差超过 50%,就要怀疑权重展开逻辑有问题。另一个常见错误是权重列表展开时把「百分比」直接当「权重」用,比如 50%、30%、20% 被当成三个整数,展开后实际概率变成了 50/100、30/100、20/100,这个逻辑本身不错,但一旦权重总和不是 100,概率就悄悄变了。

解决:生成结束后立即做稀有度报告,按 trait 统计每个素材的实际出现次数,和理论权重对比,偏离超过 20% 就告警。权重配平用目标概率反推整数权重,并且保证每个 trait 的权重和一致,这样各 trait 之间的相对稀有度才可比。

4.5 素材命名不规整导致合成中断

现象:脚本跑到一半报FileNotFoundError,或者合成图出现大片空白,排查半天发现是素材目录里多个 PNG 命名是中文,或者混进了 JPG。

原因:素材太多时,人工整理总会出漏。Pillow 打开文件时对路径空格和中文尚能容忍,但跨平台部署后(Windows 开发、Linux 服务器跑批)路径分隔符和编码问题就会冒出来。更隐蔽的是 JPG 没有透明通道,convert("RGBA") 后虽然能合成,但边缘会出现难看的黑底。

解决:写一个素材预检脚本,启动时扫描assets目录,检查每个layers下是否有缺失的素材名、文件是否全是 PNG、尺寸是否和画布一致、有没有 P 模式或 RGBA 之外的格式。这些检查在生成前做一次,成本极低,能省掉后面一两个小时排查。

5. 从生成到上链:元数据上传与合约 Mint 的最小闭环

图片和 JSON 都在本地后,下一步是让它们能被 NFT 市场访问到。这一步的链路是:图片传分布式存储 → 拿到 CID 回填 JSON → JSON 再传一次 → 把 JSON 目录的 CID 写进合约的baseURI。

5.1 组织目录结构并上传:CID 回填顺序不能乱

先看目录结构,规范如下:

output/ ├── images/ │ ├── 0001.png │ ├── 0002.png │ └── ... ├── metadata/ │ ├── 0001.json │ ├── 0002.json │ └── ... └── dnas/ ├── 0001.json └── ...

metadata和images分开传,但关键是顺序。我一般这样操作:ipfs add -r output/images拿到图片目录的 CID,然后用这个 CID 批量重写metadata里的image字段,最后再把metadata目录传到 IPFS。注意不要在第一次生成 JSON 时就写死 CID,因为图片目录一旦改动,CID 就会变,重写一次 JSON 反而更省事。

CID 由文件内容决定,同一段内容重复上传会得到同一个 CID,所以本地节点测试时反复试验没有副作用。上线的最后一版 CID 要记录到项目文档里,很多项目翻车是因为合约部署后才发现 JSON 里 image 字段指向旧 CID,用户打开藏品图裂了。

5.2 ERC-721 合约里的 baseURI 与 tokenURI

合约侧只需要存一个baseURI,tokenURI再拼接tokenId和.json后缀。这样每次 mint 不需要额外传元数据地址,gas 也省。常见写法是标准 ERC721 里的覆盖函数:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; contract RandomArtNFT { string public baseURI; function tokenURI(uint256 tokenId) public view virtual returns (string memory) { require(_exists(tokenId), "ERC721: token does not exist"); return string(abi.encodePacked(baseURI, Strings.toString(tokenId), ".json")); } function setBaseURI(string memory newBaseURI) external onlyOwner { baseURI = newBaseURI; } }

setBaseURI是部署后才能调用的管理接口,调用参数形如ipfs://<metadata_CID>/。注意最后的斜杠不能丢,因为tokenURI拼接出来后是ipfs://<metadata_CID>/0001.json,少斜杠就会拼接成0001.json前面直接连着 CID,路径错误。

5.3 mint 时要不要链上随机:三种做法的取舍

常见做法是链下生成、链上按序 mint:img 和 metadata 已经全部生成上传,用户 mint 时按照铸造顺序获得下一张图。这是最简单也最可靠的方式,因为随机性已经在链下实现,链上无需任何随机逻辑,gas 和风险都最低。

第二种是「mint 时先掩盖,之后 reveal」:合约先返回占位 URI,等全量 mint 完成后再调用 reveal 接口切换baseURI。这种方式能制造开盒的悬念,但工程复杂度高,而且要防止有人通过扫描链上数据提前反推稀有度。

第三种是真正在 mint 时生成随机数,需要引入预言机服务或 commit-reveal 机制,成本和失败点都多,对艺术品项目基本没必要。我见过不少小项目直接写在合约里的伪随机keccak256(abi.encodePacked(block.timestamp, msg.sender)),这种写法虽然省事但理论上可以被操纵,一旦被盯上,整个项目信誉就没了。除非做链上生成艺术有强需求,否则老老实实链下生成。

6. 验证批量结果:稀有度报告与全量复现校验

发布前最后一步是验证。我习惯跑两件事:一次稀有度分布检查,一次全量复现对比。

稀有度检查就是拿dnas目录下的所有 DNA 文件,按 trait 统计实际出现次数,和理论权重做对比。复现校验则是把每个token_id对应的seed重新跑一遍draw_dna + compose_image,然后计算新旧两张图的 MD5,有任何一个不一致就说明生成逻辑不可复现。

import hashlib import os from PIL import Image def file_md5(path: str) -> str: h = hashlib.md5() with open(path, "rb") as f: for block in iter(lambda: f.read(4096), b""): h.update(block) return h.hexdigest() for token_id in range(5000): dna_path = f"output/dnas/{token_id:04d}.json" with open(dna_path, encoding="utf-8") as f: record = json.load(f) new_image_path = f"/tmp/recheck/{token_id:04d}.png" compose_image(cfg, record["dna"], new_image_path) old_md5 = file_md5(f"output/images/{token_id:04d}.png") new_md5 = file_md5(new_image_path) if old_md5 != new_md5: print(f"mismatch: {token_id}")

这套校验跑一遍也就几分钟,能同时暴露「生成逻辑被改过」和「图片文件被外部替换」两类问题。我现在的习惯是每次全量生成后先跑稀有度报告,再随机抽 20 张对照 DNA 肉眼检查图层顺序,确认没有素材错位、透明底发黑、属性张冠李戴,然后才上传 IPFS。这些检查听着繁琐,但真到发布后才发现某张图的背景被头发盖住,那才叫社死。把这套校验留在脚本里,每次换素材、调权重后顺手跑一遍,能省掉后面大量售后解释。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进