
简介栅格数据是遥感与GIS分析的核心载体但在实际工程中大量历史影像、勘察成果图常以PDF格式封装导致像素矩阵难以直接获取。PDF中的图像并非普通附件而是以XObject形式嵌入页面内容流常规文本解析工具无法触及底层像素。借助PyMuPDF可定位页面内的图像对象并抽取其原始压缩字节再结合Pillow与NumPy完成解码、位深识别和数组维度转换从而还原真正的Raster数值矩阵。针对16bit高精度栅格、多页PDF批量处理、内存边界等工程痛点本文给出从抽取、转换到验证的完整技术路径并支持与GeoTIFF等栅格格式衔接为GIS分析、文档自动化、遥感预处理等场景提供可靠的数据底座。1. 读取Raster像素值PDF里那张图才是交付物拿到一个带.pdf后缀的勘察报告、成果图或论文附件时最常见的两个操作是“看”和“读”。“看”用阅读器就能完成“读”则要拿到Raster背后的像素值矩阵。很多人卡在第一步把PDF当普通文档解摘出来的是文字而不是图等把图导出成PNG再读又发现像素值被压缩或重采样过。读取Raster像素值这件事核心其实有两步——先搞清楚PDF内嵌的是不是真正可解析的Raster对象再用Python把像素数组原样取出来。这篇文讲的就是从PDF到像素值矩阵的完整路径适合GIS、遥感、文档处理和自动化测试方向的工程师尤其适合手上有一批PDF格式老图但需要批量做像元级分析的场景。2. 先把PDF里的Raster抽出来用 PyMuPDF 定位页面图像对象PDF 里的图片并不像src属性那样挂在页面上而是以 XObject 的形式存在于页面资源字典中。直接读文本、用 pdfplumber 提取字符都拿不到像素矩阵。这一章先把结构说清楚再给出用 PyMuPDF 定位 Raster 的最小代码。2.1 为什么 PDF 里的 Raster 不能直接读页面内容流与图像 XObjectPDF 页面内容是一段 PDF 指令流里面Do操作符负责把某个图像 XObject“画”到页面上。XObject 本身又指向 PDF 文件内的对象偏移对象里存的是图像数据和元信息。普通解析库把Do当作版面元素不会深入对象层去解码像素流PDF 阅读器渲染时会调用底层的图像解码器但不会把解码后的像素矩阵暴露给上层 API。所以读 PDF 里 Raster 像素值的第一步不是“读取”而是“定位”。需要按 页面 → 资源字典 → XObject → 图像流 的顺序层层下钻拿到图像的原始压缩字节再交给图像库解码。PyMuPDF 的page.get_images()专门做这件事返回该页所有图像 XObject 的引用信息包括 xref交叉引用编号、宽高、位深和颜色空间。知道这些字段就能判断这张 Raster 是什么格式的原始数据以及解码后像素矩阵的形态。2.2 用page.get_images(fullTrue)列出页面上的 Raster 对象PyMuPDF 里get_images返回的是列表每个元素至少包含 7 个字段xref, smask, width, height, bpc, colorspace, alt.colorspace。其中smask不是蒙版路径而是“软遮罩”对象编号用于表示透明通道bpc是每个 band 的位深colorspace是颜色空间引用编号这个数字本身没有意义需要用doc.xref_object(colorspace)去查具体类型。一个页面可能有多张图背景图、图标、嵌入地图都算。做 Raster 像素读取时需要先通过宽高或位深初步筛一遍通常真正的栅格成果图尺寸大于 1000×1000颜色深度也不是 GIF 那种 1-bit 或 8-bit 索引色。下面是列出所有图像 XObject 的脚本import fitz # PyMuPDF doc fitz.open(sample.pdf) page doc[0] for img_index, img in enumerate(page.get_images(fullTrue)): xref img[0] smask img[1] w, h img[2], img[3] bpc img[4] colorspace img[5] print(f[{img_index}] xref{xref}, size{w}x{h}, bpc{bpc}, smask{smask}) print(f colorspace ref: {colorspace}) print(f colorspace object: {doc.xref_object(colorspace, compressedFalse)[:200]})这段代码输出的bpc直接决定后续读取时的 dtype 选择。bpc8表示每通道 8 bit数组类型是uint8bpc16表示 16 bit常见于高精度栅格数组类型是uint16。colorspace对象内容如果包含DeviceRGB说明解码结果是三通道如果是DeviceGray则是单通道灰度。先打印出来能减少后面反复试错。2.3filters、colorspace与bpc三个决定后续路径的参数get_images不会直接告诉你压缩格式需要带上xref去取对象内容再解析。doc.xref_object(xref)返回的字符串里包含/Filter字段常见取值是/FlateDecode无损压缩、/DCTDecode即 JPEG、/JPXDecodeJPEG2000。这一步很关键DCTDecode 和 JPXDecode 是有损压缩即使 PDF 里内嵌的是原始栅格解出来的像素值也已经和原始值有偏差只适合做视觉判断不适合做定量分析。colorspace同样不能只看DeviceRGB。有个常见情况是图片本身灰度但 colorspace 标成 DeviceRGB三通道数值相等。处理时如果直接转成灰度图不会丢信息反之如果想保留原 PDF 的颜色语义就要按 colorspace 类型决定是否降维。bpc则和/DecodeParms里的BitsPerComponent对应如果不一致解码会直接报错或产生花屏。2.4 最小抽取示例从 PDF 页面拿到 Raster 对象原始字节定位之后用doc.extract_image(xref)拿到可直接写入文件的图像数据和元信息。返回的字典包括width、height、colorspace、bpc、ext、image。ext是推荐的文件扩展名image是解码后的字节流可以直接交给Pillow打开或直接写磁盘。下面这个函数封装了从 PDF 中抽取所有 Raster 的完整流程from PIL import Image import fitz import io def extract_rasters_from_pdf(pdf_path, page_index0, min_size(500, 500)): doc fitz.open(pdf_path) page doc[page_index] results [] for img in page.get_images(fullTrue): xref img[0] w, h img[2], img[3] if w min_size[0] or h min_size[1]: continue base_info doc.extract_image(xref) pil_img Image.open(io.BytesIO(base_info[image])) info { xref: xref, width: base_info[width], height: base_info[height], colorspace: base_info[colorspace], bpc: base_info[bpc], ext: base_info[ext], pil: pil_img, } results.append(info) print(fextracted xref{xref}: {base_info[width]}x{base_info[height]}, fcolorspace{base_info[colorspace]}, bpc{base_info[bpc]}, ext{base_info[ext]}) doc.close() return resultsextract_image内部会处理解码不需要手动区分 FlateDecode 或 DCTDecode。输出里的width和height要与pil_img.size对照如果两者不一致说明 PDF 对象宽高定义与图像实际解码尺寸有差异要以pil_img.size为准。colorspace是字符串形式如DeviceRGB、DeviceGray可以直接参与判断。bpc是抽取元数据里带的值通常在 1、8、16 三档这个值与后面 NumPy 数组的 dtype 直接相关。3. 像素值读取从 Pillow 到 NumPy 的维度与位深抽出来的PIL.Image对象只是中间态最终目标是拿到 NumPy 数组或者进一步读成带地理信息的栅格。这里最常出问题的两个点是位深和顺序。位深错读出来的极大值、均值全是错的通道顺序错RGB 变 BGR 肉眼不一定立刻发现但波段运算时会出大问题。3.1 band 数与 dtype先搞清楚读出来的是什么Pillow 打开一张图后mode直接说明通道结构L单通道灰度、RGB三通道、RGBA四通道、I;16单通道 16 bit、I;16B大端序 16 bit。mode不直接对应dtype需要显式转换。常见做法是import numpy as np from PIL import Image img Image.open(extracted_0.png) arr np.asarray(img) print(shape:, arr.shape) # (height, width) 或 (height, width, channels) print(dtype:, arr.dtype) # uint8、uint16 或 int32 # 如果是单通道灰度但位深是16确认是否被Pillow当成int32 if arr.dtype np.int32 and img.mode in (I;16, I;16B): arr arr.astype(np.uint16) print(converted to uint16, new dtype:, arr.dtype)用np.asarray直接转 Pillow 图时有个隐藏行为16 bit 灰度图通常会被转成int32因为 Pillow 内部以带符号整数保存这类数据。转成uint16之前先检查数据范围确认最大值不超过 65535避免因符号位导致整体偏移。RGB 图正常情况下来就是uint8shape 为(H, W, 3)RGBA 是(H, W, 4)第 4 个通道是 alpha与栅格分析无关建议直接arr[..., :3]截断。3.2 16bit 与浮点栅格PDF 内嵌 Raster 的隐藏坑PDF 规范里bpc支持 1、2、4、8、16 五档但实际 Java 版 PDFBox 和 PyMuPDF 对 16 bit 支持都不是统一行为。PyMuPDF 抽取 JPEG2000 格式的 16bit 图时Pillow 可能识别为I;16或RGB不同 Pillow 版本有差异。更稳的做法是用tifffile或imageio走一遍原始字节流但多数情况下 Pillow 手动 dtype 转换已经能覆盖。浮点栅格32bit float几乎不会直接内嵌在 PDF 中PDF 规范不支持浮点像素原始存储。如果明确需要浮点精度应该回去找原始 GeoTIFF而不是从 PDF 中逆向。从 PDF 里读出来的数据位深最高只到uint16超过这个范围读不出来是正常的不是代码问题。def arr_to_float_fraction(arr): 把像素值归一到 0~1 区间方便对比不同位深的数据 info np.iinfo(arr.dtype) return arr.astype(np.float32) / float(info.max)这段归一化代码常用于对比 8bit 和 16bit 数据。注意np.iinfo只适用于整数类型对float32数组会报错用之前要判断arr.dtype.kind in iu。3.3 对齐坐标系PDF 坐标与图像像素坐标页面上的某个点(x, y)和像素矩阵的行列不是一回事。PDF 坐标原点在页面左下角x 向右y 向上单位是 Point1/72 inch。图像像素坐标原点在左上角y 向下。如果要把 PDF 上某个经纬度、或者图纸上的某段距离换算回像素位置必须先拿到图像在页面上的显示矩形这个信息在page.get_image_rects(xref)里。rects page.get_image_rects(xref) if rects: r rects[0] print(display rect:, r) # Rect(x0, y0, x1, y1) # 行列与坐标换算 col int((target_x - r.x0) / (r.x1 - r.x0) * arr.shape[1]) row int((r.y1 - target_y) / (r.y1 - r.y0) * arr.shape[0]) print(ftarget({target_x:.1f}, {target_y:.1f}) - pixel({row}, {col}))get_image_rects返回的矩形坐标已经是 PDF 页面的坐标系不需要再考虑 Transform 矩阵。注意同一张图可能被页面多次引用rects里会有多个显示区域这时要遍历所有矩形每个都做一次坐标映射。3.4 封装成函数从 PDF 到像素数组一次到位把前面零散的步骤收拢成一个工具函数返回(array, meta_dict)后面批量处理时直接用import numpy as np import fitz from PIL import Image import io def read_raster_pixels(pdf_path, page_index0, min_width100, min_height100): doc fitz.open(pdf_path) page doc[page_index] rasters [] for img in page.get_images(fullTrue): xref img[0] if img[2] min_width or img[3] min_height: continue info doc.extract_image(xref) pil_image Image.open(io.BytesIO(info[image])) # 统一转成RGB再转数组避免P模式索引异常 if pil_image.mode P: pil_image pil_image.convert(RGBA) arr np.asarray(pil_image) if arr.dtype np.int32 and info[bpc] 16: arr arr.astype(np.uint16) rasters.append({ array: arr, xref: xref, width: info[width], height: info[height], colorspace: info[colorspace], bpc: info[bpc], dtype: str(arr.dtype), }) print(fread xref{xref}: array.shape{arr.shape}, dtype{arr.dtype}) doc.close() return rasters这里对P模式调色板单独做了处理避免索引色图像直接进 NumPy 变成一维索引数组。bpc 为 16 时 Pillow 常常返回int32转换到uint16前不建议先转其他格式否则位深信息会丢失。如果extract_image返回的 bpc 是 16 而colorspace是 DeviceRGB则每个通道都是 16 bit数组 shape 是(H, W, 3)dtype 是uint16这种三通道高精度图像在处理时要注意内存体量翻一倍。4. 批量处理与工程化多页 PDF 与内存边界单页抽取只是热身。嵌入式图集、扫描版报告、分幅栅格子图通常一个 PDF 里有几十页。手写循环遍历页面很容易但真正会拖垮程序的是内存一个 10000×10000 的 RGB uint8 矩阵约 300MB如果一次把所有页全抽出来放内存机器直接卡死。这一章说清楚批量读、只保留分析所需子集的工程做法。4.1 多页批量抽取用 Rust 侧释放内存页面级即时处理PyMuPDF 的doc一旦打开页面对象会占内存page.get_images返回的图像引用列表也要占内存extract_image返回的解码结果更要占。批量处理时建议“用后即弃”import fitz import numpy as np from PIL import Image import io def batch_read_pixels(pdf_path, pagesNone, target_xrefNone, max_side4000): doc fitz.open(pdf_path) if pages is None: pages range(len(doc)) results [] for pno in pages: page doc[pno] for img in page.get_images(fullTrue): xref img[0] if target_xref is not None and xref ! target_xref: continue if img[2] max_side or img[3] max_side: print(fskip {xref}: {img[2]}x{img[3]} exceeds max_side{max_side}) continue info doc.extract_image(xref) arr np.asarray(Image.open(io.BytesIO(info[image]))) # 只向结果列表放小图或关键图 if arr.size 50_000_000: # 大约50MB uint8 results.append((pno, xref, arr)) else: # 大图直接保存不驻留内存 Image.fromarray(arr).save(fpage_{pno}_xref_{xref}.png) del arr, info doc.close() return resultsmax_side参数可以挡住超大底图arr.size 50_000_000这个条件限制列表内存占用。注意这里arr.size是元素个数对 uint8 来说相当于字节数但如果是 uint16实际内存要乘 2判断条件该按arr.nbytes更准确。target_xref配合showpages可以只处理特定图。4.2 与 GeoTIFF 的衔接从 PDF 数组到磁盘栅格文件PDF 里的 Raster 本身没有地理参考。但实际项目里多是从 ArcGIS、QGIS 导出的图坐标信息要么在 PDF 页面的注释里要么文书里带一个坐标对照表。把像素数组写成 GeoTIFF 时需要自己拼接 transformimport rasterio from rasterio.transform import from_origin # 假设已知左上角坐标和地面分辨率 left, top 452700.0, 4423300.0 pixel_size 0.5 # 每像素代表0.5米 transform from_origin(left, top, pixel_size, pixel_size) with rasterio.open( output.tif, w, driverGTiff, heightarr.shape[0], widtharr.shape[1], countarr.shape[2] if arr.ndim 3 else 1, dtypearr.dtype.name, crsEPSG:32650, transformtransform, ) as dst: if arr.ndim 3: dst.write(arr.transpose(2, 0, 1)) # HWC - CHW else: dst.write(arr, 1)from_origin的参数顺序是(左, 上, 像素宽, 像素高)其中水平方向通常为正垂直方向为负因为影像坐标从左上角开始向下增长。对 PDF 场景来说left, top通常不是直接写在 Raster 里的需要从页面上的文字注释或图廓线坐标反算这一步是手工作业。arr.transpose(2, 0, 1)是把 Pillow 风格的 HWC 排列转成 GDAL 风格的 CHW漏掉这一步写出来的 GeoTIFF 长宽对不上。4.3 内存边界与性能该避免的两种写法两个常见错误写法一是把整页做成高分辨率 PNG 再解像素额外多一次有损变换二是doc.extract_image的结果直接存进 List 不释放几页之后内存直接起飞。推荐的写法是用生成器逐页处理、逐图回写不要让results无限增长。page.get_images(fullTrue)不会触发图像解码真正的大开销在extract_image打印日志时只输出 xref 和尺寸不要print图像对象本身。4.4 常见的读取失败原因与兜底策略现象可能原因处理方式extract_image返回空 imagexref 指向的不是图像对象用doc.xref_object(xref)检查类型Pillow 报OSError: cannot identify image file数据流是 JPX 或特殊编码换imageio或cv2.imdecode读出的 shape 全是反的PDF 内嵌图带旋转/翻转标记用page.get_image_rects里的变换矩阵反转arr.dtype是int32且最大值异常Pillow 对 16bit 的封装行为按info[bpc]16转uint16颜色偏色或通道反转内嵌图是 CMYK 未转换在 Pillow 里.convert(RGB)出现抖动显示不是代码问题而是 Pillow 对部分 JPEG2000 流的兼容问题此时直接改用imageio.v3.imread读原始字节多数能解出来。CMYK 转 RGB 不是简单丢通道要用 Pillow 的convert(RGB)内部算法处理颜色映射直接切通道必然偏色。5. 用直方图验证像素值三行代码判断读对没有从 PDF 里读出来的 Raster 像素数组最怕“读出来但读错”。一个快速且可信的验证方式是看像素分布直方图。Raster 数据和照片数据的直方图形状差异很大传感器栅格通常有大量暗区或空值区在 0 处堆积照片则有丰富中间调。看几个关键统计量基本能确认读出来的不是被 PDF 渲染引擎干扰过的图。5.1 用 NumPy 直方图快速确认位深与空值读出来的数组如果最大值正好是 255 或 65535说明大概率是完整数据范围如果最大值是 254、65534且大量像元集中在 0那可能经过了缩放或裁剪。用三行命令看分布import numpy as np arr np.asarray(img) # 从前面任一过程中拿到的数组 if arr.ndim 3: arr arr[..., 0] # 只看第一个band hist, edges np.histogram(arr, bins256) print(max:, arr.max(), min:, arr.min(), std:, arr.std()) print(zero_ratio: {:.2%}.format((arr 0).mean()))zero_ratio是 0 值像素占比。遥感影像中 0 值通常代表背景或 NoData如果占比超过 30%说明数组里包含大量背景区域后续分析前要用arr[arr 0] np.nan掩膜掉。hist里的第 0 个 bin 格外高也佐证这一点。5.2 与页面渲染结果做像素级对比如果怀疑抽取过程引入了偏差可以把 PDF 页面渲染成图片再来一次像素值对比。PyMuPDF 自带渲染接口但要注意渲染会经过抗锯齿和色彩管理所以对比的是整体形态而非逐像素相等import fitz doc fitz.open(sample.pdf) page doc[0] pix page.get_pixmap(dpi72) # 用默认dpi保证尺寸接近原始布局 render_arr np.frombuffer(pix.samples, dtypenp.uint8).reshape(pix.height, pix.width, pix.n) print(render shape:, render_arr.shape)用直方图对比render_arr和抽取的arr如果看整体趋势一致但最大最小值略小是 JPEG 有损压缩导致如果分布完全错位则是 colorspace 处理错误回去检查info[colorspace]。dpi72可以保持与 PDF 页面坐标系一致避免缩放导致尺寸对不上。5.3 一个通用的小验证函数十几行覆盖常见偏差def validate_array(arr, expected_shapeNone, expected_dtypeNone): if expected_shape is not None: assert arr.shape[:2] expected_shape[:2], \ fshape mismatch: {arr.shape} vs {expected_shape} if expected_dtype is not None: assert arr.dtype expected_dtype, \ fdtype mismatch: {arr.dtype} vs {expected_dtype} percentiles np.percentile(arr, [1, 50, 99]) print(fpercentiles: p1{percentiles[0]}, p50{percentiles[1]}, p99{percentiles[2]}) assert percentiles[0] 0, negative values not expected这个函数不查逻辑只做形状、类型和分位数三层校验适合接在批量读取的循环体末尾。p99接近 dtype 最大值时说明图像动态范围合理如果p99远小于最大值优先怀疑图片本来是拉伸过的。读 PDF 里的 Raster 像素值技术难点从来不在 API 调用而在于确认读出来的矩阵能不能放进后续的栅格计算流程。形状、dtype、分位数这三个维度确认无误后面不管是做 NDVI、分类还是统计分析数据地基就是稳的。本文还有配套的精品资源点击获取