0


digit_eye开发记录(1): C++读取MNIST数据集

文章目录

1. 下载 MNIST 数据集

原版数据集在 Yann LeCun 个人主页 https://yann.lecun.com/exdb/mnist/, 目前(2024.11.17)无法下载;作为替代,我从 https://github.com/cvdfoundation/mnist 给出的链接下载:

  • Training images: train-images-idx3-ubyte.gz
  • Training labels: train-labels-idx1-ubyte.gz
  • Testing images: t10k-images-idx3-ubyte.gz
  • Testing labels: t10k-labels-idx1-ubyte.gz

2. IDX 文件格式

mnist 数据集的4个文件,都是 IDX 格式。理解了 IDX 格式,就可以知道怎样解析 MNIST 数据集。

在这里插入图片描述
可以看出, IDX 文件由三部分组成:

  • magic number
  • dimensions
  • data

magic number 占4个字节:

  • 前2个字节:固定维0
  • 第3个字节,表示数据类型,0x08 为 unsigned byte,…
  • 第4个字节,表示数据维度,可能是 1(向量)、2(矩阵)、3(3维张量)等

magic number 之后,是具体的每个维度的大小,有多少个维度就需要解析几个整数。

再之后,是具体的数据,根据它各个维度的大小,以及数据类型(magic第3个字节表达的),能确定数据长度。

3. 解析 magic number

在这里插入图片描述
magic number 这个术语有点误导人,一度让我觉得要解析为具体的数字。但其实只要忠实的读取4个字节即可,不用转为整数。

回到C++:

train-images-idx3-ubyte.gz

是二进制文件,用

std::ifstream

类来读写文件; 声明一个长度为4的char数组,把 magic number 的字节读进来,就这么简单:

    std::ifstream in("train-images.idx3-ubyte", std::ios::binary);char buffer[4];
    in.read(buffer,4);explain_magic(buffer);

其中

explain_magic()

函数, 是对 magic number 文档的“代码翻译”:

voidexplain_magic(char magic_bytes[4]){printf("magic number types: ");for(int i=0; i<4; i++){printf("%d ", magic_bytes[i]);}printf("\n");printf("type of the data: ");switch(magic_bytes[2]){case0x08:printf("unsigned byte\n");break;case0x09:printf("signed byte\n");break;case0x0B:printf("short (2 bytes)\n");break;case0x0C:printf("int (4 bytes)\n");case0x0D:printf("float (4 bytes)\n");case0x0E:printf("double (8 bytes)\n");break;}printf("number of dimensions of the data: ");switch(magic_bytes[3]){case1:printf("1D vector\n");break;case2:printf("2D matrices\n");break;case3:printf("3D cubes\n");break;default:printf("unknown\n");}}

对训练图像,我们得到:

Reading /Users/zz/play/digit_eye/data/train-images.idx3-ubyte
magic number types: 0 0 8 3
type of the data: unsigned byte
number of dimensions of the data: 3D cubes

对训练标签,我们得到:

Reading /Users/zz/play/digit_eye/data/train-labels.idx1-ubyte
magic number types: 0 0 8 1
type of the data: unsigned byte
number of dimensions of the data: 1D vector

而其实 MNIST 数据集的4个文件名字,也暗示了对应的数据维度和类型:

  • train-images-idx3-ubyte.gz 训练图像,3维数据,unsigned byte 类型
  • train-labels-idx1-ubyte.gz 训练标签,1维数据,unsigned byte 类型
  • t10k-images-idx3-ubyte.gz 测试图像,3维数据,unsigned byte 类型
  • t10k-labels-idx1-ubyte.gz 测试标签,1维数据,unsigned byte 类型

4. 解析维度信息

回归一下 IDX 格式文件的构成:

  • magic number
  • dimensions // 本次要解析的内容
  • data

维度信息,指的是 data 的各个维度的大小。有多少个维度?magic numbers 的第4个字节是维度数量;如何解析每个维度大小?先假设

read_int_from_binary(in)

能自动处理。因此代码很直观:

        std::ifstream in(filepath, std::ios::binary);char buffer[4];
        in.read(buffer,4);//explain_magic(buffer);int num_of_dims = buffer[3];for(int i=0; i<num_of_dims; i++){int num =read_int_from_binary(in);printf("num[%d]: %d\n", i, num);}

获得的信息如下:

Reading /Users/zz/play/digit_eye/data/train-images.idx3-ubyte
num[0]: 60000
num[1]: 28
num[2]: 28

Reading /Users/zz/play/digit_eye/data/train-labels.idx1-ubyte
num[0]: 60000

而其中,读取每个维度的大小,细节较多,代码如下:

enumclassEndian{
    LSB =0,
    MSB =1};static Endian get_endian(){unsignedint num =1;char* byte =(char*)&num;if(*byte)return Endian::LSB;elsereturn Endian::MSB;}template<typenameT>inline T swap_endian(T src);template<>inlineintswap_endian<int>(int src){int p1 =(src &0xFF000000)>>24;int p2 =(src &0x00FF0000)>>8;int p3 =(src &0x0000FF00)<<8;int p4 =(src &0x000000FF)<<24;return p1 + p2 + p3 + p4;}staticintread_int_from_binary(std::ifstream& in){static Endian endian =get_endian();int num;
    in.read(reinterpret_cast<char*>(&num),sizeof(num));if(endian == Endian::LSB)
        num =swap_endian(num);return num;}

5. 解析数据

对于图像和标签,由于维度分别是3维和1维,因此需要分别解析。

解析图像数据

对于训练图像来说,60000张图像,每张图像28x28大小。

28 * 28 * 60000 / (1024 * 1024) = 44.86

, 训练图像占据了接近 45MB 的内存。我们考虑内存复用,避免内存拷贝。也就是说,从二进制文件解析出的单个的图像的像素时使用的内存,和最终训练或推理时使用的内存,我们希望是同一块内存。

基本代码实现为:

...// 省略解析 magic number 和 dimensions 的代码char* buffer =(char*)malloc(60000*28*28);// 存储所有图像的 pixel
std::vector<cv::Mat> images;int offset =0;for(int i=0; i<60000; i++){
    offset +=28*28;
    image[i]= cv::Mat(28,28, CV_8UC1, buffer + offset);// 复用 pixel 中的一小部分,来创建 cv::Mat 对象}

而为了释放上述大块内存 buffer, 我们创建

class DataSet

, 析构函数中释放它:

classDataSet{public:...~DataSet(){free(buffer);}private:char* buffer{};...}

解析标签数据

和解析图像数据类似,我们也希望复用内存。由于标签数据只有1维,可以直接用 vector 存储,不需要额外引入

cv::Mat

. 作为类的成员:

// load labels...// 省略读取 magic numberint num_labels =read_int_from_binary(in);
    labels = std::vector<uint8_t>(num_labels);
    in.read(reinterpret_cast<char*>(labels.data()), num_labels);

labels 会在析构函数中,自动处理内存释放

classDataSet{public:...private:...
    std::vector<uint8_t> labels;};

整合后的 DataSet 类

为了方便使用,我们让 DataSet() 构造函数传入图像、标签这两个文件的路径,在构造函数中解析图像、标签的IDX文件。图像的解析,放到单独的函数 load_image() 函数中; 标签的解析,放到单独的 load_label() 函数中。

classDataSet{public:DataSet(const std::string& image_filename,const std::string& label_filename){load_images(image_filename);load_labels(label_filename);}~DataSet(){free(buffer);}public:
    std::vector<cv::Mat> images;
    std::vector<uint8_t> labels;private:char* buffer{};voidload_images(const std::string& filename);voidload_labels(const std::string& filename);};voidDataSet::load_images(const std::string& filename){
    std::ifstream in(filename, std::ios::binary);if(!in.is_open()){
        std::cout <<"failed to open "<< filename <<"\n";return;}char magic_bytes[4];
    in.read(magic_bytes,4);CV_Assert(magic_bytes[2]==0x08);// unsigned byteCV_Assert(magic_bytes[3]==3);// 3D tensorint num_images =read_int_from_binary(in);int rows =read_int_from_binary(in);int cols =read_int_from_binary(in);const size_t buffer_size = num_images * rows * cols;
    buffer =(char*)malloc(buffer_size);
    in.read(buffer, buffer_size);

    images = std::vector<cv::Mat>(num_images);for(int i=0; i<num_images; i++){
        images[i]= cv::Mat(cols, cols, CV_8UC1, buffer + i * cols * cols);}}voidDataSet::load_labels(const std::string& filename){
    std::ifstream in(filename, std::ios::binary);if(!in.is_open()){
        std::cout <<"failed to open "<< filename <<"\n";return;}char magic_bytes[4];
    in.read(magic_bytes,4);CV_Assert(magic_bytes[2]==0x08);// unsigned byteCV_Assert(magic_bytes[3]==1);// 1D vectorint num_labels =read_int_from_binary(in);
    labels = std::vector<uint8_t>(num_labels);
    in.read(reinterpret_cast<char*>(labels.data()), num_labels);}

作为测试,我们读取索引为233的图像和label:

intmain(){const std::string data_dir =get_data_dir();
    DataSet train_set(data_dir +"/train-images.idx3-ubyte", data_dir +"/train-labels.idx1-ubyte");
    DataSet test_set(data_dir +"/t10k-images.idx3-ubyte", data_dir +"/t10k-labels.idx1-ubyte");int idx =233;
    cv::Mat image = train_set.images[idx];uint8_t label = train_set.labels[idx];
    std::cout <<"label: "<<(int)label <<"\n";for(int i=0; i<image.rows; i++){for(int j=0; j<image.cols; j++){printf("%03d ", image.at<uint8_t>(i, j));}printf("\n");}return0;}

在这里插入图片描述

6. 完整代码

#include<string>#include<fstream>#include<opencv2/opencv.hpp>const std::string get_data_dir(){#ifdef_MSC_VERreturn"C:/work/digit_eye/data";#elif__APPLE__return"/Users/zz/play/digit_eye/data";#endif}classDataSet{public:DataSet(const std::string& image_filename,const std::string& label_filename){load_images(image_filename);load_labels(label_filename);}~DataSet(){free(buffer);}public:
    std::vector<cv::Mat> images;
    std::vector<uint8_t> labels;private:char* buffer{};voidload_images(const std::string& filename);voidload_labels(const std::string& filename);};//-----------------------------------------------------------------------------enumclassEndian{
    LSB =0,
    MSB =1};static Endian get_endian(){unsignedint num =1;char* byte =(char*)&num;if(*byte)return Endian::LSB;elsereturn Endian::MSB;}template<typenameT>inline T swap_endian(T src);template<>inlineintswap_endian<int>(int src){int p1 =(src &0xFF000000)>>24;int p2 =(src &0x00FF0000)>>8;int p3 =(src &0x0000FF00)<<8;int p4 =(src &0x000000FF)<<24;return p1 + p2 + p3 + p4;}staticintread_int_from_binary(std::ifstream& in){static Endian endian =get_endian();int num;
    in.read(reinterpret_cast<char*>(&num),sizeof(num));if(endian == Endian::LSB)
        num =swap_endian(num);return num;}voidDataSet::load_images(const std::string& filename){
    std::ifstream in(filename, std::ios::binary);if(!in.is_open()){
        std::cout <<"failed to open "<< filename <<"\n";return;}char magic_bytes[4];
    in.read(magic_bytes,4);CV_Assert(magic_bytes[2]==0x08);// unsigned byteCV_Assert(magic_bytes[3]==3);// 3D tensorint num_images =read_int_from_binary(in);int rows =read_int_from_binary(in);int cols =read_int_from_binary(in);const size_t buffer_size = num_images * rows * cols;
    buffer =(char*)malloc(buffer_size);
    in.read(buffer, buffer_size);

    images = std::vector<cv::Mat>(num_images);for(int i=0; i<num_images; i++){
        images[i]= cv::Mat(cols, cols, CV_8UC1, buffer + i * cols * cols);}}voidDataSet::load_labels(const std::string& filename){
    std::ifstream in(filename, std::ios::binary);if(!in.is_open()){
        std::cout <<"failed to open "<< filename <<"\n";return;}char magic_bytes[4];
    in.read(magic_bytes,4);CV_Assert(magic_bytes[2]==0x08);// unsigned byteCV_Assert(magic_bytes[3]==1);// 1D vectorint num_labels =read_int_from_binary(in);
    labels = std::vector<uint8_t>(num_labels);
    in.read(reinterpret_cast<char*>(labels.data()), num_labels);}intmain(){const std::string data_dir =get_data_dir();
    DataSet train_set(data_dir +"/train-images.idx3-ubyte", data_dir +"/train-labels.idx1-ubyte");
    DataSet test_set(data_dir +"/t10k-images.idx3-ubyte", data_dir +"/t10k-labels.idx1-ubyte");int idx =233;
    cv::Mat image = train_set.images[idx];uint8_t label = train_set.labels[idx];
    std::cout <<"label: "<<(int)label <<"\n";for(int i=0; i<image.rows; i++){for(int j=0; j<image.cols; j++){printf("%03d ", image.at<uint8_t>(i, j));}printf("\n");}return0;}

7. 总结和思考

本文给出了 MNIST 数据集的格式说明和 C++ 解析代码实现,首先解读了 IDX 文件的格式,由 magic number、dimensions、data 3部分组成; 然后分析了 magic number 的构成:是4个byte:0, 0,数据类型,维度数量, 也用 C++ 做了实现。接下来根据维度数量,使用 C++ 解析了各个维度的大小,主要代码量在于 MSB 下的 int 转为 LSB 下的 int。最后是数据的解析,包括图像和label的解析,考虑到了内存复用问题,使用了 OOP 的方式来封装整块内存,以及每个图像用 OpenCV 的 cv::Mat 表示时,像素是整块内存中的一小块,标签数据则放在 vector 中。最后的最后,打印了相同索引的 图像 和 标签,验证了数据的正确性。

解析MNIST的代码,写过很多次了,之前一直忽略了 magic number 的具体含义,虽然对于 MNIST 数据集本身来说确实可以忽略,但其实 IDX3 格式确实可以用来存储图像数据,而如果我们想避免依赖图像编解码库,比如外部依赖库仅考虑 opencv_core 模块,使用 IDX3 不失为一种可选方案。

8*. 扩展:保存自己的 IDX 格式数据

举一个极端例子:原本的 MNIST 的训练图像数据、训练标签数据,各自有60000样本;现在,我们只保存第一个样本,保存为 IDX 格式。

根据 IDX 3格式,以及 MNIST 图像的大小,我们可以算出新保存的图像 IDX 文件大小:

  • magic number:4个字节
  • dimensions:3个数字,每个4字节
  • 数据:一张图, 28x28=784字节 加起来,一共是 784 + 3x4 + 4 = 800 字节; 程序运行结果,也的确是800字节:在这里插入图片描述

下面是扩展后的代码:

classDataSet{public:DataSet()=default;// 新增DataSet(const std::string& image_filename,const std::string& label_filename){load_images(image_filename);load_labels(label_filename);}~DataSet(){free(buffer);}staticvoidexplain_magic(char magic_bytes[4]);voidload_images(const std::string& filename);voidload_labels(const std::string& filename);voidsave_images(const std::string& filename);// 新增voidsave_labels(const std::string& filename);// 新增

    std::vector<cv::Mat> images;
    std::vector<uint8_t> labels;private:char* buffer{};};staticvoidwrite_int_to_binary(std::ofstream& out,int num){static Endian endian =get_endian();if(endian == Endian::LSB)
        num =swap_endian(num);
    out.write(reinterpret_cast<char*>(&num),sizeof(num));}voidDataSet::save_images(const std::string& filename){
    std::ofstream out(filename, std::ios::binary);if(!out.is_open()){
        std::cout <<"failed to open "<< filename <<"\n";return;}CV_Assert(images.size()>0);// check image data types and dimsfor(int i=0; i<images.size(); i++){CV_Assert(images[i].type()== images[0].type());CV_Assert(images[i].dims ==2);CV_Assert(images[i].rows == images[0].rows);CV_Assert(images[i].cols == images[0].cols);}// write magic numberchar magic_bytes[4]={0,0,0x08,3};if(images[0].type()== CV_8UC1)
        magic_bytes[2]=0x08;elseif(images[0].type()== CV_8SC1)
        magic_bytes[2]=0x09;elseif(images[0].type()== CV_16SC1)
        magic_bytes[2]=0x0B;elseif(images[0].type()== CV_32SC1)
        magic_bytes[2]=0x0C;elseif(images[0].type()== CV_32FC1)
        magic_bytes[2]=0x0D;elseif(images[0].type()== CV_64FC1)
        magic_bytes[2]=0x0E;elseCV_Assert(false);
    out.write(magic_bytes,4);// write dimensionswrite_int_to_binary(out, images.size());write_int_to_binary(out, images[0].rows);write_int_to_binary(out, images[0].cols);// write datafor(int i=0; i<images.size(); i++)
        out.write(reinterpret_cast<char*>(images[i].data), images[i].rows * images[i].cols);

    out.close();}voidDataSet::save_labels(const std::string& filename){
    std::ofstream out(filename, std::ios::binary);if(!out.is_open()){
        std::cout <<"failed to open "<< filename <<"\n";return;}// write binarychar magic_bytes[4]={0,0,0x08,1};
    out.write(magic_bytes,4);// write number of labelswrite_int_to_binary(out, labels.size());// write labels
    out.write(reinterpret_cast<char*>(labels.data()), labels.size());

    out.close();}

调用和测试代码:

intmain(){const std::string data_dir =get_data_dir();
    DataSet train_set(data_dir +"/train-images.idx3-ubyte", data_dir +"/train-labels.idx1-ubyte");
    DataSet test_set(data_dir +"/t10k-images.idx3-ubyte", data_dir +"/t10k-labels.idx1-ubyte");

    DataSet train_set2;
    train_set2.images.push_back(train_set.images[0]);
    train_set2.labels.push_back(train_set.labels[0]);
    train_set2.save_images("train-images-0.idx3-ubyte");
    train_set2.save_labels("train-labels-0.idx1-ubyte");

    DataSet train_set3;
    train_set3.load_images("train-images-0.idx3-ubyte");
    train_set3.load_labels("train-labels-0.idx1-ubyte");
    cv::Mat image3 = train_set3.images[0];uint8_t label3 = train_set3.labels[0];
    std::cout <<"label3: "<<(int)label3 <<"\n";
    cv::imshow("image3", image3);
    cv::waitKey(0);}

9. 参考链接

标签: c++ MNIST

本文转载自: https://blog.csdn.net/baiyu33/article/details/143833197
版权归原作者 baiyu33 所有, 如有侵权,请联系我们删除。

“digit_eye开发记录(1): C++读取MNIST数据集”的评论:

还没有评论