drawtext 做的事可以拆成两步:按字体把文字画成一张透明图,再把它贴到指定位置。 第一步靠 freetype,第二步和图片水印一模一样。
6.2 讲的是「贴一张现成的图」。这一节要贴的图是当场画出来的。 弄明白这两步,后面所有文字水印的参数都不再是黑箱。
一、文字其实是一张带透明通道的小图
「水印」两个字画出来是 56×31 的画布,字形只占 48×23, 非透明像素 481 个,只占整张图的 28%。
换句话说,这张图里有 72% 的像素是全透明的。 所以贴上去必须按 alpha 混合,不能整块覆盖。
整块覆盖会怎样?字的四周会挂一个黑色方框—— 因为透明区在 rgb24 里就是黑色,一覆盖就把底下的画面涂黑了。
二、贴上去:只碰了 3.3% 的画面
把这张图贴到 (94, 43),也就是右下角再往里缩 10 像素:
项 值
贴图位置 (94, 49) 被改动的像素 481 单通道最大变化 255 整幅像素 14400
改动像素 481 个,占整幅的 3.3%。 这就是水印「不碍事」的原因——它只碰了这么小一块地方。
「单通道最大变化 255」是指某些像素从全黑变成全白, 不是指整张图都被改了。这两件事很容易看混。
三、四条位置公式,值得背下来
drawtext 的 x、y 不是死坐标,是表达式。
w、h 是画面宽高,tw、th 是文字宽高。
位置 x 写什么 y 写什么
左上 10 10 右上 w-tw-10 10 左下 10 h-th-10 右下 w-tw-10 h-th-10
这四行与分辨率无关。换成 4K 素材,一个数字都不用改。 这是「写表达式」相对「写死坐标」最大的好处。
反过来说,如果你在命令里看到 x=1580:y=1020 这种数字,
基本可以断定这条命令只能在这一个分辨率上用。
四、命令行的写法
-vf "drawtext=text='水印':fontsize=24:fontcolor=white:x=10:y=10"
把 x=10:y=10 换成 x=w-tw-10:y=h-th-10,水印就跑到右下角。
不用自己去量文字多宽,tw 就是现成的。
本机没有 drawtext,所以这行命令没法在这里跑。 但它的两个动作(画字、贴图)上面都算过了,参数的实际效果不会跑偏。
五、动手 Demo:把 drawtext 拆成两步
用 Pillow 按字体把「水印」画成透明图,量出画布、包围盒与非透明像素。 再按 alpha 公式贴到右下角,数一数动了多少像素。
import numpy as np
from PIL import Image, ImageDraw, ImageFont
FONT = u"C:/Windows/Fonts/msyh.ttc"
f = ImageFont.truetype(FONT, 24)
bb = ImageDraw.Draw(Image.new(u"RGBA", (8, 8))).textbbox(
(0, 0), u"水印", font=f)
pad = 4
im = Image.new(u"RGBA", (bb[2] - bb[0] + pad * 2,
bb[3] - bb[1] + pad * 2), (0, 0, 0, 0))
ImageDraw.Draw(im).text((pad - bb[0], pad - bb[1]), u"水印",
font=f, fill=(255, 255, 255, 255))
a = np.array(im)
n = int((a[:, :, 3] > 0).sum())
print(u"画布 %dx%d 字形 %dx%d 非透明 %d 占 %.0f%%"
% (im.size[0], im.size[1], bb[2] - bb[0], bb[3] - bb[1],
n, n * 100.0 / (im.size[0] * im.size[1])))
import av
c0 = av.open(u"s5.mp4")
bg = next(c0.decode(video=0)).to_ndarray(format=u"rgb24")
c0.close()
H, W = bg.shape[:2]
x, y = W - im.size[0] - 10, H - im.size[1] - 10
fg = a[:, :, :3].astype(np.float64)
al = a[:, :, 3:4].astype(np.float64) / 255.0
reg = bg[y:y + im.size[1], x:x + im.size[0]].astype(np.float64)
out = bg.copy()
out[y:y + im.size[1], x:x + im.size[0]] = np.clip(
fg * al + reg * (1 - al) + 0.5, 0, 255)
d = np.abs(out.astype(np.int32) - bg.astype(np.int32))
print(u"贴到 (%d, %d) 改动 %d 像素 最大变化 %d 整幅 %d"
% (x, y, int((d.max(axis=2) > 0).sum()), int(d.max()), W * H))
实跑输出:
--------------------------------------------------------------------
6.3.1 drawtext 添加固定文字水印——等价实验
--------------------------------------------------------------------
**本机没有 drawtext,但 drawtext 做的事可以拆成两步:**
**「按字体把文字画成图」和「把这张图贴到指定位置」。**
**第二步与 6.2 的图片水印是同一件事,所以只要把第一步算清楚就够了。**
**① 把「水印」两个字画成一张透明图**
项 值
----------------------------
透明画布 56×31
字形包围盒 48×23
非透明像素 481
占整张图的比例 28%
**最后一行是关键:文字图里有 72% 的像素是透明的,字形本身只占一小片。**
**所以贴的时候必须按 alpha 混合,不能整块覆盖——**
**整块覆盖的话,字的四周会挂一个黑色方框。**
**② 贴到右下角**
项 值
-------------------------------
贴图位置 (94, 49)
被改动的像素 481
单通道最大变化 255
整幅像素 14400
**改动像素 481 个,占整幅的 3.3%。**
**水印之所以「不碍事」,就是因为它只碰了这么小一块地方。**
**③ 命令行怎么写**
**`-vf "drawtext=text='水印':fontsize=24:fontcolor=white:x=10:y=10"`**
**把 `x=10:y=10` 换成 `x=w-tw-10:y=h-th-10`,水印就跑到右下角去了。**
**`w`、`h` 是画面宽高,`tw`、`th` 是文字的宽高——**
**`w-tw-10` 就是「右边缘再往里缩 10 像素」,不用自己去算文字多宽。**
**这一点比「先量文字尺寸、再算坐标」省事得多,也是 drawtext 最好用的地方。**
**④ 水印放哪儿:四条常用公式**
位置 x 写什么 y 写什么
----------------------------------
左上 10 10
右上 w-tw-10 10
左下 10 h-th-10
右下 w-tw-10 h-th-10
**这四行值得抄进笔记——它们与画面分辨率无关,**
**换成 4K 素材也不用改数字,这是写成表达式而不是写死坐标的最大好处。**
textbbox 返回的是 (左, 上, 右, 下),原点在左上。
它可能与 (0, 0) 差几个像素——字形有上伸部与下伸部,起点会偏。
pad 是我自己加的空边,给字形留一点余量。
真实 drawtext 也有个类似的 borderw,作用是加描边而不是留白。
fg * al + reg * (1 - al) 这一行就是 overlay 的公式。
下一章讲 overlay 时会发现,我们在这里已经把它写完了。
30 秒实验
- 换字号:把 24 改成 12 与 48,看非透明像素怎么变。
- 换位置:把
(94, 43)换成左上角(10, 10),比较视觉影响。 - 不混合:把公式改成直接赋值
out[...] = fg,看图里是不是挂了黑框。
常见误区
- 整块覆盖水印图。透明区变成黑色,字的四周挂一圈黑框。
- 以为字号就是文字高度。
th才是,而且它随字体变化。 - 把坐标写死。换个分辨率水印就跑到画外去了。
自测三问(点开对答案)
1. drawtext 可以拆成哪两步? —— 按字体把文字画成透明图;再把这张图按 alpha 贴到指定位置。 2. 为什么不能整块覆盖? —— 文字图大部分像素是全透明的,覆盖会把这些地方涂黑。 3. `w-tw-10` 里的 tw 是什么? —— 文字的包围宽度,由字体与文字内容决定,不用自己去量。- ☐ (本节完,下一节 6.3.2 -vf 的 drawtext 控制文字颜色及大小)