← 返回全部文章

ArtStation:一周内从空仓库到经过测试的多 GPU 纹理流水线

发布于:
toolinggpupythonevidence

QuickTile Studio 能把一个材质变成无缝平铺的纹理。但要卖纹理,需要的是材质本身:数量 要多,风格要统一。2 月的最后一周,我搭建了 ArtStation,一条私有流水线,在我自己的 GPU 上生成可平铺纹理,目标是做 Unity Asset Store 的纹理包。

仓库创建于 2 月 22 日。到 2 月 28 日结束时:

提交次数38 次,含初始提交
版本2 月 27 日 v0.2.0,同日 v0.2.2
提示词定义1,188 个 YAML 文件:9 种风格,每种 132 个纹理类型
可用的步骤基础生成、img2img 精修、图像描述、LoRA 训练
仍是占位组合式最终生成;Unity 材质准备尚未开始
仓库中的测试用例502 个 pytest、204 个 vitest、38 个 Playwright
GPU 调度每个 Stable Diffusion 服务实例一个 worker;主机上有两块 GPU

测试数字是已提交测试套件中测试函数的数量,不是运行结果。另外,本文讲的是流水线本身, 而不是它的产出:生成的图像是刻意不提交到仓库的,所以仓库里没有记录它到底生成了多少张, 我也不打算估一个数。

大部分代码由 AI 编程代理在我的指导下编写(38 次提交中有 17 次带有代理的共同作者标记)。 架构由我定,需求由我写,合入的内容由我审查。

结构:用文件夹作为契约

在仓库建立之前,这套流程已经手工验证过。README 记录了在 Stylized 风格下用一个训练好的 风格 LoRA 验证过的 12 个提示词定义。ArtStation 把这个循环自动化了。每一步读取上一步的 文件夹,写出自己的文件夹:

Prompts/<Style>/<type>.yaml
  -> Step 1  txt2img, tiling=true         Generated/Step1-Base/<Style>/<Type>/
  -> Step 2  img2img, denoise ~0.45       Generated/Step2-Denoised/<Style>/<Type>/
  -> Annotate  vision-model captions      .txt beside each training image
  -> Step 3  LoRA training (kohya_ss)     trained .safetensors
  -> Step 4  LoRA-enhanced final set      (stub at month end)
  -> Step 5  PBR maps, Unity materials    (planned)

生成通过本地 SDNext 服务器的 REST API 完成。图像描述通过 Ollama 调用 Moondream 视觉 模型。训练则直接调用 kohya_ss 子进程,因为 SDNext 不通过 API 提供训练功能。

用文件夹作为接口,意味着每一步都可以单独重跑、检查或替换;后来加的 Web UI 也不需要新的 契约:它浏览的就是同一棵目录树。

每张图都带着自己的请求

最初的版本在每张图旁边写一个包含提示词的 .txt。2 月 23 日这一点改了:第 1、2 步现在 在每张图旁边写一个 .meta.json,包含三部分:

  • request:发送给 SDNext 的完整 API 请求体
  • response:解析后的生成信息(种子、采样器、模型)
  • meta:风格、纹理类型、运行 ID、时间戳

有两个细节很关键。img2img 的输入图像会从保存的请求体中剔除,因为一份 base64 副本会让 每个附属文件都变成好几 MB。另外,.txt 文件从此只保留给一个用途,即 LoRA 训练的图像 描述,因为 kohya_ss 读取的是与图像同名的 .txt 文件。同一个扩展名有两种含义,就会把 提示词当作描述喂进训练。

描述格式是固定的,训练可以依赖它:

<trigger_word>, <texture type>, top-down terrain, <Moondream description>

前三个 token 是固定的,与训练配置中的 keep_tokens=3 对应。

配置是一条解析链

每个值经过四层解析,后面的覆盖前面的:

pipeline defaults -> style config.yaml -> preset -> CLI flags

预设是有名字的覆盖集合(fast-iteration、high-quality、no-tile-explore,以及一个 模型测试预设)。每个步骤命令都接受 --preset 和 --override。

在此之上,pipeline.yaml 有一个按主机名区分的分机器配置段,在加载时深度合并。同一份 检出代码可以在两台磁盘布局和 GPU 数量都不同的机器上运行,不需要分支,也不需要本地改动。

三个模型服务共享 GPU

三个模型服务争用同一块显存:SDNext 里的 SDXL、Ollama 里的 Moondream,以及训练时的 kohya_ss。做出的决定:

  • 每次描述请求后卸载。 Ollama 服务的 keep-alive 配置为 0,所以描述模型不会常驻在 SDXL 旁边。
  • 卸载检查点:先加上,后撤回。 2 月 23 日,第 1、2 步开始在完成后卸载 SDXL 检查点。 五天后,这些调用被移除,同一次提交让 set_model 在所请求的检查点已加载时跳过重新加载。 我对这个取舍的理解是:每块 GPU 都有专属的 SDNext 实例时,常驻的检查点就是缓存,而每个 任务都重新加载才是成本。哪种做法正确取决于 GPU 是否共享;按 GPU 划分实例的配置在同一天 大约一小时后合入。
  • 每块 GPU 一个 worker。 任务队列为每个 SDNext 实例启动一个异步 worker。每个 worker 在一个大小等于实例数的线程池上执行阻塞的 HTTP 调用,所以长时间的生成不会阻塞 FastAPI 事件循环。取消任务时,中断请求只发给正在运行该任务的实例,而不是所有实例。
  • 可观测性。 nvidia-smi 快照(显存、利用率、温度)会在流水线的检查点写入日志, 并在仪表盘上按 GPU 显示。

能说明自身类别的错误

第二天,流水线有了结构化日志和一套错误层级:

class PipelineError(Exception): ...
class NetworkError(PipelineError): ...
class ConfigError(PipelineError): ...
class FileSystemError(PipelineError): ...
class TrainingError(PipelineError): ...
class OllamaError(PipelineError): ...

SDNext 客户端中的每个 raise_for_status() 都被包装成 NetworkError。每次运行生成一个 运行 ID,并注入到跨步骤的每一行日志中;每个服务(流水线、SDNext、kohya_ss)都有自己的 滚动日志文件。当一次批量运行在几小时后失败时,第一个问题是:这是哪一类失败,属于哪一次 运行。现在这两者都只是一个日志过滤条件。

以服务运行,而不是靠终端

一条只在终端开着时才运行的流水线算不上基础设施。2 月 27 日,各个组件变成了通过 NSSM 安装的 Windows 服务,安装程序会:

  • 把服务参数与可执行文件路径分开传递,因此带空格的安装路径也能用
  • 在 hosts 文件末尾没有换行时,追加前先做防护
  • 某个服务安装失败时继续处理其余服务,打印每个服务的成功和失败汇总,只要有失败就以 非零状态退出
  • 自动检测 Python 可执行文件,而不是写死路径

那次提交加入了 29 个安装程序的单元测试。版本号收敛到 Python 包中的单一来源;Web 前端的 版本文件由它生成,并且每次服务重启都会重新构建前端,所以 UI 不可能显示过期的版本号。

从市场中得出分类体系

一个纹理包首先是一套分类体系,然后才是一组图像。为了把我的分类和已经在卖的产品对照,我 加了一个 Playwright 爬虫,从 Asset Store 包页面提取内容树。这些页面使用虚拟化列表,所以 它会展开每个文件夹节点并滚动容器强制渲染,然后再读取目录树。它抓取了三个大型竞品纹理 合集的内容树用于对比,同一次提交还加入了 Fabric and Textile(织物与纺织品)类别。随后, Stylized 的 132 个纹理类型被复制到另外八个风格文件夹中,作为它们的起点。

先有测试,再有债务清单

2 月 28 日,项目有了覆盖全部三层的测试套件:流水线和 API 用 pytest,Vue 前端用 vitest, 端到端测试用 Playwright,并通过 page.route() 模拟 API。一个统一的运行脚本执行这三类 测试并生成 HTML 报告。

另一个值得保留的习惯是一份精确列出技术债务的 known-issues.md。其中一条:代码读取的是 combination 键,而所有 YAML 文件用的都是 combinations,所以这个设置一直悄无声息地为 空,落回默认值。现在加载器两种都接受。其他条目指出 README 中的结构描述的是目标设计而不是 真实文件(现在已标注为目标方案),以及一些没人使用的目录常量。把它们写下来,意味着之后 接手代码的代理不会去信任错误的来源。

可推广的经验

让每个输出都带着它的输入。 一个记录了确切请求的附属文件,能把任意一张图变成可复现 的实验,代价只是多写一个文件。

每种文件类型只赋予一种含义。 提示词和训练描述都想用 .txt。在训练开始前把它们分开, 避免了一个不声不响的数据错误。

显存策略取决于拓扑。 当多个模型共享一块 GPU 时,每步之后卸载是对的;当每块 GPU 都有 专属服务时,它就是错的。我在按 GPU 划分的拓扑合入的同一天撤回了最初的选择。

用和代码一样的精度记录技术债务。 一份写清楚哪里错了、错在什么地方、为什么错的已知 问题文件,才能让下一次改动,无论来自人还是代理,从事实出发。