AI 生成内容标识落地:显式角标 + EXIF 隐式标识 + 内容编号台账

缘起:一个"看起来只是写几个字段"的需求

《人工智能生成合成内容标识办法》(国信办通字〔2025〕2 号)与强制性国标 GB 45438-2025 于 2025-09-01 施行后,"给 AI 生成的内容加标识"从可选项变成了上架硬门槛------分发平台在上架时会核验标识材料,不符合强制性国标属行政处罚面,不是"整改提醒"。

我做的悦舍堂装修智能平台是一个多端产品(一套后端 + iOS / 安卓 / 鸿蒙 / 三个 Web 端)需要落地这套标识。初看这件事,需求很朴素:

  • 显式标识:人眼可见的标注(界面角标 + 导出文件上的文字水印);
  • 隐式标识:文件元数据里的生成合成属性信息(我们选 EXIF)。

但真正动手之后,我发现这件事的难点不在"写",而在"写对"与"跨端一致"。下面把踩过的坑、验证手段和工程取舍写清楚,给同样要做合规标识的同学省点时间。


1. 先把要求拆成三类字段

法规对隐式标识的三要素要求是:生成合成属性信息 、服务提供者名称或编码 、内容编号。我们把它映射成三处载体:

要素 载体 说明
生成合成属性信息 EXIF UserComment 的 aigc 恒 "1"
服务提供者 EXIF UserComment 的 provider 写平台自身的 ASCII 编码名
内容编号 EXIF UserComment 的 contentId + 服务端台账 YSTAI-{YYYYMMDD}-{8位小写hex},后端唯一生成

外加两个辅助字段:Software(标识版本)、ImageDescription(ASCII 短语)。UserComment 是这三要素的真正载体 ,Software / ImageDescription 属"可被常规工具直接看到"的辅助信息。

内容编号必须由后端生成------端上不许自造。这条不是洁癖:编号是回溯到服务端台账(留存 ≥6 个月)的唯一钥匙,若各端各行其是,台账就成了孤岛。


2. EXIF 写入:第一个坑在"落哪个 IFD"

用 Python + Pillow 给一张 JPEG 写这三个键,直觉写法长这样:

python 复制代码
from PIL import Image

img = Image.open("in.jpg")
exif = img.getexif()
exif[0x010E] = "AI Generated Content"        # ImageDescription
exif[0x0131] = "MyApp AI/v1"                 # Software
exif[0x9286] = b"ASCII\x00\x00\x00" + b'{"aigc":"1",...}'  # UserComment
img.save("out.jpg", exif=exif.tobytes())

这段代码的 UserComment 不会被写进 ExifIFD。 实测(Pillow 10.1.0)三种写法:

写法 结果
exif[0x9286] = ...(顶层赋值) ✗ 落 IFD0,get_ifd(0x8769) 读不到
exif.get_ifd(0x8769)[0x9286] = ... ✗ 原地改完全不落盘
exif[0x8769] = {0x9286: ...} ✓ 正确落在 ExifIFD

也就是说,UserComment(0x9286) 必须通过 ExifIFD 指针(0x8769) 整体重新赋值才落盘。规范读端从 ExifIFD 取 UserComment,写错 IFD 的话,文件"看着有 EXIF",但标识读不出来。正确写法:

python 复制代码
TAG_EXIF_IFD_POINTER, TAG_USER_COMMENT = 0x8769, 0x9286
sub = dict(exif.get_ifd(TAG_EXIF_IFD_POINTER))
sub[TAG_USER_COMMENT] = b"ASCII\x00\x00\x00" + payload
exif[TAG_EXIF_IFD_POINTER] = sub     # 整体重新赋值,才落 ExifIFD

顺带一个重编码陷阱 :如果你的通道会重编码图片,必须把源图的 EXIF 带上 (base_exif=img.getexif()),否则会把来源图的 Orientation 抹掉------方向标签一丢,看图方向和上传时不一致,用户会以为你处理坏了图片。做法是:带上源 EXIF,只覆盖我们自己的三个键。


3. UserComment 的六个键,为什么要"恒定齐全"

我们最终的载荷是单行 JSON、六键恒定:

json 复制代码
{"aigc":"1","contentId":"YSTAI-20260928-a1b2c3d4","provider":"Yueshetang","model":"","labelVersion":"v1","generatedAt":"2026-09-28T10:00:00+08:00"}

三条设计决定,都是被坑出来的:

① 未知值写空串,禁止省键。 同一张图可能经两条通道导出:后端落盘、端上导出。端上导出往往拿不到模型名(model 只能是 "")。如果"未知就省略键",那么同一内容经两条通道会得到不同的键集合 ------跨端逐字节比对时无法用"键完全相等"判定,只能降级成子序列匹配,此后每次比对都要先做键集合归一。恒定六键 让判定器可以直接断言 keys == EXPECTED_KEYS。

② provider 写平台名,不写上游通道名。 这是最容易写错的一处。最初的实现里,provider 回落成了"通道名"(如 modelscope / paid)。问题在于:法规要的是服务提供者 ,对使用者而言就是平台本身;上游模型通道是供应商 。写通道名会让"后端落盘"与"端上导出"得到不同的 provider,四端取证互相矛盾。定稿是:provider 恒为平台 ASCII 名,通道与模型的明细归服务端台账,靠 contentId 回溯。

③ 非 ASCII 一律转 \uXXXX。 UserComment 的 8 字节前缀声明的是 ASCII\0\0\0,正文若写裸 UTF-8 就与声明的字符集自相矛盾。所以 JSON 用 ensure_ascii=True 序列化,中文经 \uXXXX 无损往返,任何 JSON 消费者都能解回。


4. 三个实测坑(都是"只有按规范/按像素看产物"才暴露)

这三个坑的共性是:本地"写下来再读回"的镜像式断言永远发现不了它们 。因为端上写侧与读侧用的是同一个库、同一套假设,任何"库自己不加/不改"的偏差都会对称隐藏。

坑一:UserComment 缺 8 字节字符集前缀

EXIF 的 UserComment(0x9286) 是 type=UNDEFINED 型,规范要求载荷以 8 字节字符集标识开头:ASCII\0\0\0(5 个字母 + 3 个 NUL)。

现象:安卓端(Dart image 包)导出的图,用规范读端解析时,JSON 的前 8 个字符被当成字符集吃掉,整条隐式标识读不出来。

根因:iOS 的 ImageIO 会自动补这个前缀、后端 Pillow 按同布局写、鸿蒙手写字节------只有 Dart image 包把字符串原样写成 code units、不补前缀。

修正:

dart 复制代码
// 修复前:直接把 JSON 字符串写进去(读端会把前 8 字符当字符集吃掉)
// 修复后:显式补 8 字节前缀
final bytes = utf8.encode('ASCII\u0000\u0000\u0000' + jsonStr);

回归守卫要断言两件事:前缀 8 字节正确 ,且前缀后紧跟 {(证明 JSON 起点没错位)。

⚠️ 同一处还有个"手滑"版本的坑:鸿蒙端手写十六进制时写成了 ASCI\0\0\0\0(4 字母 + 4 NUL )。因为 Python 镜像脚本按规范写、ArkTS 手写少了一个 I,两边就此分叉------而镜像模式的断言恰好对比的是镜像自己的字节,永远发现不了。只有"设备上真跑一遍 → 拉回文件 → 按规范解字节"才暴露。修法是从前缀字符串字面量取字节,不再手写十六进制。

坑二:generatedAt 无时区偏移、且带小数秒

现象:判定器对该字段的正则 FAIL。

根因:安卓端用了 DateTime.now().toIso8601String(),得到的是

makefile 复制代码
2026-09-28T17:09:51.283023

既没有时区偏移,又带了 6 位小数秒。而契约规定的格式是:

css 复制代码
^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$

修正:本地时间 + 显式 ±hh:mm + 秒精度:

dart 复制代码
String _localIsoNow() {
  final now = DateTime.now();
  final off = now.timeZoneOffset;
  final sign = off.isNegative ? '-' : '+';
  final hh = off.inHours.abs().toString().padLeft(2, '0');
  final mm = (off.inMinutes.abs() % 60).toString().padLeft(2, '0');
  return '${now.year.toString().padLeft(4, '0')}-'
         '${now.month.toString().padLeft(2, '0')}-'
         '${now.day.toString().padLeft(2, '0')}T'
         '${now.hour.toString().padLeft(2, '0')}:'
         '${now.minute.toString().padLeft(2, '0')}:'
         '${now.second.toString().padLeft(2, '0')}$sign$hh:$mm';
}

为什么较真时区:generatedAt 是溯源字段。没有时区偏移的时间戳,在不同时区的读取方那里会得到不同的绝对时刻------审计时说不清"到底什么时候生成的"。

坑三:显式水印的中文全丢,只画出「AI」

现象:安卓导出的成品图右下角只有「AI」两个字,「生成 · 悦舍堂」整段是空白。逐像素核对确认是自家产物,同一位置在 iOS 上是完整的。

根因:水印文字用的是 Dart image 包自带的 arial48 位图字体,只有 ASCII 字形 (包内也未自带任何字体),中文全部画不出来。影响面是安卓独有的------iOS 走 CoreGraphics 真字体、鸿蒙走 ArkUI Canvas 真字体、Web 走 DOM/Canvas 真字体。

修正:水印文字改走 dart:ui 文本引擎 (ParagraphBuilder → Picture.toImage → 取 alpha 掩膜)再进原有的合成流程,位图字体保留为最后兜底(无 Flutter binding 的纯 Dart 环境)并大声记日志。

这个坑最值得记的不是修法,而是"为什么前几轮都没发现" :EXIF 那几条断言完全管不到显式 水印的像素;而唯一的"水印改变了像素"断言只要求 changed > 0------而「AI」两个字已经能满足它。这是典型的断言强度不足以区分"画对了"与"只画了一部分"。

改进后的守卫:比对"整串墨迹宽度"与"仅『AI』宽度"的比值 ------ 只出「AI」时比值≈1,正常≈5+。从断言层面锁死"中文静默丢失"。


5. 跨端一致性断言:什么该比、什么必须排除

四端(后端 / iOS / 鸿蒙 / 安卓)都实现后,我们加了一个"逐字节比对"的门禁。这里有个反直觉的坑:如果什么都比,门禁会假红。

最终冻结的断言范围:

比对项 是否断言 原因
键集合 ✅ 六键恒定是设计决定,必须一致
键序 ✅ 固定键序让判定器能做"完全相等"比较
aigc ✅ 恒 "1"
provider ✅ 恒平台名
labelVersion ✅ 恒 v1
contentId ⚠️ 有后端下发时应一致 端上只搬运不生成
model ❌ 必须排除 端上导出不知道模型名,写 "";后端写真实模型名
generatedAt ❌ 必须排除 时间戳必然不同

这条"必须显式排除"是收口时才发现的第三处不一致------两端都没上报,但它同样会让逐字节比对自相矛盾。判定脚本里要把 model 与 generatedAt 显式排除,不得因它们不同就判红。


6. 幂等与重试:别让标识叠成两层

生成任务会因为网络、超时而重试。标识的写入必须按 contentId 判重:

  • 若目标文件已有同号标识 → 原样返回,不二次叠加;
  • 若已有异号 标识 → 替换而非叠加。

水印叠加是不可逆操作,重复叠加会让水印越来越糊、字越来越重;EXIF 段重复插入则会让文件膨胀、读端可能取到旧段。所以"幂等"不是优化项,是正确性项。

同时定一条性能红线 :如果出图通道里 PIL 已经解码过一次图像,stamp 必须复用该 Image 对象,禁止二次解码------大图二次解码的耗时是肉眼可见的。


7. 取证脚本:为什么不"信" Pillow

验收口径是「证据是读数,不是截图 」。我们写了一个独立的判定器,不依赖 Pillow / exiftool,直接按 Exif 规范解 JPEG(APP1) 与 PNG(eXIf) 里的 TIFF 结构。

不用 Pillow 的理由很具体 :Pillow 把 ImageDescription 按 latin-1 解 ------ 而"该键必须是 ASCII"正是我们要判定的那条规则本身。用一个带解码假设的库去验"编码是否越规范",容易自证自话。

核心就三层,约 120 行:

python 复制代码
def tiff_from_jpeg(data):
    """扫 JPEG 段,找到 APP1 且带 'Exif\\0\\0' 前缀,返回其后裸 TIFF"""
    if data[:2] != b"\xff\xd8":
        return None
    i = 2
    while i + 4 <= len(data):
        if data[i] != 0xFF:
            i += 1; continue
        marker = data[i + 1]
        if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
            i += 2; continue
        length = int.from_bytes(data[i + 2:i + 4], "big")
        payload = data[i + 4:i + 2 + length]
        if marker == 0xE1 and payload[:6] == b"Exif\x00\x00":
            return payload[6:]
        if marker == 0xDA:      # 进入压缩数据,后面不会再有 APP1
            return None
        i += 2 + length
    return None

class Tiff:
    """按字节序标记(II/MM)解 IFD,产出 (tag, type, count, raw)"""
    def __init__(self, blob):
        self.blob = blob
        self.e = "little" if blob[:2] == b"II" else "big"
        assert self.u16(2) == 42, "坏 TIFF 魔数"
        self.ifd0 = self.u32(4)

    def u16(self, off): return int.from_bytes(self.blob[off:off+2], self.e)
    def u32(self, off): return int.from_bytes(self.blob[off:off+4], self.e)

    def entries(self, ifd_off):
        count = self.u16(ifd_off)
        for n in range(count):
            off = ifd_off + 2 + n * 12
            yield self.u16(off), self.u16(off+2), self.u32(off+4), self.blob[off+8:off+12]

有了 Tiff,UserComment 的判定就变成"取字节 → 剥 8 字节前缀 → 解 JSON → 断言键序":

python 复制代码
raw = tags[0x9286]
charset, body = raw[:8], raw[8:]
assert charset == b"ASCII\x00\x00\x00", "字符集前缀不符"
payload = json.loads(body.decode("ascii"))
assert list(payload.keys()) == ["aigc","contentId","provider","model","labelVersion","generatedAt"]

这个脚本可以直接当门禁 用(非全过即 exit 1),我们对 iOS / 鸿蒙的取证产物都跑了一遍,最终 ALL PASS(14 项判定)。

顺带说一个反直觉的结论:这个"按规范读字节"的判定器,比任何"写下来再读回"的单元测试都更有价值 。上文三个坑里有两个(8 字节前缀、鸿蒙少写一个 I)都是它抓出来的------因为镜像式断言的写侧和读侧共享同一套错误假设。如果只能做一个测试,就做这个"独立读端 + 真实产物"的判定器。


8. 一个容易忽略的架构点:四个通道的"字节持有"不同

标识该加在后端还是前端?取决于谁手里有图片字节:

通道 后端是否持有字节 标识落点
出图以 data URL 直返 ✅ 内存中 后端
落盘到对象存储 ✅ 落盘前 后端 + 对象元数据
第三方直连(只有上游 CDN URL) ❌ 只有 URL 只能前端在导出咽喉叠加
文本类(问答 / 文案) ✅ 落库 后端台账 + 端上角标 + 导出带

第三方直连那条是关键 :后端只有 URL、没有字节,所以前端导出时必须叠加标识 ------这是前端"省不掉"的根因。我们最终在前端把"导出咽喉"收敛成单点(下载 / 保存 / 写文件三个出口共用一个函数),一次改动覆盖十几个调用点,而不是散落在各个页面。


9. 复盘:合规标识的工程难点在哪

回头看,这件事花掉的时间远超"写几个字段"的预期,难点集中在三处:

  1. 规范细节 :UserComment 的字符集前缀、ImageDescription 的 type=ASCII 限制、generatedAt 的时区要求------每一条都有"看起来对、读出来错"的写法。
  2. 跨端一致 :多端并行时,必须有一份冻结的契约(字段名、取值、编号格式、角标规格全部锁定,各端禁自创),否则商店审核会以"标识不规范"打回。
  3. 验证手段 :必须有一个不信任何库、只信字节 的独立读端,且要拿真实设备产物跑,而不是本地镜像测试。

如果只让我留一条经验:把"文件级读数"当作唯一验收标准,别用截图和源码自证 。截图证明不了元数据,源码里的 write(exif=...) 也不等于文件里真有那条 EXIF------中间隔着重编码、IFD 落点、字符集前缀三个坑。

相关推荐
AiMan1 小时前
读取Meta Muse列表并导出这个功能,是没办法做到的是吗?
前端
RaaS1001 小时前
AI网关选型指南:MAI Gateway(魔芋企业级AI网关)vs 开源方案全面对比
前端·后端
小画家~1 小时前
vue3 里面的 itemList.reduce
前端·数据结构·算法
锋行天下1 小时前
langgraph 循环节点的静态和动态实现
前端·后端
keyipatience2 小时前
JS学习笔记(2)数组,函数,作用域,对象
前端·javascript·笔记·学习·html
2603_965148112 小时前
商品价格监控 API 接口:从原理到实战的完整指南
java·前端·数据库·python·生活
酷虎软件2 小时前
视频加字幕怎么制作
java·前端·音视频
Geoffwo2 小时前
robotjs0.6.0鼠标中轴滚动失效修复方案
前端·javascript
彭亚川Allen2 小时前
从零手搓 RAG 之文档解析技术
前端·后端·架构