手写 PPM 和 PNG
目标
不借助任何库,直接写出图像文件,让浏览器能够显示自己计算出的像素。完成本实验后,在这条学习路径的其余所有实验中,你都能用眼睛检查结果。
为什么重要
图形学学习中途停滞最常见的原因,是没有查看结果的方法。但写出一个图像文件并不困难。PPM 只是在三行文件头后依次排列像素;PNG 也只需 8 字节签名和三个数据块。压缩和 CRC 可由标准库的 zlib 完成。
亲自写一次,就会理解:像素按行优先排列;坐标 (x, y) 的位置是 (y * 폭 + x) * 3;图像的 y 轴向下增长,而 3D 数学中的 y 轴向上增长。这三点会在后续所有实验中反复出现。此外,当着色器异常时,最快的诊断方法是把中间值映射为颜色输出;为此必须熟练掌握这里的函数。
步骤
- 在
/root/gfx/out/tiny.ppm写入 4x4 P6 PPM。 - 在
/root/gfx/out/gradient.ppm写入 256x256 渐变图。 - 仅使用
zlib和struct写出 PNG,生成/root/gfx/out/gradient.png。 - 创建
/root/gfx/out/index.html,启动 HTTP 服务器并通过网页预览查看。 - 填充圆形,生成
/root/gfx/out/shapes.png。 - 绘制 12 条直线,生成
/root/gfx/out/lines.png。 - 合并两者,生成
/root/gfx/out/final.png和/root/gfx/out/report.txt。
参考
- 必须使用
open(path, "wb")打开文件。以文本模式打开可能改变字节内容。 - 使用
nohup python3 -m http.server 8080 -d /root/gfx/out &启动服务器。然后点击界面的网页预览按钮,选择端口 8080,再打开http://localhost:8080/。 - 常见错误 1:遗漏 PNG 每行开头的过滤器字节,图像会呈现对角错位。
- 常见错误 2:PPM 文件头中数字之间用空格,行末用换行符。这里一旦错位,连文件大小都会发生变化。
创建一个 4x4 PPM
创建 /root/gfx/tiny.py,将 4x4 的 P6 PPM 写入 /root/gfx/out/tiny.ppm。左上角 (0,0) 为红色(255,0,0),右上角 (3,0) 为绿色(0,255,0),左下角 (0,3) 为蓝色(0,0,255),右下角 (3,3) 为白色(255,255,255),其余十二个像素为黑色。
P6 的文件头有三行。用换行符依次连接 P6、4 4、255,然后原样写入 48 个像素字节。
像素从左上向右排列,一行结束后进入下一行。坐标 (x, y) 的起始位置是 (y * 4 + x) * 3。
必须以二进制模式(open(path, "wb"))打开文件。文本模式下,字节值 10(换行)可能因平台而改变。
256x256 渐变图
使用 /root/gfx/gradient.py 将 256x256 P6 PPM 写入 /root/gfx/out/gradient.ppm。坐标 (x, y) 的颜色必须恰好为 (x, y, 64)。
结构与上一步相同,只是尺寸更大。在双重循环中把 y 放在外层、x 放在内层,字节生成顺序就与文件顺序一致。
使用 bytearray 并通过 += 追加,比每次计算索引更快。文件大小为文件头 15 字节 + 256×256×3 = 196,608 字节。
图像应从左到右越来越红,从上到下绿色越来越强。
仅使用 zlib 和 struct 写出 PNG
在 /root/gfx/png.py 中创建 write_png(path, w, h, px) 函数,并将与上一步相同的图像写入 /root/gfx/out/gradient.png。颜色类型为 2(真彩色),位深为 8,所有行过滤器均为 0(None)。
PNG 以 8 字节签名 \x89PNG\r\n\x1a\n 开始,之后依次是数据块。一个数据块的格式为 길이(4바이트 빅엔디안) + 종류(4글자) + 내용 + CRC(4바이트),CRC 仅针对类型和内容计算(zlib.crc32)。
IHDR 内容为 struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)。IDAT 内容是在每行前添加一个值为 0 的过滤器字节、拼接全部行,再经 zlib.compress 压缩所得。IEND 内容为空字节串。
如果遗漏每行的过滤器字节,图像会呈现对角错位。这是该格式中最常见的错误。
在浏览器中查看自己的图像
创建 /root/gfx/out/index.html,通过 gradient.png 将其放入 <img>;启动 python3 -m http.server 8080 -d /root/gfx/out 后,在网页预览中打开 http://localhost:8080/。
HTML 只需几行。图像位于同一文件夹,因此使用 gradient.png 这样的相对路径。
服务器会占用终端,请像 python3 -m http.server 8080 -d /root/gfx/out & 一样在末尾添加 &,让它在后台运行。点击界面顶部的网页预览按钮,选择端口 8080 即可查看。
评分检查的是 index.html 文件和 PNG,而不是服务器——如果用时有时无的进程来判定,同一答案可能今天通过、明天失败。
填充圆形
使用 /root/gfx/shapes.py 将 256x256 PNG 写入 /root/gfx/out/shapes.png。背景为白色(255,255,255),以 (128,128) 为圆心、半径 60 的圆内部填充蓝色(40,80,220),图像外围 1 像素边框为黑色(0,0,0)。
无需开平方即可判断点是否在圆内。满足 (x - 128) ** 2 + (y - 128) ** 2 <= 60 ** 2 即在内部。开平方更慢,而且这里没有必要。
边框是 x 或 y 等于 0 或 255 的像素。先画圆、最后画边框,重叠位置将以边框为准。
请导入上一步在 png.py 中创建的 write_png,写法为 from png import write_png。
绘制直线
使用 /root/gfx/line.py 将 256x256 PNG 写入 /root/gfx/out/lines.png。背景为黑色,从中心 (128,128) 向外绘制 12 条长度为 100 的白色直线,角度间隔 30 度(0 度、30 度、...、330 度)。角度 t 的端点为 (128 + 100*cos(t), 128 - 100*sin(t))。
使用 Bresenham 直线算法,可仅用整数运算完成绘制。设置误差值 err = dx + dy(dx = abs(x1-x0)、dy = -abs(y1-y0)),每一步将 2*err 与 dy、dx 比较,以决定沿 x 还是 y 前进。
请注意端点 y 坐标中有减号。数学坐标的 y 轴向上增长,而图像坐标的 y 轴向下增长。
先将端点四舍五入为整数,再进行绘制。
合并绘制并记录可靠数值
使用 /root/gfx/report.py 将圆和直线合并到一张图中,生成 /root/gfx/out/final.png(256x256、白色背景、蓝色圆、12 条黑色直线、黑色边框);并在 /root/gfx/out/report.txt 中写入 width=、height=、background=r,g,b、nonbackground= 四行。nonbackground 是颜色不等于背景色的像素数量。
把前两个步骤连接起来即可。先填充圆,在其上用黑色绘制直线,最后绘制边框。
不要猜测 nonbackground,请在全部绘制完成后统计像素再写入。评分器会直接打开 PNG 进行统计,并与你写下的值核对。两者不一致就说明其中一个有误。
background 是以逗号连接的三个整数。例如:background=255,255,255