☰
Python图片数据集制作:h5py高效封装HDF5存储实践
2026/10/15 15:28:29 网站建设 项目流程

1. 项目概述

1.1 为什么不用文件夹存图,偏要折腾 h5py

先把这个事情说透。做图片数据集这件事,十个新手九个第一反应是:建个文件夹,把图片往里头一丢,训练的时候按路径读不就行了?这个方案对于几百张图、随便跑跑实验确实没问题。但等你到了几千上万张图,尤其是那种单张几MB的高分辨率图,你会发现每次训练光读文件就要卡很久,磁盘IO一直在100%转悠,GPU在那儿干等。这就是典型的“数据喂不饱模型”。

h5py这个库,底层封装的是HDF5格式。你可以把它理解成一个自带索引的超大号字典,数据以二进制块的形式存在一个文件里。打开这个文件、读取某个key下的numpy数组,速度比从分散的小文件里一张一张读要快得多。而且HDF5支持压缩、分块存储、局部切片读取,你不用把整个数据集load进内存,按索引取batch的时候,性能优势非常明显。

这次这个项目的标题是“python:从零开始的图片h5py数据集制作”,核心就一句话:把你手头乱七八糟的图片文件夹,整理成一个规范的、可直接喂给深度学习训练脚本的.h5文件。整个流程涉及图片扫描、数据预处理、标签对齐、h5写入、读取验证这几个环节。文章适合正在做图像分类、目标检测、迁移学习,或者被数据读取速度折磨到挠头的开发者参考。

1.2 要做出什么样的成品

先明确目标,不然做着做着就跑偏了。

最终产出一个.h5文件,文件内至少包含两个核心数据集(Dataset):

  • 图像数据,shape是(N, H, W, C),N是图片总数,H和W是统一后的尺寸,C是通道数,dtype推荐uint8或者float32。
  • 对应的标签,shape是(N,),dtype依据分类数量选择int32或int64。

另外,建议在文件里再存一份元信息(Attribute),记录图片尺寸、通道数、类别映射、样本量这些信息。这样后续写训练脚本的时候,直接从这个h5文件里读参数,不用再回头翻你的原始数据脚本,省心很多。

这就是一个标准的数据集封装形态,你可以把它想成是给图片数据做了一次打包发货,训练的时候只需要拆包裹,不用再去仓库里翻箱倒柜找每一件货。

2. 环境准备与前置知识

2.1 装什么库

需要的东西很简单:python环境、h5py、图像读取库。推荐用Pillow,如果你要处理的是遥感影像或者医学图像这种特殊格式,可以换成OpenCV或者专用库,但核心流程一致。

pip install h5py pillow numpy

我用的是python 3.10环境,h5py版本3.8以上。如果你在linux服务器上操作,建议用虚拟环境,别把系统python弄乱了。Windows用户需要注意一点:h5py的安装通常没问题,但如果你自己编译过HDF5库,版本冲突的概率会大一些,直接pip装的轮子一般是最省事的。

2.2 HDF5的几个核心概念先过一遍

第一次接触h5py的读者,别急着写代码,先理解三个概念。

第一个是Dataset,你可以理解为numpy数组在磁盘上的映射。创建一个dataset的时候要指定shape和dtype,它支持通过索引切片来读写,就像操作一个硬盘上的numpy数组。

第二个是Group,相当于文件夹。Group可以嵌套,可以在group下创建dataset,也可以给group添加属性。我们用group来组织数据集的结构,比如把训练集和测试集放到不同的group下。

第三个是Attribute,是挂在group或dataset上的元数据,用来存一些全局信息,比如类别名列表、图片尺寸、创建时间。Attribute的读写速度很快,适合存小体积的描述性信息。

这三个概念搞清楚了,后面写代码就不会迷路。

3. 图片数据集制作流程详解

3.1 第一步:设计目录结构与扫描图片

先说说输入数据长什么样。我自己的习惯是,训练集和验证集分目录存放,每个类别一个子文件夹,类别文件夹的名字就是标签名:

data/ |-- train/ | |-- cat/ | | |-- cat_001.jpg | | |-- cat_002.jpg | |-- dog/ | | |-- dog_001.jpg |-- val/ | |-- cat/ | | |-- cat_101.jpg | |-- dog/

扫描图片的时候,注意用pathlib代替os.path,代码更简洁,路径跨平台也没毛病。扫描逻辑里有一个坑:子目录中混入了非图片文件,比如.DS_Store、缩略图缓存、隐藏文件,如果不做过滤,后面读图会直接报错。

这里给一个扫描函数,支持按扩展名过滤:

from pathlib import Path IMG_EXTENSIONS = ('.jpg', '.jpeg', '.png', '.bmp', '.tif', '.tiff') def scan_images(root_dir): root = Path(root_dir) samples = [] label_names = [] for label_dir in sorted(root.iterdir()): if not label_dir.is_dir(): continue label_names.append(label_dir.name) for img_path in sorted(label_dir.rglob('*')): if img_path.suffix.lower() in IMG_EXTENSIONS: samples.append((str(img_path), len(label_names) - 1)) return samples, label_names

这段代码做了几件事:遍历每个类别目录,给每个类别分配一个从0开始的数字标签,把(图片路径,标签)的元组收集到列表里。label_names列表的下标就是标签编号,这个对应关系后面要存到h5的attribute里。

3.2 第二步:预处理细节与加载器设计

扫描完毕,接下来是图片读取和预处理。重点说两个容易踩坑的地方。

第一,图片统一尺寸。我建议在制作数据集阶段就把尺寸固定下来,这样训练的时候省去resize的耗时。尺寸怎么选?我的经验是结合模型输入尺寸和数据集实际情况折中。常见的是256x256或224x224,这是很多分类网络的默认输入。如果原始图宽高比差异很大,直接resize会变形,可以用Image.thumbnail+paste到黑色背景的方式做letterbox。但说实话,对于分类任务,直接用resize变形的影响没那么大;对于检测任务影响较大,需要换方案。

第二,颜色通道问题。Pillow打开图片默认是RGB,但是有些图片带透明通道,RGBA模式转RGB要处理好;灰度图也要转成RGB,保持通道数统一。这里给出核心代码:

import numpy as np from PIL import Image def load_and_preprocess(img_path, target_size=(256, 256)): img = Image.open(img_path) # 统一转换 RGB,防止后续通道不一致 if img.mode != 'RGB': img = img.convert('RGB') img = img.resize(target_size, Image.Resampling.LANCZOS) arr = np.asarray(img, dtype=np.uint8) return arr

第三个要注意的坑是图片损坏。一张坏图可能导致整个数据集制作流程中断。写一个跳过损坏图片的版本:

def load_and_preprocess(img_path, target_size=(256, 256)): try: img = Image.open(img_path) img.load() if img.mode != 'RGB': img = img.convert('RGB') img = img.resize(target_size, Image.Resampling.LANCZOS) return np.asarray(img, dtype=np.uint8) except Exception: return None

加载函数返回None的时候,上层逻辑直接跳过这张图,并打印警告,记录失败数量。这种做法看起来有点粗暴,但实际能救你很多次。

3.3 第三步:h5文件写入的核心框架

到了最关键的部分。创建一个h5文件并写入数据,代码本身不难,难点在于写完后文件是否损坏、数据是否对齐、写入速度是否能接受。

直接给出完整流程:

import h5py import numpy as np from tqdm import tqdm train_samples, train_labels = scan_images('data/train') val_samples, val_labels = scan_images('data/val') img_size = 256 num_channels = 3 with h5py.File('image_dataset.h5', 'w') as f: # 创建group,按train/val组织 train_group = f.create_group('train') val_group = f.create_group('val') # 创建dataset,指定shape和dtype train_imgs = train_group.create_dataset( 'images', shape=(len(train_samples), img_size, img_size, num_channels), dtype=np.uint8, compression='gzip', compression_opts=4, chunks=(64, img_size, img_size, num_channels) ) train_lbls = train_group.create_dataset( 'labels', shape=(len(train_samples),), dtype=np.int32 ) val_imgs = val_group.create_dataset( 'images', shape=(len(val_samples), img_size, img_size, num_channels), dtype=np.uint8, compression='gzip', compression_opts=4, chunks=(64, img_size, img_size, num_channels) ) val_lbls = val_group.create_dataset( 'labels', shape=(len(val_samples),), dtype=np.int32 ) # 写入训练数据 for i, (img_path, label) in enumerate(tqdm(train_samples, desc='train')): arr = load_and_preprocess(img_path) if arr is None: print(f"[warn] skip broken image: {img_path}") continue train_imgs[i] = arr train_lbls[i] = label # 写入验证数据 for i, (img_path, label) in enumerate(tqdm(val_samples, desc='val')): arr = load_and_preprocess(img_path) if arr is None: print(f"[warn] skip broken image: {img_path}") continue val_imgs[i] = arr val_lbls[i] = label # 写入元信息 f.attrs['img_size'] = img_size f.attrs['num_channels'] = num_channels f.attrs['num_train'] = len(train_samples) f.attrs['num_val'] = len(val_samples) train_group.attrs['label_names'] = train_labels val_group.attrs['label_names'] = val_labels

这里有几个细节需要解释一下。

compression='gzip',这是HDF5自带的压缩方案。设置压缩后文件体积会小很多,但写入耗时和读取耗时都会增加。如果磁盘不吃紧、训练时读取带宽是瓶颈,你可以考虑不压缩,或者用lzf压缩,速度更快但压缩率略低。压缩级别compression_opts=4是经过权衡的,gzip级别1到9,4到5是推荐档位,再高压缩率上不去多少,时间倒是成倍涨。

chunks参数,这个是分块存储的意思。当你的数据集特别大、无法一次性全放内存时,分块存储可以按需读取。这里设置成每64张图一块,读取时就能按64张为单位从磁盘加载,不用把整个文件读进内存。注意:分块大小和你后面训练时读取的batch size最好对齐,我习惯上让chunk的第一个维度和batch size相等或成整数倍关系,节省很多IO开销。

3.4 第四步:考虑部分图片损坏的情况

代码里如果遇到坏图,continue跳过,但dataset的shape是按总样本数预先定义的,跳过一张图会导致该索引位置没有被写入,后面读取的时候就是全零数据,模型训练直接被污染。这是很要命的。

我的建议是,在制作数据集之前先做一次清洗。把所有图片先全部过一遍加载函数,发现坏图就打印日志并记录路径,然后从train_samples里剔除。清洗之后再进行写入,这时候理论上不会再遇到坏图。

def clean_samples(samples): valid_samples = [] for img_path, label in tqdm(samples): arr = load_and_preprocess(img_path) if arr is not None: valid_samples.append((img_path, label)) else: print(f"skip: {img_path}") return valid_samples

这样做确实多花一遍读取时间,但比起训练到一半猛然发现数据缺了一块,这点时间花得非常值。数据集的完整性,这是一个必须较真的细节。

3.5 第五步:写完后校验

文件写完了,别急着关。先重新打开验证一下:

  • dataset的shape是否正确
  • 抽样几张图片,和原图对比,检查色值、尺寸、通道数是否一致
  • 标签是否按顺序对齐
  • attribute是否读取正常
with h5py.File('image_dataset.h5', 'r') as f: print('keys:', list(f.keys())) print('img_size:', f.attrs['img_size']) train_imgs = f['train']['images'] print('train shape:', train_imgs.shape, train_imgs.dtype) print('labels:', f['train']['labels'][:10]) # 抽样比对 from PIL import Image idx = 0 orig = np.asarray(Image.open(train_samples[idx][0]).resize((256, 256), Image.Resampling.LANCZOS).convert('RGB')) h5_img = train_imgs[idx] print('max diff:', np.abs(orig.astype(np.int16) - h5_img.astype(np.int16)).max())

如果max diff是0,说明写入完全无损。如果有非零值,多半是预处理函数里做了什么归一化操作,或者Image.open之后操作顺序不对,检查一下逻辑。

4. 制作过程中最常见的翻车现场

4.1 数据集大小预计错误

初学者经常会犯一个错误:想当然地认为一张2MB的jpg图,转成numpy数组后还是2MB。实际上,一张256x256x3的RGB图,在内存里占256x256x3=196KB。如果原始图是4096x4096,转成数组占48MB。一万张这种图,就是480GB,直接爆内存。所以统一尺寸这个步骤不仅是给模型准备输入,也是合理控制数据集体积的必要手段。

解决办法是:先扫一遍所有图,抽样算一下转换后的平均体积,再估算总数据量的量级。如果总体积太大,考虑缩小目标尺寸、放弃压缩,或者分批写入。

4.2 直接赋值整个数组,内存直接爆炸

刚入门的时候,我试过把所有图片一次性读进列表,再转成一个大数组写入h5。这种写法在几百张图时没什么问题,到几万张图就废了,内存必然不够。正确的做法是边读边写,每次只处理一张图,让内存占用保持在恒定低位。上面的示例代码用的就是这种方法。

4.3 多进程加速的坑

有人会想,python太慢了,图片预处理用multiprocessing加速一下。方向是对的,但要注意:大量小文件的高并发读取,瓶颈通常不是CPU,而是磁盘IO。用8个进程同时读机械硬盘,效果不仅没有提升,反而会因为磁头频繁寻道变得更慢。SSD上会好一些。另外一个隐藏问题是子进程的开销和数据的顺序维护,处理不好容易顺序错乱。如果真想加速,建议先做一次小规模测试,对比单进程和多进程的耗时,别盲目上。

4.4 大量小图片的场景

如果你的任务涉及大量小图片(比如几百像素的小图标),可以一次性把所有图读进内存再写入,因为总数据量不大。但我更推荐统一走边读边写的流程,只为保持代码习惯的一致性。未来数据规模变大时,你不需要回来改代码。

4.5 为什么文件比你想象的大

压缩没生效,可能原因有几种:

  • 图片本身已经是JPEG压缩格式,再压缩的收益不大,压缩率不高是正常现象。
  • compression参数没设置,或设置成None。
  • 图片数组里都是噪声像素,gzip对这种数据的压缩效果很差。
  • 没有设置chunks,HDF5需要chunked布局才能启用压缩。

训练的时候如果发现每次读取都要解压,速度慢,就把压缩去掉,用体积换速度。特别是当你的磁盘是SSD、文件不是特别大时,读取速度更重要。

5. 训练时的读取方法与性能优化

5.1 基础读取:完整加载到内存

数据量不大(比如几万张缩略图)时,训练前一次性把整个h5文件读进内存,转成numpy数组,用起来最省事。

with h5py.File('image_dataset.h5', 'r') as f: X_train = f['train']['images'][:] y_train = f['train']['labels'][:]

注意[:]这个切片操作,它会读取整个dataset到内存。如果你的数据量大到超过内存,这套方式直接不能用了,改成用索引按batch读取。

5.2 按batch读取配合数据增强

对于大规模数据,推荐配合PyTorch的自定义Dataset来按需读取。在__getitem__里传入索引,用h5py文件对象的切片操作读取对应数据,再做数据增强。

import h5py from torch.utils.data import Dataset class H5Dataset(Dataset): def __init__(self, h5_path, split='train', transform=None): self.h5_file = h5py.File(h5_path, 'r') self.images = self.h5_file[split]['images'] self.labels = self.h5_file[split]['labels'] self.transform = transform def __len__(self): return len(self.labels) def __getitem__(self, idx): img = self.images[idx] # (H, W, C) label = self.labels[idx] if self.transform: img = self.transform(img) return img, label

这里有一个关键点:h5py的切片返回的是numpy数组,self.images[idx]即使索引是一个整数,返回的shape是(H, W, C),正好是单张图片。如果要做PIL的增强操作,先转PIL Image再做transform。

5.3 多进程DataLoader的兼容性问题

自定义Dataset配合DataLoader的num_workers>0时,需要注意h5py文件对象能否被pickle序列化。实测在很多环境下,直接把h5py文件对象作为成员变量传给多进程DataLoader,进程会直接报错或者数据读取异常。我的建议是:在底层用懒加载的方式打开h5文件,比如在__init__里只保存路径,在__getitem__里用with h5py.File(...)打开,读完关闭。代价是每次读图都要打开关闭文件,但因为HDF5文件打开速度很快,实际损失可以接受。

5.4 压榨读取性能的进阶选项

如果数据集更大,一个h5文件几十GB,随机读取的IO压力会变大。提升读取性能的办法有:

  • 把chunk的第一个维度和batch size对齐,避免一次读入过多不需要的数据。
  • 考虑按类别或按训练轮次排序数据,让局部性更友好。
  • 在多个磁盘上复制多个数据集文件副本,配合多进程训练时分散读取压力。
  • 把数据分成多个h5文件,比如每5000张一个文件,训练时多进程各读各的文件。

这些属于优化手段,不是一开始就要做的。如果你的数据集只有一两个GB,先把标准流程跑通,再谈优化。

6. 扩展玩法:数据集的动态更新与多模态扩展

6.1 在已有h5文件上追加数据

数据不是死东西,实际项目里经常发现原始数据集缺类别、缺样本,需要追加。HDF5的dataset在做初始定义时完全确定shape,追加数据有两种思路:

  • 在原地不动的条件下,给dataset预留一部分空间,maxshape=(None, ...),留好扩展余地。写入时先写一部分,后续用.resize()扩大真实使用区域。
  • 直接新建一个更大的h5文件,把旧文件读出来写进去。简单粗暴,但需要额外的磁盘空间和时间。

如果你预计数据会持续增加,从一开始就使用可扩展shape比较好。把创建dataset的方式改为:

train_imgs = train_group.create_dataset( 'images', shape=(0, img_size, img_size, num_channels), maxshape=(None, img_size, img_size, num_channels), dtype=np.uint8, compression='gzip', chunks=(64, img_size, img_size, num_channels) )

写入时用resize配合索引写入:

current_count = len(train_imgs) train_imgs.resize((current_count + n, img_size, img_size, num_channels)) train_imgs[current_count:] = new_arrays

6.2 多标签分类与多模态数据

h5py的数据组织能力很强。多标签分类时,标签数组的shape可以是(N, num_classes),dtype用uint8,每个位置表示该类别是否存在。

多模态数据(比如图片配文本、图片配数值特征)时,在同一个group下创建多个dataset,分别为图片、文本索引、数值特征。HDF5支持异构数据的存储,读取时按同一个索引取所有模态的数据。

6.3 与tensorflow数据管道的对接

TensorFlow中,可以用tf.data直接读取h5文件,用from_generator把Generator映射到数据集。但我不太推荐这么做,性能瓶颈会出现在生成器和TensorFlow线程之间的GIL竞争上。更稳妥的办法是:先用h5py把数据抽到numpy数组,然后构建tf.data.Dataset.from_tensor_slices。如果数据量超出内存,就分块抽取。

7. 一个更容易上手的替代:用numpy的内存映射

如果你的项目对文件体积不那么敏感,且不太需要压缩和元数据管理,可以直接用numpy提供的np.memmap或np.save/np.savez来存图片数组。思路是:

X = np.memmap('images.bin', dtype=np.uint8, mode='w+', shape=(N, H, W, C))

然后按索引写入图片。这种方式比h5py更简单,但牺牲了压缩、分组、属性管理、跨维度切片等能力。如果你是第一次做数据集,或只是快速验证一个想法,优先选择numpy方案能省不少事。一旦数据规模上了量级,还是老老实实回到h5py,格式优越性会体现得很明显。

不过也要承认,h5py的方案也不是只有优点没有成本。压缩让文件变小,但读取时需要解压;分块存储让随机读取更快,但写文件时耗时增加;元信息管理方便,但对文件结构不熟悉的人维护起来还是有学习成本。要不要用、用到什么程度,本质上是在数据体积、训练速度和开发效率之间取一个平衡。

8. 排错速查表

为了更直接地帮大家排查问题,我把常见的错误现象、原因和解决办法整理成一张表。

现象主要原因解决方案
写入时报“dataset shape mismatch”写入的数组shape和dataset预定义shape不一致检查resize后数组的H、W、C是否和dataset创建时一致
训练时读到全零图片有坏图被跳过但索引位置未填充先做清洗步骤,剔除坏图后再创建数据集
打开h5文件很慢文件碎片多,或数据集巨大且无分块重建文件,设置合理的chunks;或改用lzf压缩
Attribute存字符串乱码直接存中文或特殊字符,编码问题转换为UTF-8编码的bytes,或使用numpy数组字符串类型
多进程DataLoader报错h5py文件对象无法跨进程pickle懒加载文件,每次在getitem里打开和关闭
文件比预期大得多未启用压缩,或图片数组本身不可压缩设置gzip/lzf压缩;如果压不动,就改变存储策略
内存爆掉一次性读取全部数据或图片在内存中转成数组边读边写,控制chunk大小

分享一个小经验:制作数据集时,先在少量样本(比如200张图)上跑通全流程,验证代码正确后再扩大到全量数据。这样做可以避免在大数据集上执行到最后才发现逻辑错误,重新再来非常烧时间。

9. 从零到一的完整流程回顾

结合前面的内容,把完整步骤在脑内走一遍:

  1. 扫描图片目录,建立路径和标签的映射列表。
  2. 清洗数据,剔除损坏图片。
  3. 确定统一尺寸和目标通道数。
  4. 创建h5文件,设计group和dataset结构,定义shape、dtype、压缩和chunk参数。
  5. 边读边写,逐张写入图片数组和标签。
  6. 写入attribute元信息。
  7. 用独立脚本重新打开h5文件,做抽检比对,确认数据写对了。
  8. 在训练脚本中用自定义Dataset封装h5文件的读取逻辑。

你会发现,整个流程的核心难点其实不在h5py这个库本身,而在于:

  • 图片读取和预处理的不确定性
  • 数据对齐与完整性保障
  • 内存和磁盘资源的调度

把这些细节处理干净,一份能长期稳定使用的图片数据集就这么诞生了。

我自己在实际操作中最大的体会是:数据集制作这件事,花在上面的时间永远不亏。一个干净、规范、不用反复修补的数据集,能在接下来整个项目周期里帮你省出数倍的时间。尤其当你从单机实验转向分布式训练,或者要复现论文结果、与别人共享数据时,严谨的数据集格式所带来的收益就更明显了。

最后再分享一个我实测下来很有用的习惯:每次制作完数据集后,顺手生成一份描述文件(txt或json),把数据来源、预处理细节、尺寸选择原因、坑点记录都写下来。月底回头看,你就能清楚地了解历史数据集的结构和每次改动的来龙去脉,不用靠记忆硬撑。这一点看似不起眼,但等你管理十几个数据集版本的时候就会明白它的价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询