图库从 5 张扩到 50 张以后,真正危险的已经不是"少放了一张图片",而是四份数据悄悄失去对应关系:卡片封面是 A,标题属于 B,编号矩阵来自 C,调色板统计又是 D。
拼豆制图当前有 50 张运行时图库 PNG,其中 43 张来自批量图板裁切与量化,另外 7 张保留既有命名和矩阵。这个"43 + 7"的结构决定了生成脚本不能粗暴清空再重建:它既要批量生产,也要保护历史资产;既要输出好看的封面,还要同步生成 70×70 编号矩阵和 16×16 轻量预览。
本文不讨论如何手工多放几张图,而是搭建一条可重复执行、可审计、可回滚的素材生产线。

本文会解决这些具体问题:
- 如何划分原始图板、裁切产物、运行资源和矩阵代码的生命周期。
- 如何用一个清单统一 ID、标题、分类、裁切坐标和资源名。
- 如何把图板稳定裁成单图,再量化为 16 色、70×70 矩阵。
- 为什么矩阵使用
0-9/A-F和.编码,而不是 4900 个对象。 - 如何让 Repository 组装业务对象,而不是让页面理解素材管线。
- 如何验证 50 个 ID 在 PNG、矩阵、标题和详情页之间一一对应。


先建立 50 张图纸的闭环清单
图库条目至少有六个需要同步的身份:
| 字段 | 示例 | 由谁使用 |
|---|---|---|
patternId |
anime-7 |
Repository、详情、收藏 |
| 标题 | 海盐人鱼姬 |
搜索、卡片、详情 |
| 分类 | anime |
筛选与主题 |
| 源图板位置 | 第 3 列第 2 行 | 裁切脚本 |
| 资源名 | gallery_anime_7 |
ArkUI $r 引用 |
| 矩阵块 | PatternAssetCharts.get('anime-7') |
编号图纸 |
只靠文件名约定,无法表达"某些 ID 使用旧资源名""某个图板是 2×5 而不是 3×3"。更稳的做法是建立显式清单:
python
from dataclasses import dataclass
@dataclass(frozen=True)
class GalleryAssetSpec:
pattern_id: str
category: str
title: str
sheet_key: str
sheet_columns: int
sheet_rows: int
panel_column: int
panel_row: int
media_name: str
preserve_existing: bool = False
清单是生产管线的单一来源。脚本从它生成裁切图、媒体文件、矩阵代码和审计报告;Repository 的 50 个种子也应能与清单对齐。这样新增第 51 张图时,不需要同时手改四个文件。
43 张批量素材与 7 张既有素材要走不同分支
项目脚本把七个历史 ID 放进 EXISTING_IDS,其余 43 个 ID 由 PANEL_MAP 指定图板位置:
python
EXISTING_IDS = {
'anime-1',
'game-2',
'idol-1',
'idol-2',
'designer-1',
'designer-2',
'designer-3',
}
def pattern_order() -> list[str]:
result: list[str] = []
for category in ['anime', 'game', 'idol', 'scene', 'designer']:
for index in range(1, 11):
result.append(f'{category}-{index}')
return result
"保留既有"不等于跳过验证。七张旧资产仍要检查 PNG 是否存在、矩阵是否为 70 行、调色板 token 是否有效。它们只是跳过裁切和量化,不能跳过最终闭环检查。
生成器还要维持稳定顺序。anime-1 到 designer-10 的输出顺序固定后,PatternAssetCharts.ets 的差异才可读;否则同一批数据每次重跑都可能产生大段无意义改动。
目录必须表达"生产时"与"运行时"
项目把素材分成三个主要目录:
text
doc/design/assets/gallery/image2-batch/ 原始裁切源
doc/design/assets/gallery/generated/ 可审阅的单图产物
entry/src/main/resources/base/media/ 进入应用包的 PNG
entry/src/main/ets/services/PatternAssetCharts.ets
运行时矩阵
它们不能互相替代:
image2-batch保留可追溯源,不直接进入应用包。generated用于人工查看单张裁切和颜色效果。resources/base/media只放最终运行资源,名称必须符合资源引用规则。PatternAssetCharts.ets是压缩后的业务图纸数据,不保存大图。
源图、调试 contact sheet、脚本日志如果混入 resources,包体积会增长;反过来只保留运行图,不保留源与裁切参数,下次替换素材就无法复现。
图板裁切必须由网格坐标决定
批量图板有 3×3、2×4、2×5 等不同布局。裁切函数根据列数、行数和格子坐标计算边界,再做居中方形裁切:
python
def crop_panel(
sheet: Image.Image,
columns: int,
rows: int,
column: int,
row: int
) -> Image.Image:
width, height = sheet.size
left = int(column * width / columns)
right = int((column + 1) * width / columns)
top = int(row * height / rows)
bottom = int((row + 1) * height / rows)
panel = sheet.crop((left, top, right, bottom))
side = min(panel.size)
x = (panel.width - side) // 2
y = (panel.height - side) // 2
return panel.crop((x, y, x + side, y + side))
不要在脚本里为每一张图写像素坐标。图板分辨率变化后,绝对坐标会全部失效;基于网格比例的裁切仍然能工作。
裁切后可以去掉约 2%---3% 的格子间距,但这个比例必须统一。裁得太多会破坏人物耳朵、边框或场景边缘,裁得太少则会把相邻面板带进封面。
70×70 采样要避开格子边界
源图板本身可能是拼豆风格,每个大格子包含边框、高光和中心颜色。直接取格子左上角,会大量采到网格线。
项目脚本先把单图缩放到 70 × 10 像素,再从每个 10×10 单元中心采样一个 4×4 区域:
python
SIZE = 70
def bead_samples(panel: Image.Image) -> list[list[tuple[int, int, int]]]:
scaled = panel.resize(
(SIZE * 10, SIZE * 10),
Image.Resampling.LANCZOS
).convert('RGB')
result = []
for y in range(SIZE):
row = []
for x in range(SIZE):
left = x * 10 + 3
top = y * 10 + 3
pixels = [
scaled.getpixel((xx, yy))
for yy in range(top, top + 4)
for xx in range(left, left + 4)
]
row.append(clean_sample(pixels))
result.append(row)
return result
中心采样不是追求摄影级还原,而是避免边框颜色污染拼豆主体。clean_sample() 还可以过滤过暗的网格像素,再取各通道中位数,降低单个高光点的影响。
调色板量化与空格判断必须分开
每张图最多使用 16 种 token。量化前先识别近白、低饱和背景为空格,剩余像素再生成调色板:
python
TOKENS = '0123456789ABCDEF'
def is_empty(rgb: tuple[int, int, int]) -> bool:
brightness = sum(rgb) / 3
return brightness > 242 and saturation(rgb) < 32
def encode_pixel(
rgb: tuple[int, int, int],
palette: list[tuple[int, int, int]]
) -> str:
if is_empty(rgb):
return '.'
index = min(
range(len(palette)),
key=lambda i: distance(rgb, palette[i])
)
return TOKENS[index]
. 是业务空格,不等于白色拼豆。白色拼豆必须占用某个真实 token,并进入颜色统计;否则施工图会少算材料。
空格阈值要用多张浅色素材验证。阈值过低,背景会产生大量"白豆";阈值过高,奶油白衣服和云朵又可能被删除。
字符矩阵比 4900 个对象更适合源码
一张 70×70 图纸有 4900 个格子。如果每个格子都写成带 row、col、hex 的对象,50 张图会产生 24.5 万个对象字面量,难以审查也增加源码体积。
PatternAssetCharts 使用颜色表和字符串行:
typescript
export interface AssetChart {
colors: string[];
chartRows: string[];
previewRows: string[];
}
export class PatternAssetCharts {
static get(id: string): AssetChart | null {
if (id === 'anime-1') {
return {
colors: ['#4A2559', '#5B3470', '#744483'],
chartRows: [
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
'B.F..........F.........F...FF..................F.......F............BB'
],
previewRows: [
'F...............',
'F.E...F...F..9..'
]
};
}
return null;
}
}
真实矩阵必须有 70 行、每行 70 个字符;预览矩阵为 16×16。字符串格式让行宽、边框和空白区域一眼可见,也方便生成脚本稳定输出。
运行时解码器要拒绝非法 token
当前编码约定为 .、0-9、A-F:
typescript
private static assetColorIndex(token: string): number {
if (token === '.') {
return -1;
}
const digit = '0123456789'.indexOf(token);
if (digit >= 0) {
return digit;
}
const letter = 'ABCDEF'.indexOf(token);
if (letter >= 0) {
return 10 + letter;
}
return -2;
}
建议把"空格"和"非法 token"返回不同值。若两者都返回 -1,脚本误写 G 时,页面会把坏数据静默渲染成空格,问题直到用户对照封面才被发现。
解码前还要验证索引小于 colors.length。矩阵里出现 F,颜色表却只有 12 色,也应该被视为资源错误,而不是回退到白色。
Repository 负责把素材变成 Pattern
页面只消费 Pattern,不应该直接读取 AssetChart。Repository 把标题种子、矩阵颜色和业务统计组装在一起:
typescript
private static createPattern(
seed: PatternSeed,
includeFullChart: boolean
): Pattern {
const asset = PatternAssetCharts.get(seed.id);
const palette = asset === null
? seed.palette
: PatternRepository.assetPalette(seed.id, asset.colors);
const previewCells = asset === null
? PatternRepository.createCells(
`${seed.id}-preview`, palette, seed.variant, 16)
: PatternRepository.createCellsFromRows(
`${seed.id}-preview`, palette, asset.previewRows);
const chartCells = asset === null
? PatternRepository.createCells(
seed.id, palette, seed.variant, 32)
: includeFullChart
? PatternRepository.createCellsFromRows(
seed.id, palette, asset.chartRows)
: previewCells;
const colorStats = PatternRepository.countColors(palette, chartCells);
return {
id: seed.id,
title: seed.title,
category: seed.category,
categoryName: seed.categoryName,
width: asset === null ? 32 : asset.chartRows[0].length,
height: asset === null ? 32 : asset.chartRows.length,
difficulty: '简单',
beadCount: PatternRepository.countBeads(chartCells),
colorCount: palette.length,
likes: seed.likes,
previewCells,
chartCells,
colorStats
};
}
素材管线在构建前完成,运行时只做轻量字符解码和统计。用户相册转图则走 ImageConvertService,不要把离线批量脚本搬到应用启动路径。
资源名要稳定,但不能只靠 if 链
七张历史图片使用特殊资源名,其余条目遵循 gallery_<category>_<index>。脚本必须把例外写进映射:
python
MEDIA_NAMES = {
'anime-1': 'gallery_anime_magic',
'game-2': 'gallery_game_mecha',
'idol-1': 'gallery_idol_pink',
'designer-1': 'gallery_designer_bunny',
}
def media_name(pattern_id: str) -> str:
if pattern_id in MEDIA_NAMES:
return MEDIA_NAMES[pattern_id]
category, index = pattern_id.split('-')
return f'gallery_{category}_{index}'
ArkUI 最终通过 $r('app.media.xxx') 使用资源。若页面用几十段 if 映射 ID,新增素材时容易只更新脚本却漏掉页面。更好的终点是让资源标识进入种子配置,页面只接收已经解析好的 Resource。
可复跑的脚本必须保证幂等
同一份源图和清单连续运行两次,输出应该保持一致。要做到这一点,需要控制:
- ID 与输出顺序固定。
- 调色板排序规则固定。
- 不使用当前时间生成文件名。
- PNG 尺寸、裁切和压缩参数固定。
- 保留的 7 个矩阵块按原顺序写回。
- 写入前比较内容,无变化时不改文件。
可以给产物计算摘要,快速发现意外漂移:
python
from hashlib import sha256
def file_digest(path: Path) -> str:
return sha256(path.read_bytes()).hexdigest()
before = file_digest(ASSET_CHARTS)
run_pipeline()
after = file_digest(ASSET_CHARTS)
print({'changed': before != after, 'sha256': after})
摘要变化不一定是错误,但必须能追溯到清单、源图或算法参数的变化。
自动验证先于页面抽查
素材管线完成后,先做结构验证,再打开应用:
python
def validate_asset(
spec: GalleryAssetSpec,
colors: list[str],
chart_rows: list[str],
preview_rows: list[str]
) -> None:
assert len(chart_rows) == 70
assert all(len(row) == 70 for row in chart_rows)
assert len(preview_rows) == 16
assert all(len(row) == 16 for row in preview_rows)
assert 1 <= len(colors) <= 16
allowed = set('.0123456789ABCDEF')
assert all(set(row) <= allowed for row in chart_rows)
assert (MEDIA_DIR / f'{spec.media_name}.png').exists()
完整审计还应确认:
- 清单恰好有 50 个唯一 ID。
- 五个分类各有 10 张。
- 50 个运行时 PNG 都能打开,尺寸与颜色模式符合约定。
- 43 个批量 ID 都有可追溯源图,7 个历史 ID 都有保留说明。
- 每个 Repository 种子都能找到媒体资源和矩阵。
- 颜色统计之和等于非空格子数。
- 脚本复跑后,无输入变化时输出摘要不变。
contact sheet 负责发现"数据正确但视觉错误"
自动检查能发现行宽、缺图和非法 token,却无法判断人物是否被裁掉耳朵、封面是否串图、主色是否偏灰。
建议每次生成一个 5×10 contact sheet:每格同时显示封面、ID、标题和矩阵小预览。人工先看整表,再随机进入每个分类至少两张详情。
powershell
python scripts/apply_image2_gallery_sheets.py
python scripts/generate_gallery_patterns.py
hvigor assembleHap --no-daemon
执行命令前要确认脚本输入路径有效。批量脚本包含外部源图路径时,应把源图归档到项目可追溯目录,避免换一台机器就无法复跑。
常见故障按生产阶段定位
| 现象 | 优先检查 | 根因 | 修复方向 |
|---|---|---|---|
| 封面与详情不是同一角色 | 清单 ID | 裁切坐标或资源名错配 | 从 ID 反查全链路 |
| 人物边缘被切掉 | crop_panel |
去边比例过大 | 保留安全边距 |
| 大量浅色区域变空 | is_empty |
亮度阈值过宽 | 用浅色样本重新标定 |
| 网格线进入调色板 | 中心采样 | 采到了单元边界 | 缩小并居中采样区域 |
| 某行整体错位 | 字符矩阵 | 行宽不是 70 | 写入前拒绝不等宽行 |
| 局部颜色突然消失 | token 解码 | 索引超过颜色表 | 区分空格与非法 token |
| 重跑产生巨大代码差异 | 输出顺序 | 遍历顺序不稳定 | 固定 ID 和调色板排序 |
| 应用包突然变大 | 资源目录 | 源图板进入 media | 生产源与运行资源分离 |
| 新电脑无法生成 | 脚本路径 | 依赖用户目录绝对路径 | 归档源图并使用项目相对路径 |
排查顺序应是"清单 → 源图板 → 裁切 → 采样 → 调色板 → 字符矩阵 → 媒体资源 → Repository → 页面"。不要看到详情错色就直接在 ArkUI 卡片里补一个颜色。
小结
50 张内置图库不是 50 个孤立 PNG,而是 50 条必须闭环的数据记录。用清单统一 ID 和裁切坐标,脚本稳定生成单图、16 色调色板与 70×70 字符矩阵,Repository 再把素材组装成业务 Pattern。同时保留 43 张批量资产与 7 张历史资产的差异,配合幂等输出、结构校验和 contact sheet,图库扩容才能真正可重复、可审查、可维护。