11. 防滥用斜向平铺水印:预渲染图块 + 像素级Blit平铺实现

11. 防滥用斜向平铺水印:预渲染图块 + 像素级Blit平铺实现

扫描身份证、护照、营业执照后,最怕什么?怕被拿去他用。一条斜向平铺的"仅供XX使用"水印,是法律层面最有效的防滥用手段。本文完整拆解 WatermarkService 的两步式架构------先用 Flutter 引擎渲染单个旋转文字图块,再用纯 Dart 像素级 blit 将图块斜向平铺到整张图上,支持行间错位、透明度调节、以及证件复印与通用图片水印的复用。


一、为什么水印是扫描App的"安全底线"

证件扫描场景有一个绕不开的问题:用户扫描身份证后,图片可能被截图、转发、甚至被第三方提取后用于实名认证。如果在证件图上叠加覆盖性文字水印(如"仅供XX银行办理业务使用"),即使图片被截图传播,水印也无法被简单地裁剪或涂抹去除。

核心原则:水印必须是"覆盖性"的,而非"附加性"的。 贴在角落的小logo毫无意义------裁剪一下就没了。真正的防滥用水印,要像纸币上的防伪线一样,覆盖整张图片、密度足够高、角度不可预测。

WatermarkService 采用两步式架构,将"文字渲染"和"像素平铺"彻底解耦:

步骤 方法 执行环境 职责
1 renderWatermarkTile 主 isolate 用 Flutter 引擎渲染单个旋转文字图块(支持中文)
2 stampWatermarkTile 纯 Dart,可后台 isolate 将图块斜向平铺合成到画布(像素级 blit)

这种分工的好处:证件复印功能和通用图片水印功能可以共用同一套实现,预览和导出也能复用同一张图块。


二、第一步:渲染水印图块(renderWatermarkTile)

2.1 方法签名

dart 复制代码
/// 水印文字图块的渲染结果(透明背景 RGBA)
///
/// [text] 水印文字;[fontSize] 像素字号;[color] 0xAARRGGBB。
/// 失败(如空文字)返回 null。
static Future<img.Image?> renderWatermarkTile({
  required String text,
  required double fontSize,
  int color = 0xFF8A8A8A,
}) async

参数说明:

参数 类型 默认值 含义
text String 必填 水印文字内容
fontSize double 必填 像素字号
color int 0xFF8A8A8A 颜色值,格式 0xAARRGGBB,默认灰色

为什么颜色用 int 而不是 Color?因为这个方法要和纯 Dart 的 image 包协作,Color 是 Flutter 的 UI 类,用 int 格式更通用。

2.2 完整实现

dart 复制代码
static Future<img.Image?> renderWatermarkTile({
  required String text,
  required double fontSize,
  int color = 0xFF8A8A8A,
}) async {
  final t = text.trim();
  if (t.isEmpty || fontSize < 4) return null;

  const angle = -30 * math.pi / 180; // 斜 30°,复印件常见角度
  final style = TextStyle(
    fontSize: fontSize,
    fontWeight: FontWeight.w600,
    color: Color(color),
  );

  // 1) 测量文字尺寸
  final measurer = TextPainter(
    text: TextSpan(text: t, style: style),
    textDirection: TextDirection.ltr,
    maxLines: 1,
  )..layout();
  if (measurer.width <= 0 || measurer.height <= 0) {
    measurer.dispose();
    return null;
  }

  // 2) 旋转后的包围盒尺寸
  final cosA = math.cos(angle).abs(), sinA = math.sin(angle).abs();
  final w = (measurer.width * cosA + measurer.height * sinA).ceil() + 8;
  final h = (measurer.width * sinA + measurer.height * cosA).ceil() + 8;

  // 3) 在透明画布上旋转绘制文字
  final recorder = ui.PictureRecorder();
  final canvas = Canvas(recorder, Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()));
  canvas.translate(w / 2, h / 2);
  canvas.rotate(angle);
  final painter = TextPainter(
    text: TextSpan(text: t, style: style),
    textDirection: TextDirection.ltr,
    maxLines: 1,
  )..layout();
  painter.paint(canvas, Offset(-painter.width / 2, -painter.height / 2));
  final picture = recorder.endRecording();
  try {
    final image = await picture.toImage(w, h);
    try {
      final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
      if (data == null) return null;
      return img.Image.fromBytes(
        width: w,
        height: h,
        bytes: data.buffer,
        numChannels: 4,
      );
    } finally {
      image.dispose();
    }
  } finally {
    picture.dispose();
    painter.dispose();
    measurer.dispose();
  }
}

2.3 关键设计点

固定 -30° 旋转角

dart 复制代码
const angle = -30 * math.pi / 180; // 斜 30°,复印件常见角度

为什么是 -30°?这是复印件水印的"行业标准角度"------既不会太平(像下划线),也不会太陡(文字难读),视觉上最自然。

旋转包围盒计算

dart 复制代码
final cosA = math.cos(angle).abs(), sinA = math.sin(angle).abs();
final w = (measurer.width * cosA + measurer.height * sinA).ceil() + 8;
final h = (measurer.width * sinA + measurer.height * cosA).ceil() + 8;

文字旋转后,其外接矩形的宽高不再是原文字的宽高。用 w*cosθ + h*sinθ 计算旋转后的包围盒宽度,高度同理。+8 是留一点 padding,防止旋转后边缘像素被裁切。

Flutter 引擎渲染中文

必须用 Flutter 的 TextPainter + PictureRecorder 来渲染文字------image 包自带的字体只支持 ASCII,无法渲染中文。这也是 renderWatermarkTile 必须在主 isolate 调用的原因:它依赖 Flutter 的 UI 引擎。


三、第二步:平铺水印图块(stampWatermarkTile)

3.1 方法签名

dart 复制代码
/// 把水印图块斜向平铺合成到画布上(原地修改并返回 [canvas])。
///
/// [opacity] 0..1 整体透明度;[step] 图块间距倍数(相对图块尺寸,
/// 越大越稀疏)。行间错位半个周期,观感更接近专业水印。
static img.Image stampWatermarkTile(
  img.Image canvas,
  img.Image tile, {
  required double opacity,
  double step = 2.2,
})

参数说明:

参数 类型 默认值 含义
canvas img.Image 必填 目标画布,原地修改
tile img.Image 必填 水印图块(由 renderWatermarkTile 生成)
opacity double 必填 整体透明度,0...1
step double 2.2 图块间距倍数,越大越稀疏

step = 2.2 表示图块之间的间距是图块自身尺寸的 2.2 倍。这个值是视觉密度的经验最优值------既不会太密(遮挡信息),也不会太疏(容易去除)。

3.2 平铺算法

dart 复制代码
static img.Image stampWatermarkTile(
  img.Image canvas,
  img.Image tile, {
  required double opacity,
  double step = 2.2,
}) {
  if (opacity <= 0) return canvas;
  final dst = canvas.toUint8List();
  final src = tile.toUint8List();
  final cw = canvas.width, ch = canvas.height;
  final tw = tile.width, th = tile.height;
  if (tw <= 0 || th <= 0) return canvas;

  final strideY = math.max(1, (th * step).round());
  final strideX = math.max(1, (tw * step).round());
  final op = opacity.clamp(0.0, 1.0);

  var rowIdx = 0;
  for (var y = -th ~/ 2; y < ch; y += strideY, rowIdx++) {
    // 行间错位半周期
    final rowShift = (rowIdx.isOdd) ? strideX ~/ 2 : 0;
    for (var x = -tw + rowShift; x < cw; x += strideX) {
      _blitTile(dst, cw, ch, src, tw, th, x, y, op);
    }
  }
  return canvas;
}

3.3 行间错位(rowShift)

dart 复制代码
final rowShift = (rowIdx.isOdd) ? strideX ~/ 2 : 0;

这是水印效果的点睛之笔。如果每行图块严格对齐,会形成明显的"行列网格感",看起来很 cheap。奇数行偏移半个周期后,视觉上形成类似砖墙的错落排列,更接近专业水印软件的效果。

复制代码
无错位(网格感强):        有错位(砖墙排列):
  [字]  [字]  [字]           [字]  [字]  [字]
  [字]  [字]  [字]             [字]  [字]  [字]
  [字]  [字]  [字]           [字]  [字]  [字]

3.4 起始位置偏移

dart 复制代码
for (var y = -th ~/ 2; y < ch; y += strideY, rowIdx++) {
  // ...
  for (var x = -tw + rowShift; x < cw; x += strideX) {

起始位置从 -th/2-tw 开始,而不是从 0 开始------这样可以保证画布的左上角也有水印覆盖,不会出现"左上角空白"的问题。因为图块是斜向旋转的,从负坐标开始能确保旋转后的文字完整覆盖到画布的四个角落。


四、像素级 Blit 合成(_blitTile)

4.1 Alpha 合成公式

单个图块的合成是标准的 alpha blending:

复制代码
dst.r = src.r * a + dst.r * (1 - a)
dst.g = src.g * a + dst.g * (1 - a)
dst.b = src.b * a + dst.b * (1 - a)

其中 a 是图块像素的 alpha 值乘以整体透明度。

4.2 完整实现

dart 复制代码
/// 单个图块的 alpha 合成(越界部分自动裁切)
static void _blitTile(Uint8List dst, int cw, int ch, Uint8List src, int tw,
    int th, int ox, int oy, double op) {
  final x0 = math.max(0, ox), y0 = math.max(0, oy);
  final x1 = math.min(cw - 1, ox + tw - 1), y1 = math.min(ch - 1, oy + th - 1);
  if (x0 > x1 || y0 > y1) return;
  for (var y = y0; y <= y1; y++) {
    final sy = y - oy;
    var si = (sy * tw + (x0 - ox)) * 4;
    var di = (y * cw + x0) * 4;
    for (var x = x0; x <= x1; x++) {
      final a = src[si + 3] / 255.0 * op;
      if (a > 0.003) {
        final ia = 1 - a;
        dst[di] = (src[si] * a + dst[di] * ia).round().clamp(0, 255).toInt();
        dst[di + 1] =
            (src[si + 1] * a + dst[di + 1] * ia).round().clamp(0, 255).toInt();
        dst[di + 2] =
            (src[si + 2] * a + dst[di + 2] * ia).round().clamp(0, 255).toInt();
      }
      si += 4;
      di += 4;
    }
  }
}

4.3 性能优化点

越界裁切 :先计算 x0, y0, x1, y1 的有效范围,图块在画布外的部分直接跳过,不进入内层循环。

透明度阈值跳过

dart 复制代码
if (a > 0.003) { ... }

当 alpha 小于 0.003(几乎完全透明)时,跳过合成计算------对视觉毫无影响,但能省掉大量乘法运算。文字图块的透明区域(占绝大部分面积)都会被跳过。

直接操作 Uint8List :使用 toUint8List() 获取像素字节数组的直接引用,然后用数组索引操作像素,比 getPixel/setPixel 方法调用快得多。


五、两步式架构的优势

5.1 为什么不逐字旋转绘制

直觉上,水印可以"遍历每个位置,旋转画布,绘制文字"。但这种方案有三个致命问题:

  1. 性能差 :每个水印文字都要调用一次 canvas.drawParagraph,高分辨率图上可能有上百个文字,每次都是完整的文字布局+绘制流程
  2. 无法后台执行Canvas 绘制只能在主 isolate 进行,大图水印会卡死 UI
  3. 内存开销大:每次绘制都要创建 Paragraph、布局、光栅化

预渲染图块 + blit 平铺的方案则完全不同:

  • 文字只渲染一次,之后只是像素复制
  • blit 是纯 Dart 运算,可以在后台 isolate 执行
  • 图块尺寸小(通常几百像素),内存占用低

5.2 主 Isolate / 后台 Isolate 分工

复制代码
主 isolate(必须):
  renderWatermarkTile()
    → TextPainter 布局文字
    → Canvas 旋转绘制
    → Picture → Image → RGBA 字节
    → 转为 img.Image 返回

后台 isolate(可选,大图用):
  stampWatermarkTile()
    → 遍历网格位置
    → _blitTile 像素合成
    → 返回处理后的画布

renderWatermarkTile 必须在主 isolate------因为它用到了 TextPainterCanvasPictureRecorder 等 Flutter UI 类,这些都只能在主线程使用。

stampWatermarkTile 是纯 Dart 代码,只操作 Uint8List,完全可以在后台 isolate 执行。高分辨率导出(如 300dpi 的 A4 纸,约 870 万像素)时,放在后台 isolate 能避免 UI 卡顿。

5.3 一处渲染,多处复用

因为图块是独立的 img.Image 对象,所以:

  • 预览和导出可以复用同一张图块(只是画布分辨率不同)
  • 证件复印的防滥用水印和通用图片水印共用同一套实现
  • 调整透明度不需要重新渲染图块,只需要在 blit 时改 opacity 参数

六、字号的相对比例策略

水印字号不应该用固定像素值------同样 24px 的字,在 1000px 宽的预览图和 3000px 宽的导出图上视觉大小完全不同。正确的做法是按画布宽度的比例计算字号

dart 复制代码
// 示例:字号约为画布宽度的 1/30
final fontSize = canvasWidth / 30;

final tile = await WatermarkService.renderWatermarkTile(
  text: '仅供办理业务使用',
  fontSize: fontSize,
  color: 0xFF8A8A8A,
);

这样无论画布是 1000px 还是 3000px,水印文字的视觉占比都是一致的。调用方根据场景选择合适的比例系数即可。

同理,step 参数也是相对比例------它是图块尺寸的倍数,所以字号变大时,间距也会自动按比例变大,密度保持一致。


七、完整调用示例

7.1 通用图片水印

dart 复制代码
Future<Uint8List?> applyWatermark(
  String imagePath,
  String watermarkText,
) async {
  // 1. 读取原图
  final bytes = await File(imagePath).readAsBytes();
  final image = img.decodeImage(bytes);
  if (image == null) return null;

  // 2. 渲染水印图块(主 isolate,字号按画布宽度比例)
  final fontSize = image.width / 30;
  final tile = await WatermarkService.renderWatermarkTile(
    text: watermarkText,
    fontSize: fontSize,
    color: 0xFF8A8A8A, // 灰色
  );
  if (tile == null) return null;

  // 3. 平铺水印(可放后台 isolate)
  WatermarkService.stampWatermarkTile(
    image,
    tile,
    opacity: 0.16,
    step: 2.2,
  );

  // 4. 编码输出
  return img.encodeJpg(image, quality: 92);
}

7.2 证件复印场景的水印

IdCopyService.compose 中集成:

dart 复制代码
// 水印图块(主 isolate 预先渲染,字号按目标 dpi 的画布宽度换算)
final tile = await WatermarkService.renderWatermarkTile(
  text: '仅供办理业务使用',
  fontSize: a4Width / 30,
);

// 合成时在后台 isolate 中平铺
WatermarkService.stampWatermarkTile(
  canvas,
  tile,
  opacity: 0.16,
  step: 2.4,
);

证件复印场景的 step 用 2.4(比通用水印的 2.2 稍疏),因为 A4 纸面积大,稍疏一点密度刚好。


八、性能数据

场景 分辨率 耗时
渲染图块(20字) ~200×60px <5ms
平铺(手机照片) 4000×3000px ~120ms
平铺(A4 300dpi) 2480×3508px ~150ms
预览(1200px 长边) ~1200×1600px ~30ms

渲染图块几乎不耗时,瓶颈在平铺阶段。但平铺是纯像素运算,3000 多像素的大图也只要 100 多毫秒,性能完全可接受。


九、安全性与防去除分析

水印的安全性取决于"去除成本"------攻击者需要花多少精力才能把水印去掉。

攻击方式 斜向平铺水印抵抗能力 说明
直接裁剪 覆盖全图,无法裁剪
频域滤波 斜向排列 + 行间错位打破频域周期性
局部修复 高密度下手动修复极耗时
AI去水印 低透明度 + 斜向排列增加AI误判
重新打印扫描 但会引入噪点降低图片质量

总结: 对于99%的日常防滥用场景,斜向平铺水印已经足够安全。它不需要密码学级别的防护,只需要让"去除成本 > 滥用收益",攻击者就会放弃。


十、踩坑总结

  • renderWatermarkTile 必须在主 isolate 调用------它依赖 Flutter UI 引擎(TextPainter、Canvas),后台 isolate 无法使用;
  • 图块颜色用 int(0xAARRGGBB)而不是 Color------方便和纯 Dart 的 image 包交互;
  • 字号用相对比例(画布宽度的几分之一),不要用固定像素------否则预览和导出的视觉大小不一致;
  • step 参数是图块尺寸的倍数,不是固定像素------这样字号变化时密度自动保持一致;
  • 行间错位(rowShift)是提升水印"专业感"的关键------没有错位的水印看起来像网格,有错位才像砖墙,更自然;
  • 起始位置从负坐标开始(-th/2, -tw)------确保画布的四个角落都有水印覆盖;
  • _blitTile 中的 a > 0.003 跳过透明像素------文字图块大部分区域是透明的,这个优化能省掉 80% 以上的计算量;
  • 透明度在 blit 阶段统一乘以 opacity------这样调整透明度不需要重新渲染图块,直接改参数就行。

水印的核心不是"怎么画字",而是"怎么高效地把字铺满整张图,同时又好看又难去除"。预渲染图块 + 像素级 blit 平铺 + 行间错位,三者结合构成了一套高性能、高复用、高防去除能力的水印方案。


🔔 完整源码即将上架

下一篇预告: 《证件正反面合成A4:composeIdToA4 排版与打印精度控制》------将深入讲解身份证正反面如何自动排版到 A4 纸张,包括 composeIdToA4 函数、stacked 参数控制横竖布局、保持宽高比缩放、整组垂直居中、8mm 证件间距、以及 WatermarkService 防滥用水印集成。