Harmony os 技术实战|拼豆制图09:把 50 张图库素材做成可复跑的生产管线

图库从 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-1designer-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 个格子。如果每个格子都写成带 rowcolhex 的对象,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-9A-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()

完整审计还应确认:

  1. 清单恰好有 50 个唯一 ID。
  2. 五个分类各有 10 张。
  3. 50 个运行时 PNG 都能打开,尺寸与颜色模式符合约定。
  4. 43 个批量 ID 都有可追溯源图,7 个历史 ID 都有保留说明。
  5. 每个 Repository 种子都能找到媒体资源和矩阵。
  6. 颜色统计之和等于非空格子数。
  7. 脚本复跑后,无输入变化时输出摘要不变。

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,图库扩容才能真正可重复、可审查、可维护。

相关推荐
chjif3 小时前
Android 设备管控开发实战:企业受控终端的 DNS 域名策略实现
android·华为·harmonyos
OH_TPC3 小时前
【鸿蒙优选三方库】@ohos/grpc:在 HarmonyOS 上像调本地方法一样调远程服务
华为·harmonyos·鸿蒙
达子6664 小时前
第30章_综合案例:HarmonyOs开发图解之 俄罗斯方块游戏
游戏·华为·harmonyos
云端漫步19874 小时前
HarmonyOS NEXT AI 应用开发总结:30 篇之旅
人工智能·华为·harmonyos
math_hongfan4 小时前
鸿蒙Agent高级技能自定义开发:自定义意图服务/能力注册/Agent技能编排/服务插件开发实战
android·学习·华为·harmonyos·鸿蒙
云端漫步19877 小时前
HarmonyOS NEXT AI 智能生活助手:项目打包与发布
人工智能·华为·生活·harmonyos
less_121388 小时前
HarmonyOS WPS Open SDK:关闭回传 URI-FD 与沙箱 filePath
华为·harmonyos·wps
math_hongfan8 小时前
鸿蒙多模态AI交互高级:图文+语音+手势融合交互/多模态大模型端侧适配/跨模态检索高阶实战
人工智能·学习·华为·交互·语音识别·harmonyos·鸿蒙
云端漫步19878 小时前
HarmonyOS NEXT AI 智能生活助手:源码解析与项目复盘
人工智能·华为·生活·harmonyos