Image watermark library for MoonBit: visible and invisible traceable watermarks with embed/extract/verify APIs and a CLI.
Dependencies
| 类型 | API | 说明 |
|---|---|---|
| 文本水印 | RgbaImage::embed_text + TextConfig | 内置 5×7 点阵字体(A-Z / 0-9 / 常用符号)+ 16×16 中文点阵(Cjk16,GB2312 一级字 3755 个 + ASCII/符号 96 个,v0.1.10);TextConfig.font 切换,支持缩放、透明度、定位、角度旋转(angle,45° 斜向防盗播水印)。Cjk16 含汉字+ASCII:混合文本(中文标语+英文标识+数字/时间戳)一条水印完整渲染;生僻字/自定义品牌字形用 BitmapFont::with_cjk16(extra) 补充。越界防护(v0.1.11):水平文本超宽/超高默认报错,auto_shrink=true 自动缩小适配 |
| 随机点阵水印 | RgbaImage::embed_dots + DotConfig | 确定性 PRNG(xorshift64)按网格落点;同一 seed 完全可复现 → 可提取、可溯源 |
| Logo 水印 | RgbaImage::embed_logo | 任意 RGBA 图叠加,支持透明 PNG |
| 平铺水印 | RgbaImage::embed_tiled + TiledConfig | 全幅网格平铺,防截屏/盗摄 |
| API | 说明 |
|---|---|
| RgbaImage::embed_invisible + extract_invisible | LSB 逐位嵌入(RGB 通道各 1 bit/像素);key 派生 keystream 整体混淆,错误 key 在 magic 校验处被拒绝;容量 = 宽×高×3/8 字节 |
| RgbaImage::embed_dct + extract_dct | DCT 域:亮度域(BT.601 Y)8×8 分块 DCT-II,中频系数 (4,1) QIM 嵌入(与 JPEG 亮度分量一致,抗色度量化);delta 默认 24,JPEG 重压可提取(自然纹理 q85 默认通过;强压缩 q60 建议 delta=48);容量 = 完整 8×8 块数/8 字节 |
| API | 说明 |
|---|---|
| extract_dots | 用同一 DotConfig 复现点位置,统计深色命中率(0.0~1.0);注意这是绝对命中率,暗色调原图天然偏高,严谨判别请用 dot_contrast / verify_dots |
| dot_contrast | 多对照成对信号(v0.1.18):目标点位命中率 − median(k 组对照点位命中率),controls 默认 k=8(设 1 则与单对照完全一致)。对照组由 seed 确定性派生(同密度、未画点处),同一张图上两侧用同一亮度阈值统计 → 原图暗区/纹理的系统性偏差被自动扣除。未嵌入图 contrast≈0(暗图不再假阳性);已嵌入图 contrast 显著 >0。标定数据:单对照在渐变/棋盘等结构图型上存在 1.3% 虚警(1024 样本实测 13 例),多对照压至 0.5%(5 例),自标定后 0.1%(1 例)——见 cmd/calibrate |
| verify_dots | dot_contrast(多对照)≥ 阈值(默认 0.4)判定水印存在;未嵌入亮图/暗图均判 false,嵌入后判 true。calibrated=true 启用图像自标定显著性检验:contrast 还须显著高于本图 null 本底(+significance_margin,默认 0.15)才判存在——用于压制渐变等目标点位自身偏斜的残余虚警(v0.1.15 起由"绝对命中率 ≥0.9"升级,v0.1.18 升级为多对照+可选自标定) |
| verify_invisible | LSB 水印 magic 校验 |
| trace_dots | 对候选 DotConfig 数组逐一算多对照 dot_contrast,返回信号最强且与次强差距 ≥ margin(默认 0.15)的候选索引;-1=无信号达标,-2=候选歧义不误锁,≥0=唯一强指向候选。calibrated=true 时最高候选还须显著高于本图 null 本底才指认。输出语义是统计证据而非最终裁决:最终归属判定应结合分发台账交叉核验。分发场景为每个接收者分配独立 seed(v0.1.15 引入,v0.1.18 升级为多对照+可选自标定) |
| extract_dct | DCT 水印 magic "MD" + 长度校验;JPEG/PNG 往返均可提取;delta 可选参数须与嵌入侧一致 |
| detect_lsb / detect_dct | 盲检测:不依赖 key/delta,仅读帧头 magic + 长度合理性判断"是否被标记"——溯源第一问(拿到疑似泄漏帧先判断,再决定深入提取);注意带 key 混淆的 LSB 帧无 key 检不出(文档化语义) |
| RgbaImage::lsb_capacity / dct_capacity | 查询各通道可用 payload 容量(字节,扣除帧头),嵌入前规划"这条 payload 走 LSB 还是 DCT" |
| RgbaImage::embed_all + EmbedOptions | 组合嵌入:一次调用按配置叠加文本/点阵/LSB/DCT(可选通道),面向逐帧应用(直播贴片)复用;LSB 与 DCT 互斥返回 Err,不静默降级 |
| API | 说明 |
|---|---|
| psnr(orig, marked) | 峰值信噪比(dB),量化嵌入前后可感知差异;尺寸不一致返回 None |
| 场景 | 推荐能力 | 机制 |
|---|---|---|
| 电商主图 / 详情页防同行盗图 | 可见(Logo / 文本) | 店铺名 + 商品信息角落或平铺,搬运必留痕 |
| 素材 / 图库预览防盗 | 可见 + 不可见 | 预览图盖半透明水印;样图嵌客户 ID |
| 内部设计稿 / 文档截图泄密溯源 | trace_dots | 每个员工 / 供应商独立 seed,外泄即定位 |
| 图片版权确权(摄影 / 插画 / 设计) | LSB / DCT 不可见 | 创作时嵌入作者 ID + 时间,维权时提取作证;经压缩分发时选 DCT |
| API 图片 / 头像接口追踪 | LSB / DCT 不可见 | 按调用方嵌入 ID,识别爬虫、盗链、二次分发;CDN 压缩链路选 DCT |
| 广告 / 营销素材渠道追踪 | trace_dots | 不同投放渠道不同 seed,泄漏渠道一测便知 |
| 社交 / UGC 平台防二创搬运 | 平铺 | 全幅网格水印,截图翻拍仍可见 |
| 直播 / 视频切片防搬运 | 可见 + 点阵(动态) | 平台名 + 时间戳 + 观众 ID,配合 OBS 浏览器源落地 |
| 报告 / 报表 / 合同截图防外泄 | 平铺 + 文本 | 公司名 + 部门,截屏即留痕 |
| 取证存证 | 文本 + LSB | 时间 + 拍摄者身份,形成证据链 |
moon add yuzhiblue/moon-watermark// 依赖:moon add yuzhiblue/moon-watermark
// import:
// import "yuzhiblue/moon-watermark" @lib
// import "moonbitlang/core/encoding/utf8" @utf8 // 中文 payload 转 UTF-8 字节
// import "moonbitlang/core/bytes" @bytes // Bytes 切片(decode_png 结果)
//
// 注意:String::to_bytes 在 MoonBit 中返回 UTF-16,不可直接作 payload,
// 必须经 @utf8.encode 转码(含 ASCII:@utf8.encode("UID-001"))。
// 1. 生成测试图(或 decode_png 解码真实图片)
let img = @lib.RgbaImage::new(320, 240, 0xFFFF_FFFF)
// 2. 组合嵌入:文本(右下角 45° 斜向)+ 点阵(seed=接收者 ID)+ LSB(观众 ID)
// embed_all 返回 Result(任一通道失败即 Err,不产生半成品)
// v0.1.12 起 with_font 可直接设 angle(45° 斜向防盗播水印)
let tcfg = @lib.TextConfig::with_font("LIVE", 8, 200, 2, 0.6, 0xFF00_00FF, "ascii5x7", angle=45.0)
let dcfg = @lib.DotConfig::default()
let opts : @lib.EmbedOptions = {
text_cfg: Some(tcfg),
dot_cfg: Some(dcfg),
lsb_payload: Some(@utf8.encode("UID-001")),
lsb_key: Some(42L),
dct_payload: None,
dct_delta: 24,
}
let marked = match img.embed_all(opts) {
Ok(m) => m
Err(e) => { println("embed failed: \{e}"); abort() }
}
// 3. 盲检测 → 提取验证(溯源链路:先 detect 再 extract)
if @lib.detect_lsb(marked) { println("marked ✓") }
let back = @lib.decode_png(marked.encode_png().unwrap_or(Bytes::new())).unwrap_or(img)
let contrast = @lib.dot_contrast(back, dcfg) // 成对对照信号:目标命中率 − 对照命中率
let ok = @lib.verify_dots(back, dcfg) // ≥0.4 判定点阵水印存在(v0.1.15)# 嵌入:文本 + 点阵 + LSB 不可见水印,输出 PNG/JPEG(按扩展名)
moon run cmd/main -- embed in.png out.png --text "LIVE 2026-10-03" --font cjk16 --lsb "UID-001" --key 42
# 只嵌 DCT 域水印(抗 JPEG),delta 加大可抗更强压缩
moon run cmd/main -- embed in.jpg out.jpg --dct "UID-001" --delta 48
# 验证
moon run cmd/main -- verify-dots out.png # 点阵成对对照信号 + 判定(contrast / verify)
moon run cmd/main -- verify-lsb out.png --key 42 # LSB 提取(key 必须与嵌入一致)
moon run cmd/main -- verify-dct out.png --delta 48 # DCT 提取(delta 必须与嵌入一致)
# 无参数 = 内存演示(原 Quick Start 示例)
moon run cmd/main


moon run cmd/e2e # 默认用 assets/live_frame.png
moon run cmd/e2e -- your.png # 任意图片跑同一链路
→ 
| 判定问题 | 区分机制 | 判定出口 | 案例 |
|---|---|---|---|
| 原图干扰 | 多对照成对信号 dot_contrast = 目标点位命中率 − median(k=8 组对照命中率)。对照组由 seed 确定性派生(同密度、未画点处),同一张图上两侧用同一亮度阈值统计 → 原图暗区/纹理的系统性偏差在差值中自动扣除 | verify_dots=false、trace_dots=-1(无候选达标) | A |
| 候选歧义 | trace_dots 要求最高信号与次高信号差距 ≥ margin(默认 0.15)才锁定;不足即拒绝 | 返回 -2:候选歧义,不误锁,转人工复核 | B |
| 真实分发来源 | 分发时为每个接收者分配独立 seed;泄漏帧上只有真实来源候选的 contrast 显著 > 0(错误 seed ≈ 0 甚至为负),且与次强候选拉开 ≥ margin | trace_dots 返回唯一索引(≥ 0) | C |
| 盲检测(溯源第一问) | detect_lsb/detect_dct 不依赖 key/delta,仅读帧头 magic + 长度合理性,回答「是否被标记」,再决定是否深入提取 | 未标记 → 溯源终止;已标记 → 进入提取(需正确 key/delta) | D |
| 结构图型虚警 | 单对照在渐变/棋盘等图型上有残余虚警(目标点位子集偏斜):第一级多对照 k=8 压制对照侧噪声;第二级 calibrated=true 自标定显著性检验——候选须高于本图 null 本底 + margin 才输出 | 两级修复后 verify=false / trace=-2(不输出指认) | E |
| 指标 | 单对照(v0.1.17) | 多对照 k=8(v0.1.18 默认) | + 自标定(calibrated=true) |
|---|---|---|---|
| 未嵌入图误报率(1024 样本 × 16 图型) | 13/1024 ≈ 1.3% | 5/1024 ≈ 0.5% | 1/1024 ≈ 0.1% |
| 最大虚警 contrast | 0.667 | 0.567 | — |
| 适用场景 | 平滑内容画面 | 平滑内容画面 | 渐变/棋盘等结构图型兜底 |
================================================================
moon-watermark 溯源判定案例集(多对照信号 + 自标定,v0.1.18)
点阵溯源的判定情形:原图干扰 / 候选歧义 / 真实分发来源 / 结构图型虚警
================================================================
--------------------------------------------------------------
案例 A:原图干扰(未嵌入水印的暗色原图 → 必须拒判)
--------------------------------------------------------------
A1 输入:64×64 纯暗图(未嵌入任何水印),候选 seed=42
绝对命中率(旧判据)= 1 ← 旧判据在此假阳性 verify=true
dot_contrast(新判据)= 0 verify_dots = false
trace_dots = -1 → -1 = 无候选达标,不误锁(正确)
A2 输入:64×64 暗纹理图(亮度 20/150 伪随机,未嵌入),候选 seed=42
绝对命中率(旧判据)= 1 ← 暗纹理系统抬高命中率
dot_contrast(新判据)= 0 verify_dots = false
trace_dots = -1 → 原图干扰被对照项扣除,正确拒判
结论:原图干扰由成对对照机制自动扣除——未嵌入图 contrast ≈ 0,
verify_dots=false、trace_dots=-1;假阳性在回归测试中固化。
--------------------------------------------------------------
案例 B:候选歧义(信号不可区分 → -2 拒绝误锁;可区分 → 唯一命中)
--------------------------------------------------------------
B1 输入:嵌入 seed=42 的图,候选 = [cfg42, cfg42](信号并列)
候选[0] seed=42 dot_contrast=1 达标(阈值 0.4)
候选[1] seed=42 dot_contrast=1 达标(阈值 0.4)
最高 − 次高 = 0 < margin(0.15) → trace_dots = -2
→ -2 = 候选歧义,无法唯一确定来源,转人工复核(不误锁)
B2 输入:同一张图,候选 = [cfg46(错误seed), cfg42(正确seed)]
候选[0] seed=46 dot_contrast=0 未达标(阈值 0.4)
候选[1] seed=42 dot_contrast=1 达标(阈值 0.4)
→ trace_dots = 1 → 唯一命中索引 1(正确 seed)
结论:能区分时差距 > margin 即锁定;不能区分时返回 -2——
「歧义」与「命中」由同一阈值体系统一判定,无中间模糊态。
--------------------------------------------------------------
案例 C:真实分发来源(分发独立 seed → 泄漏帧唯一锁定)
--------------------------------------------------------------
C 输入:嵌入 seed=421(观众C)的图 → JPEG q85 转码 → 读回
候选 = [seed=1(观众A), seed=2(观众B), seed=421(观众C)]
候选[0] seed=1 dot_contrast=0 未达标(阈值 0.4)
候选[1] seed=2 dot_contrast=0 未达标(阈值 0.4)
候选[2] seed=421 dot_contrast=1 达标(阈值 0.4)
verify_dots = true(JPEG q85 后仍通过)
trace_dots = 2(0=观众A 1=观众B 2=观众C)→ 锁定观众 C(seed 421)——泄漏来源唯一确定
结论:分发时为每个接收者分配独立 seed,泄漏帧的 contrast 只在
真实来源候选上显著 >0 → trace_dots 返回其索引,溯源到人。
--------------------------------------------------------------
案例 D:盲检测(溯源第一问:先判「是否被标记」再提取)
--------------------------------------------------------------
D1 输入:未嵌入的原图
detect_lsb=false detect_dct=false → 未标记,溯源终止
D2 输入:嵌入 DCT 水印(UID-00421, delta=24)的图
detect_dct=true detect_lsb=false → 已标记(DCT)→ extract_dct = UID-00421
D3 输入:嵌入 LSB 水印(UID-001, key=42)的图
detect_lsb(无key)=false ← 带 key 混淆的帧头不可见(已文档化语义)
extract_invisible(错误key=43) None(magic 校验拒绝,错误 key 提取失败);extract_invisible(正确key=42) = UID-001
结论:盲检测回答「是否被标记」(第一问,无需参数);提取回答
「标记内容是什么」(需正确 key/delta)。带 key 的 LSB 帧
以持有方嵌入记录为溯源入口,边界已在 README 安全边界声明。
--------------------------------------------------------------
案例 E:结构图型虚警(渐变原图 → 多对照 + 自标定两级拒判)
--------------------------------------------------------------
输入:128×128 水平渐变图(0→255,未嵌入),候选 seed=12 / 8
E1 seed=12 单对照=0.4444444444444444(≥0.4 虚警) 多对照(k=8)=0.18308080808080807 → verify_dots=false(修复)
E2 seed=8 单对照=0.6666666666666666(≥0.4 虚警) 多对照(k=8)=0.4833333333333333(仍达标——目标点位自身偏斜)
calibrated=true 自标定:0.4833333333333333 未显著高于本图 null 本底 + 0.15 → verify_dots=false(修复)
verify_dots(calibrated=true) = false
结论:单对照在结构图型上存在 ~1.3% 虚警(1024 样本实测 13 例);
多对照压至 0.5%,自标定压至 0.1%。残余虚警由显著性检验兜底——
机制的承诺边界以 cmd/calibrate 标定数据为准,不做绝对化表述。
================================================================
判定规则速查(trace_dots 返回值语义):
>= 0 唯一命中 → 锁定该候选对应的分发来源(输入索引)
= -1 无候选达标 → 原图未嵌入或候选均不匹配(原图干扰已扣除)
= -2 候选歧义(最高 − 次高 < margin 0.15)→ 不误锁,转人工复核
================================================================moon run cmd/bench # 320×240 … 1920×1080 四档
moon run cmd/bench -- 640 360 # 指定尺寸| 操作 | 320×240 | 640×360 | 1280×720 | 1920×1080 |
|---|---|---|---|---|
| embed_text(cjk16 右下角) | 85 µs | 85 µs | 85 µs | 110 µs |
| embed_dots(128 点) | 5.5 µs | 5.6 µs | 5.6 µs | 5.6 µs |
| embed_invisible(LSB) | 3.5 µs | 3.5 µs | 3.5 µs | 3.5 µs |
| embed_dct(delta=24) | 111 ms | 112 ms | 110 ms | 110 ms |
| embed_all(文本+点阵+LSB) | 95 µs | 98 µs | 94 µs | 118 µs |
| encode_png | 22 ms | 60 ms | 230 ms | 514 ms |
符号含义:✅ 通过/可提取 · ❌ 该攻击下提取失败(水印特性,见说明列)· ⚠️ 已知边界(点阵在纯裁剪下失效)。❌ 不是功能缺失——每种水印各有适用链路,表格与说明列给出替代方案。
| 攻击 | 点阵水印命中率 | LSB 不可见水印 | DCT 域水印 | 说明 |
|---|---|---|---|---|
| PNG 无损往返 | 1.0 ✅ | 可提取 ✅ | 可提取 ✅ | PNG 是 LSB 的可靠载体,DCT 亮度域无损往返亦稳定 |
| JPEG q60 重压 | 1.0 ✅ | 提取失败 ❌ | 可提取 ✅(delta=48) | 深色点高对比经 JPEG 保留;LSB 被量化破坏;DCT 在亮度域嵌入、与 JPEG 量化域一致(delta 越大越抗压,默认 24 适合 PNG 工作流与自然纹理 q85,重压缩建议 --delta 48) |
| 缩放至 1/2(Nearest) | 1.0 ✅ | 提取失败 ❌ | 提取失败 ❌ | 点阵:归一化网格 + 偶数对齐 + 邻域容差(v0.1.2 起)。LSB/DCT:像素坐标随缩放错位 → 失败;需抗几何攻击的不可见水印属后续规划 |
| 裁剪半幅 | 0.0 ⚠️ | 可提取 ✅ | 提取失败 ❌ | 点阵:归一化网格按新尺寸采样、而裁剪内容是原坐标像素 → 错位(已知边界)。LSB:裁剪保留原坐标像素 → 可提取。DCT:分块按原坐标对齐,裁剪错位。纯裁剪攻击建议配合 LSB |
pub trait GlyphProvider {
fn cell_width(Self) -> Int
fn cell_height(Self) -> Int
fn glyph(Self, Char) -> FixedArray[Byte]?
}impl GlyphProvider for BitmapFontfn BitmapFont::new(cell_w : Int, cell_h : Int, pairs : Array[(Char, FixedArray[Byte])]) -> BitmapFontpub struct DotConfig {
density : Double
dot_size : Int
seed : Int64
opacity : Double
grid_offset_x : Int
grid_offset_y : Int
}pub(all) struct EmbedOptions {
text_cfg : TextConfig?
dot_cfg : DotConfig?
lsb_payload : Bytes?
lsb_key : Int64?
dct_payload : Bytes?
dct_delta : Int
}let opts = {
text_cfg: Some(tcfg),
lsb_payload: Some(payload),
lsb_key: 7L,
dot_cfg: None,
dct_payload: None,
dct_delta: 24,
}pub struct RgbaImage {
width : Int
height : Int
data : FixedArray[Byte]
}fn RgbaImage::resize(self : RgbaImage, width : Int, height : Int, sampler : SampleMode) -> RgbaImagepub struct TextConfig {
text : String
x : Int
y : Int
font_size : Int
opacity : Double
angle : Double
color : Int
font : String
auto_shrink : Bool
}fn TextConfig::new(text : String, x : Int, y : Int, font_size : Int, opacity : Double, color : Int) -> TextConfigfn TextConfig::with_font(text : String, x : Int, y : Int, font_size : Int, opacity : Double, color : Int, font : String, angle? : Double) -> TextConfigpub struct TiledConfig {
text : String
font_size : Int
spacing_x : Int
spacing_y : Int
opacity : Double
color : Int
font : String
}fn TiledConfig::new(text : String, font_size : Int, spacing_x : Int, spacing_y : Int, opacity : Double, color : Int, font? : String) -> TiledConfigfn blend_pixel(dst : Int, src : Int, alpha : Int) -> Intfn[T : GlyphProvider] render_text_with(text : String, color : Int, provider : T, scale? : Int) -> RgbaImagefn xorshift64_next(state : UInt64) -> UInt64Install
Download zipImage watermark library for MoonBit: visible and invisible traceable watermarks with embed/extract/verify APIs and a CLI.
Dependencies