☰
MATLAB imageDatastore实战:高效读取图像数据与训练管线全解析
2026/10/3 10:02:10 网站建设 项目流程

搞图像处理或者深度学习训练的朋友,应该都遇到过这个场景:几千上万张图片躺在文件夹里,网上的老教程教你用dir + imread写个循环一口气读进来,结果没跑多久内存直接爆掉。后来换了imageDatastore(),才明白官方一直推荐它是有道理的。这篇文章我会从实际使用角度出发,把imageDatastore()的底层逻辑、核心参数、训练管线衔接到各种踩坑经验完整梳理一遍,适合刚开始接触深度学习数据读取的初学者,也适合已经用了一段时间但想把性能压榨出来的老手参考。

1. 传统读图方式为什么撑不住大规模数据

1.1 dir + imread 循环:内存和时间双重爆炸

我刚入行那会儿,做医学图像分类,第一批数据只有几百张 PNG,用dir函数列出文件路径,然后写个 for 循环imread读进来存到 cell 数组里,再cell2mat拼成一个大矩阵,训练完发现一切正常,当时还觉得这样完全没问题。

但数据量从几百涨到两万张,而且每张图是 1024x1024 的 RGB 图时,问题就来了。一个 1024x1024x3 的 uint8 矩阵,单张占内存 3MB 左右,两万张全部塞进内存就是 60GB。一般工作站根本扛不住。时间上更浪费,因为很多场景下我们并不需要一次性把所有图片都加载进内存,而是每轮训练只取一小批(mini-batch),也就是几十张几十张地读。for 循环全量加载等于把“未来要用的数据”也提前搬进内存,纯属自己给自己找不痛快。

传统方式的第二个问题是不灵活。你想随机打乱顺序、按文件夹自动打标签、只读取某种扩展名的图片,这些逻辑全部要自己手写。手写多了就容易出 bug,比如路径拼接错误、系统分隔符不一致、某些文件读出来是空的等等。实际上一个成熟的数据读取组件,就应该把这些通用逻辑抽象封装好,imageDatastore()做的就是这件事。

1.2 imageSet 其实也不够用

早期 Matlab 有个imageSet函数,也可以管理图片集合。它把所有图片的路径索引一次性构建成一个对象,用read()方法按索引读图,看起来比手写循环规范。但它的实现里仍然需要一个完整的文件清单,当文件数以万计、分布在几百个子目录时,构建imageSet对象本身的内存开销和初始化时间都会明显增长。更尴尬的是它对训练这种需要频繁随机采样、动态增强的场景支持很弱,你需要先read()出单张图,再做缩放、翻转、裁剪,这些操作写在自己的训练循环里会有一大串。

另外,从 MathWorks 官方维护力度来看,imageSet在整个深度学习数据准备体系里已经属于偏边缘的组件,官方文档在很多场景下都明确推荐使用 datastore 系列接口。所以如果你是新项目,直接学imageDatastore()就好,没必要在imageSet上花时间。

1.3 Datastore 的设计思路:用时间换空间

imageDatastore()本质是一个惰性迭代器的实现。所谓惰性,就是“用到哪张才读哪张”。你在创建 datastore 的时候,它只做两件事:扫描你指定的目录、把符合条件的文件路径记录下来。数据本体依然躺在磁盘里,内存里只有一个文件路径列表。

这就像你去自助餐厅,dir + imread的做法是先把所有菜都端到桌子上再开始吃,桌子不够大就崩了;imageDatastore()的做法是建立一条取餐流水线,你需要哪盘就让厨师给你哪盘,桌子永远只放当前这一批。磁盘 I/O 确实多了一些,但内存不会爆。

这种设计的另一个好处是,它可以和 Matlab 的并行计算体系无缝结合。训练时可以在多台机器、多个 worker 之间分配 datastore 的不同分片,每个 worker 只需要知道自己负责哪部分图片,不需要把整个数据集的路径列表复制到每个 worker 里。这也是后面要讲的partition和numpartitions能够高效工作的原因。

2. imageDatastore() 核心用法与参数详解

2.1 一条命令创建 Datastore

最常用的创建方式是指定一个根目录,让它自动扫描子文件夹并按照文件夹名生成标签。

imds = imageDatastore('D:\dataset\train', ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames');

这里的IncludeSubfolders参数很关键。默认情况下imageDatastore只会扫描你指定的那一个文件夹,不会往下层递归。如果你的数据是典型的分类任务目录结构,形如train/cat/a.jpg、train/dog/b.jpg,那就必须设置IncludeSubfolders为true,否则一张图都找不到。

LabelSource设为'foldernames'时,datastore 会自动把当前文件所在文件夹的名字作为该图片的类别标签,并且自动转成categorical类型。如果你并不需要标签,比如只是做无监督特征提取,可以直接省略LabelSource,或者显式设置成'none'。

创建之后,建议第一时间查看两个东西:文件总数和标签分布。

% 查看文件数量 numImages = numel(imds.Files); % 查看每个类别的图片数量 countEachLabel(imds)

Files是一个 N x 1 的 cell 数组,保存了所有图片的绝对路径。countEachLabel返回的是一个 table,列出每个类别标签和对应的图片数量。这两条命令几乎是我每次写数据读取脚本时必写的侦察兵代码,先确认数据进了多少、分布是否均匀,再开始后续操作。

2.2 读取数据的正确姿势

创建好 datastore 之后,读取数据有几种方式,分别对应不同场景。

按索引读单张图,适合调试和可视化:

img = readimage(imds, 1); imshow(img);

批量迭代读取,适合自己写训练循环。这里特别要注意read方法的行为:

while hasdata(imds) data = read(imds); % data 是元胞数组,可能是单张图,也可能是一批图 % 每次 read 会读取 ReadSize 指定数量的文件 end

hasdata判断当前 datastore 是否还有未读的数据。每次调用read,datastore 内部的游标会往前推进ReadSize步。ReadSize默认是 1,也就是每调用一次read只返回一张图。如果你希望减少函数调用开销、提高吞吐量,可以创建时设置。

imds = imageDatastore('D:\dataset\train', ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames', ... 'ReadSize', 32);

ReadSize调大后,每次read会返回一个 32 张图的 cell 数组。我实测下来,在 SSD 环境下把ReadSize调到 16 或者 32,训练数据读取的吞吐量会有可感知的提升,因为减少了反复跨语言接口的调用开销。但也不能无限调大,一次性读 256 张图到内存里,虽然不太会爆,但会让 datastore 的“惰性”优势弱化,自己权衡。

一次读全部,就是一个大坑预警了。readall(imds)会把所有图片全部读入内存并返回一个元胞数组。如果你的数据集只有几百张图,readall很爽;但如果是上万张图,用了readall就回到了第一节说的内存爆炸问题。训练场景下,我不建议你用readall。

打乱顺序,直接调shuffle:

imds = shuffle(imds);

shuffle返回一个新的 datastore,内部自动打乱文件顺序。这里的坑在于,shuffle产生的是新对象,不是原地修改,所以你要重新赋值给变量。并且如果原 datastore 已经read过一部分数据、游标不在起点,shuffle之后会从新的随机顺序重新开始。所以标准做法是每次 epoch 开始前先shuffle一下,防止模型每次都看到相同顺序的样本。

还有一个我特别常用的方法是preview。它可以不改变游标位置、直接看一眼当前要读的数据长什么样。对于检查ReadFcn自定义读取函数是否生效非常管用。

preview(imds)

2.3 关于标签与类别分布

imageDatastore的标签存储在Labels属性里。如果你用LabelSource设为'foldernames',那imds.Labels是一个categorical数组,长度等于文件总数,顺序与imds.Files一一对应。

但这里有一个在类别不均衡时容易踩的坑:imageDatastore对foldernames的处理策略,是按文件夹名称的字母顺序来决定categorical内部类别顺序的。比如你有类别a和b,categorical内部的数字编码就固定是a=1, b=2,这在训练卷积网络时绝大多数情况下没有影响,因为最终会做 one-hot 编码。但当你手动查imds.Labels并且用==去筛选某个类别图片时,一定要确认字符匹配,大小写写错会导致筛选结果为空。

跨类别均衡性检查,推荐用countEachLabel:

tbl = countEachLabel(imds); bar(categorical(tbl.Label), tbl.Count);

如果发现某个类别样本数量特别少,训练前就要考虑数据增强补偿或者类别权重调整,这部分我后面会具体说。

3. 多标签和异构目录结构的处理方案

3.1 当数据不按类别分文件夹时

现实中的项目往往没有那么完美的目录结构。比如我从公开数据集下载的图片全部平铺在同一个文件夹里,文件名是001.jpg到10000.jpg,对应的类别记录在一个labels.csv文件里。这种情况下foldernames方案直接失效,因为所有图片都在同一个目录,不存在“文件夹名即标签”的逻辑。

解决办法是手动指定标签。

% 读取csv中的标签 labelTable = readtable('labels.csv'); % labelTable 包含两列:filename 和 label imds = imageDatastore('D:\dataset\images'); [~, fileNames] = cellfun(@(x) fileparts(x), imds.Files, 'UniformOutput', false); % 根据文件名匹配标签,注意顺序要保持和 imds.Files 一致 [~, idx] = ismember(fileNames, labelTable.filename); imds.Labels = categorical(labelTable.label(idx));

这里的关键是,imds.Labels可以直接赋值,长度必须和imds.Files完全一致。顺序一定要核对好,一旦错位,训练出来的模型就是拿张三的图配李四的标签,整个训练直接报废。这种错位很隐蔽,训练集准确率可能还会正常下降,但验证集永远上不去,排查起来特别费劲。

如果是按文件名序号匹配,还有一个更简单的思路:直接在readtable里按文件名排序,然后再创建 datastore 时用排好序的文件列表。

labelTable = sortrows(readtable('labels.csv'), 'filename'); imds = imageDatastore(fullfile('D:\dataset\images', labelTable.filename)); imds.Labels = categorical(labelTable.label);

使用imageDatastore接受文件列表元胞数组作为输入,这是一个很灵活的特性。你完全可以通过imds.Files拿到全部路径,自己过滤改组后再创建新的 datastore。

3.2 多标签与回归任务

categorical标签本质上是单标签分类。如果任务是多标签分类,比如一张胸部 X 光片同时患有肺炎和肺气肿,或者任务是回归,比如根据图片预测房屋价格或关键点坐标,Labels属性就不够用了。

这种情况下,推荐的做法是不给imageDatastore设置Labels,让它的LabelSource保持'none',然后单独维护一个标签数组,用transform或combine把图像数据和标签绑定成一个新的数据流。

% 图像数据流 imds = imageDatastore('D:\dataset\images'); % 多标签矩阵,每行对应一张图,每个元素是 0 或 1 multiLabels = load('multi_labels.mat').labels; % [numImages x numClasses] % 标签数据流 labelsDs = arrayDatastore(multiLabels); % 组合成单个数据流 combinedDs = combine(imds, labelsDs);

combinedDs是CombinedDatastore对象,每调用一次read,返回一个包含两个元素的元胞数组{image, labels},其中第一个元素是图像矩阵,第二个是multiLabels里对应行。喂给自定义训练循环非常顺手。需要注意combine要求两个数据流的“长度”一致,即图像数量和标签行数必须匹配,否则会报错。

有时候多标签需要传到trainNetwork里,用它自带的训练循环来做。那还需要把多标签矩阵转换成一个特殊的输出形式,Matlab 官方对多标签分类支持得比较绕,我的经验是直接写自定义训练循环更清晰,trainNetwork更适合标准单标签分类。

3.3 文件列表筛选与过滤

有些时候你需要从一堆混合格式的文件里只挑一部分来做实验。imageDatastore本身提供了FileExtensions参数,例如:

imds = imageDatastore('D:\dataset\mixed', ... 'FileExtensions', {'.jpg', '.png'});

这样它只会扫描.jpg和.png,其他格式的图片自动忽略。这个参数在处理目录里混杂.txt、.mat、.DS_Store之类的文件时尤其重要。实测表明,IncludeSubfolders开启后,如果子目录里出现了隐藏文件(比如 macOS 在文件夹里自动生成的.DS_Store),imageDatastore在扫描时会把它当作图片试读,然后报错说文件格式不支持。所以我在跨平台拷贝数据集时,都会提前把这类残留文件清理掉。

如果你想按文件名规则过滤,比如只保留包含'cat'的文件,可以先拿到Files,再用contains筛选,最后重新构造 datastore。

files = imds.Files; selectedIdx = contains(files, 'cat'); catImds = imageDatastore(files(selectedIdx));

有一点要注意:重新构造的 datastore 如果还是从文件名元胞数组创建,它是不会自动生成标签的,你需要再单独设置Labels。除非你保留原来的类别逻辑自己重新映射一遍。

4. 从 imageDatastore 到训练管线的实战衔接

4.1 与 augmentedImageDatastore 配合使用

把imageDatastore直接传给trainNetwork是有可能的,但通常效果不好。因为imageDatastore读出来的图片是原始尺寸,而网络的输入层要求固定大小,比如 224x224x3 或 227x227x3。尺寸不匹配,训练直接报错。标准做法是再包一层augmentedImageDatastore。

imds = imageDatastore('D:\dataset\train', ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames'); % 给训练数据做增强 imageAug = imageDataAugmenter(... 'RandXTranslation', [-10 10], ... 'RandYTranslation', [-10 10], ... 'RandXScale', [0.9 1.1], ... 'RandYScale', [0.9 1.1]); augImds = augmentedImageDatastore([224 224], imds, ... 'DataAugmentation', imageAug);

augmentedImageDatastore的第一个参数是网络期望的输入尺寸。它内部会读取原始图片,执行缩放裁剪,然后再交给网络。第二个参数就是原始imageDatastore。如果不需要做数据增强,把第三、第四个参数去掉即可。验证集和测试集一般不做随机增强。

这个过程中最有价值的点是imageAug的使用。我试过用普通的transform(imds, @(x) imresize(x, [224 224]))来做数据增强,效果很差,原因在于transform对函数的要求是每次读取都执行同样的操作,没有状态记忆;而imageDataAugmenter为了保证随机增强,每次会生成不同的随机几何变换参数,对训练更友好。所以做训练数据增强时,别自己拿transform硬凑,直接用官方数据增强器。

4.2 自定义 ReadFcn 的适用场景与性能真相

imageDatastore有一个很灵活但也容易滥用的参数ReadFcn。你可以自定义文件读取函数,在数据进入内存时做一些特殊处理。

imds = imageDatastore('D:\dataset\train', ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames', ... 'ReadFcn', @(filename) imresize(imread(filename), [224 224]));

这样每次readimage或read出来的图片就已经是 224x224 了。看起来很方便,但我要提醒你,ReadFcn的性能开销比你想象中大得多。因为每次读取数据时 datastore 都会调用这个函数处理原始图像,如果你在函数里做了复杂的预处理,比如滤波、均衡化、多步几何变换,训练速度会明显下降。我当时的习惯是:轻度能接受的预处理放进ReadFcn,重度的几何增强交给augmentedImageDatastore,这样分工最清晰,性能也可控。

另外一个常见坑是,ReadFcn的返回值要和网络输入匹配。如果你的网络输入是单通道灰度图,而imread返回的是 3 通道彩色图,网络会报通道数不一致。此时需要显式转换:

@(filename) imresize(imread(filename), [224 224, 1]);

注意imresize不会改变通道数,只改变空间大小。灰度图imread出来是 HxW,你还需要手动扩充成 HxWxC 的单通道格式,方法很多,比较直接的是调用repmat或者cat(3, img, [], []),实际用的时候建议写成:

@(filename) repmat(imresize(imread(filename), [224 224]), [1 1 1]);

4.3 数据划分的正确打开方式

训练集、验证集、测试集的划分,imageDatastore提供了几个好用的方法。最常见的是splitEachLabel。

[imdsTrain, imdsValidation] = splitEachLabel(imds, 0.8, 'randomized');

第一个参数是原 datastore,第二个参数表示每个类别下保留多少比例(这里 80% 进训练集,剩下的 20% 自动成为验证集),第三个参数'randomized'表示随机抽取,否则默认按顺序取前 80%。

如果是按固定数量而不是比例,可以这样:

[imdsTrain, imdsVal, imdsTest] = splitEachLabel(imds, 100, 50, 'randomized');

这表示每类取前 100 张做训练、50 张做验证、其余做测试。splitEachLabel内部会自动分层,保证每个类别在训练集、验证集里的比例一致。这比手动打乱后取前 N 张要严谨得多。

另一个更底层的划分方式是partition和numpartitions。这对函数主要用于交叉验证和并行训练。

numFolds = 5; [imdsTrain, imdsValidation] = partition(imds, numFolds, 1);

partition(imds, numFolds, index)把整个数据集均匀分成numFolds份,第index份作为验证集,其余合并成训练集。做 K 折交叉验证时,在循环里换个index就行。这个函数同样适用于分布式场景。

还有一个细节我花了不少时间才意识到:数据划分之前,一定要设置随机种子。否则每次运行脚本,同样的代码得到不同的划分结果。

rng(0); [imdsTrain, imdsValidation] = splitEachLabel(imds, 0.8, 'randomized');

只要没有设置rng,即使你写了'randomized',每次生成的数据集划分都不同,这在调试超参数时非常致命。因为你无法判断模型效果差异来自模型改动还是数据划分变化。所以我的习惯是,所有涉及随机操作的脚本第一行就先固定随机种子。

5. 避坑记录与性能优化

5.1 文件路径与扩展名的坑

跨平台路径分隔符问题。在 Linux 和 macOS 上,路径分隔符是/,Windows 是\。如果你在创建 datastore 时硬编码了路径,换了一台机器很可能就跑不通。我的做法是尽量用相对路径或者fullfile拼接路径,比如:

dataDir = fullfile(fileparts(mfilename('fullpath')), 'dataset', 'train'); imds = imageDatastore(dataDir, 'IncludeSubfolders', true, 'LabelSource', 'foldernames');

这样不管项目文件夹挪到哪里,脚本都能正确找到数据。

文件扩展名大小写。imageDatastore的默认扩展名匹配一般是区分大小写的。如果你的数据里有photo.JPG和photo.jpg,而我为了省事在FileExtensions里只写了'.jpg',那么所有大写扩展名的图片都会被忽略,部分图片在训练中消失,模型指标莫名其妙地偏低。为了避免这种隐蔽问题,我遇到不规范的扩展名时,会用movefile之类的方式先统一改成小写。

隐藏文件和系统文件。Windows 下文件夹里可能有Thumbs.db,macOS 有.DS_Store,Linux 偶尔有lost+found。这些文件会被imageDatastore当作图片尝试读取,然后报错。我的习惯是创建 datastore 后先跑一遍:

fs = imds.Files; badFiles = {}; for i = 1:numel(fs) try imread(fs{i}); catch badFiles{end+1} = fs{i}; %#ok end end if ~isempty(badFiles) disp('以下文件读取失败:'); disp(badFiles); end

数据量小的时候这个循环很快,数据量大时可以先把文件列表导出到文本文件,再逐个处理。总之,别让零星几个文件搞挂整个训练任务。

5.2 Datastore 在并行环境中的正确用法

如果你尝试在parfor或spmd里直接共享同一个 datastore,大概率会出问题。因为 datastore 内部游标状态无法在多 worker 之间同步。每个 worker 各自复制一个 datastore 的话,它们都会从头开始读,相当于每个 worker 重复读整份数据,完全丧失数据分配的意义。

正确做法是用partition把数据流分成互斥的子集,每个 worker 拿一份。

numWorkers = 8; parfor workerIdx = 1:numWorkers imdsPart = partition(imds, numWorkers, workerIdx); % 每个 worker 只读自己负责的那 1/numWorkers 图片 end

在单机多线程环境中,这种方式可以让每个 worker 读取各自独立的数据源,避免游标竞争和重复读取。具体到深度学习训练,如果你自己写并行训练循环(比如用parfeval做异步数据加载),这个模式非常实用。如果你只是用trainNetwork,那不需要手动处理parfor,因为内部已经帮你做了并行化。

另外一个容易被忽视的点:partition出来的子集在数据量不整除时会自动调整,不用担心中间有些 worker 被分配空数据。但如果你的应用场景对子集大小有严格要求,最好用splitEachLabel先从原数据里精确划分出各个子集,再对每个子集做partition。

5.3 性能调优:把时间花在刀刃上

说到性能,先看数据流完整链条:磁盘读取 -> 解码 -> 缩放/增强 -> 送入训练。普遍的经验是,如果用的是固态硬盘,磁盘读取本身的延迟很低,瓶颈通常在图像解码和增强。而解码时间取决于图片格式与像素总量,同一张图 PNG 解码比 JPEG 慢得多。所以如果你的数据已经预处理成 PNG 无损格式,集内又包含大量高分辨率大图,训练时imageDatastore的 read 会成为明显瓶颈。

有几个实操思路可以明显改善:

  • 离线把图片统一缩放到训练所需的尺寸附近,不要每轮训练都去读原始超清大图再 resize。离线缩放用imresize写一个批量脚本,生成一个新的 jpg 数据集,训练时读取开销会小一个数量级。
  • 把 PNG 批量转成 JPEG。同样的视觉信息量,JPEG 文件体积小,解码快,内存占用低。只有对无损精度要求极高的任务才保留 PNG。
  • ReadSize调大,这个前面提过,每次read取 16~32 张,减少函数调用开销。
  • 有一种情况需要反向操作:如果你的图片很小,比如几十 KB,磁盘读取反而可能成为瓶颈,因为 seek 时间占比增加。此时可以考虑用小文件打包工具把图片打包成一个 HDF5 或 MAT 文件,虽然失去了 datastore 的灵活性,但性能提升非常明显。我的原则是:数据集总量超过 50GB 时,优先考虑 HDF5 方案;总量不大时,datastore 完全够用。

5.4 调试技巧:用 preview 和 read 排查数据问题

训练前必须确认数据流里的图片和标签是对的。preview只能看第一个样本,如果你想看某个特定索引的样本,用readimage配合imshow:

figure; for k = 1:6 subplot(2,3,k); imshow(readimage(imds, k)); title(string(imds.Labels(k))); end

这个脚本我在每次换新数据集时都会跑一遍,确认图像内容与标签匹配。有时候你以为数据没问题,其实某些文件夹里的图片和标签完全是反的。这种错误如果不自查,训练一万轮也发现不了。还有一个小技巧:在自定义ReadFcn里加一行disp(filename),可以快速看到 datastore 实际读取了哪些文件,排查路径匹配问题非常直观。

调试完记得删掉那行disp,否则正式训练时控制台会被刷屏,影响心情也影响性能。

写在最后

从最初几百张图就内存爆炸,到现在几万张图用imageDatastore配合augmentedImageDatastore轻松跑完整个训练流程,这个函数确实解决了我工作中最头疼的数据读取问题。它最让我欣赏的地方是,把想象中琐碎的文件扫描、标签映射、批次读取全部封装成了统一接口,从单机训练到分布式并行,代码改动的成本都很低。经过几轮项目实战,我现在的习惯是把创建 datastore 和划分训练验证集的逻辑封装成一个独立函数,统一管理随机种子、路径分隔符和数据增强参数,这样每次换数据集只需要改一行数据根目录即可。如果你也在用 Matlab 做图像相关的任务,建议花一个下午把imageDatastore的文档和这套实战流程跑一遍,后面节省的时间绝对远超这半天的投入。

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

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

立即咨询