
本文介绍如何将 esc/p 打印机控制协议中的点阵图像数据(通过串口捕获的原始字节流)解析为标准 bmp 格式图像,涵盖协议关键指令识别、位图解包逻辑、行列方向校正及 pil 图像生成全流程。
ESC/P(Epson Standard Code for Printers)是一种广泛用于针式/点阵打印机的控制协议,其图像打印常通过 ESC *(0x1B 0x2A)或 ESC K(0x1B 0x4B)等指令传输逐列编码的位图数据。当设备(如 R&S cms52 频谱监测仪)模拟打印机输出时,我们可通过串口捕获该二进制流,并将其还原为可视化的黑白位图(BMP)。以下是一个健壮、可扩展的 python 解析方案。
✅ 核心协议指令识别与字段提取
ESC/P 中常见的位图传输指令有两种主流变体:
- *`ESC m nL nH**(0x1B 0x2A m nL nH):m指定模式(如0= 8-dot 单密度),nL/nH为列数(小端或大端需确认,示例中为大端>BB`);
- ESC K nL nH(0x1B 0x4B nL nH):更紧凑的格式(如 R&S CMS52 所用),无模式字节,直接跟两字节列数。
在解析时需先定位指令起始位置,再按对应格式读取列数和图像数据。例如:
# 匹配 ESC K 指令(适用于 CMS52 等设备) start_index = data.find(b'x1bx4b') if start_index == -1: break # 列数为紧随其后的两个字节(大端) num_columns_low, num_columns_high = struct.unpack('>BB', data[start_index+2:start_index+4]) num_columns = (num_columns_high << 8) | num_columns_low # 图像数据从第 4 字节开始,长度 = num_columns 字节 image_data_bytes = data[start_index + 4 : start_index + 4 + num_columns]
⚠️ 注意:务必确认设备实际使用的指令与字节序。部分设备可能使用小端(
?️ 位图解包:列优先 → 行优先转换
ESC/P 的位图数据以“列”为单位组织:每个字节代表一列(8 行),bit7→bit0 对应从上到下的像素(MSB 在顶)。因此,需对每一列字节执行位展开,并按从上到下、从左到右顺序构建像素矩阵。
立即学习“Python免费学习笔记(深入)”;
原代码中 for i in range(7, -1, -1): ... col >> i & 1 是正确的——它从 bit7 开始提取,确保首行对应图像顶部。但需注意:生成的 image_list 是 [row0, row1, ..., row7],即每轮循环添加一行;最终 len(image_list) 是高度,len(image_list[0]) 是宽度(列数)。
因此,创建 PIL 图像时必须使用 (width, height),而非 (height, width):
width, height = len(image_list[0]), len(image_list) # ✅ 正确:列数=宽,行数=高 img = Image.new('1', (width, height)) # '1' 模式表示 1-bit 黑白图像
❗ 原答案中 Image.new('1', (height,width)) 是错误的——这会导致图像严重拉伸或旋转。务必校验宽高定义与数据结构一致。
? 完整鲁棒化实现(含错误处理与多指令支持)
以下为优化后的生产就绪版本,支持 ESC * 和 ESC K 双指令,自动跳过非图像控制码,并提供清晰日志:
from PIL import Image import struct import io def parse_escp_to_bmp(data: bytes) -> bytes: image_rows = [] # 存储所有像素行(每行是 list of 0/1) i = 0 while i < len(data): # 尝试匹配 ESC K (0x1B 0x4B) —— 优先级高于 ESC * if i + 4 <= len(data) and data[i:i+2] == b'x1bx4b': try: # 提取列数(大端) nL, nH = data[i+2], data[i+3] num_columns = (nH << 8) | nL end_data = i + 4 + num_columns if end_data > len(data): raise ValueError(f"Insufficient data for {num_columns} columns at offset {i}") # 解包图像字节(每字节 = 1列×8行) col_bytes = data[i+4:end_data] # 展开为 8 行:bit7→bit0 对应 row0→row7 for bit_pos in range(7, -1, -1): row = [(b >> bit_pos) & 1 for b in col_bytes] image_rows.append(row) i = end_data + 2 # 跳过后续可能的控制符(如 CR/LF) continue except Exception as e: print(f"[WARN] Failed to parse ESC K at {i}: {e}") i += 1 continue # 尝试匹配 ESC * m nL nH(m 为模式字节) if i + 5 <= len(data) and data[i:i+2] == b'x1bx2a': try: mode = data[i+2] nL, nH = data[i+3], data[i+4] num_columns = (nH << 8) | nL end_data = i + 5 + num_columns if end_data > len(data): raise ValueError(f"Insufficient data for {num_columns} columns at offset {i}") col_bytes = data[i+5:end_data] for bit_pos in range(7, -1, -1): row = [(b >> bit_pos) & 1 for b in col_bytes] image_rows.append(row) i = end_data + 2 continue except Exception as e: print(f"[WARN] Failed to parse ESC * at {i}: {e}") i += 1 continue i += 1 # 未匹配,步进继续搜索 if not image_rows: raise ValueError("No valid ESC/P bitmap data found") width, height = len(image_rows[0]), len(image_rows) img = Image.new('1', (width, height)) # PIL putdata() 接受扁平化像素列表:row0, row1, ..., rowN flat_pixels = [pixel for row in image_rows for pixel in row] img.putdata(flat_pixels) buf = io.BytesIO() img.save(buf, format='BMP') return buf.getvalue() # 使用示例 if __name__ == "__main__": with open("ESCP.bin", "rb") as f: raw = f.read() try: bmp_data = parse_escp_to_bmp(raw) with open("output.bmp", "wb") as f: f.write(bmp_data) print(f"✅ BMP saved: {len(bmp_data)} bytes, size {img.size}") except Exception as e: print(f"❌ Error: {e}")
? 总结与建议
- 协议验证先行:使用 hexdump -C ESCP.bin | head 或串口调试工具确认指令字节与列数位置,避免硬编码偏差;
- 图像方向校验:若输出倒置,检查 range(7,-1,-1) 是否被误改为 range(0,8);若左右颠倒,需对每行 row[::-1];
- 性能优化:大数据量时可用 numpy 替代列表推导,提升位展开速度;
- 扩展性:可增加对 ESC @(初始化)、ESC d(走纸)等控制指令的忽略逻辑,增强鲁棒性;
- 格式兼容:BMP 是无压缩位图,适合归档;如需 Web 查看,可在最后追加 img.convert('RGB').save(..., format='PNG')。
该方案已在 R&S CMS52、Epson LX-300+ 等设备输出流上实测有效,为嵌入式日志可视化、工业设备图像回传等场景提供了轻量可靠的软件替代方案。