在前两篇,我们解读了 MNIST 数据集的 IDX 文件格式,并分别用 C++ 和 Python 做了 读取 MNIST 数据集的实现。 基于 C++ 的代码稍长,基于 Python 的代码则明显更短,然而它们的共同特点是:依赖了外部库:
- 基于 C++ 的实现: 依赖了 OpenCV
- 基于 Python 的实现: 依赖了 Numpy
基于 C++ 的实现,有哪些问题
为了配置 OpenCV,无论是手动下载 OpenCV 预编译包 + 自行写 CMake 配置; 还是安装 vcpkg 后,从 vcpkg 安装 OpenCV + 自行写 CMake 配置,都略微麻烦:
vcpkg install opencv
会在本地源码编译 opencv,耗时几十分钟
即便配置完毕,还会看到关于 cmake minimum version 的提示:
读取 MNIST 数据集这个任务的规模很小,不用 vcpkg、不用 OpenCV,完全可以做到的。更进一步,还可以拿掉 C++ 的 std::vector
、std::string
、std::fstream
. 那么为啥不用 C 语言实现?完全可以。
基于 Python 的实现,有哪些问题
Pure Python 的性能堪忧,调用 Numpy 库性能确实不错,但 Numpy 是 C/C++ 实现,这性能其实和 Python 本身无关。
如果为了让代码短小,那么基于 numpy 的实现也仍显啰嗦:tensorflow/pytorch/keras/sklearn 等开源库,早就提供了 mnist 的读取的实现,安静的做一个调用者,也挺快乐的,不是吗?
基于 C 语言的实现 - 可视化怎么做?
1. 基于 ImageWatch 的自定义图像格式可视化
基于 C++ 的实现, 用了 OpenCV 是为了图像可视化,是为了验证图像和标签是否配对。抛开 OpenCV,在 Windows 下可以使用 Visual Studio 中的 ImageWatch 插件,自行扩展一下,可以得到可视化。
先看一下效果:左侧是meta信息,表明是 DE_GrayImage
类型的数据结构,大小是28x28,元素是 UINT8 类型,通道是1个;右图则是 ImageWatch 可视化的结果
ImageWatch 还提供了常见图像操作,如阈值化,@thread(image, 128)
后可视化为:
又或者,旋转90度:@rot90(image)
:
其他更多操作,可以在 ImageWatch文档 找到:
我们回到如何显示上述的 DE_GrayImage 类型的问题上:首先在C代码中定义:
c
typedef struct DE_GrayImage
{
unsigned int width;
unsigned int height;
unsigned char* data;
} DE_GrayImage;
然后创建文件 C:\Users\zz\Documents\Visual Studio 2022\Visualizers\DE_GrayImage.natvis
, 内容如下:
xml
<?xml version="1.0" encoding="utf-8"?>
<AutoVisualizer xmlns="http://schemas.microsoft.com/vstudio/debugger/natvis/2010">
<UIVisualizer ServiceId="{A452AFEA-3DF6-46BB-9177-C0B08F318025}" Id="1"
MenuName="Add to Image Watch"/>
<Type Name="DE_GrayImage">
<UIVisualizer ServiceId="{A452AFEA-3DF6-46BB-9177-C0B08F318025}" Id="1" />
</Type>
<Type Name="DE_GrayImage">
<Expand>
<Synthetic Name="[type]">
<DisplayString>UINT8</DisplayString>
</Synthetic>
<Item Name="[channels]">1</Item>
<Item Name="[width]">width</Item>
<Item Name="[height]">height</Item>
<Item Name="[data]">data</Item>
<Item Name="[stride]">width</Item>
</Expand>
</Type>
</AutoVisualizer>
简单解释下:
[type]
,[channels]
,[width]
,[height]
,[data]
,[stride]
是 ImageWatch 插件规定我们在编写 .natvis 文件来可视化图像时,需要填写的字段<Item Name="[channels]">1</Item>
是为 channels 硬编码一个数值<Synthetic Name="[type]"
则是指定数据类型
保存 .natvis 文件后,重新执行 Visual Studio 里的调试会话,就可以查看 DE_GrayImage
类型的图像的可视化了。嗯, ImageWatch 挺强大的。
不过, ImageWatch 也有不足:
第一个不足:当 ImageWatch 查看的表达式本身非法时,并没有什么提示。
例如 dataset->images[0]
, 在 print_sample
函数内,ImageWatch 能正常显示图像内容,因为此时 dataset->images[0]
是合法的表达式
而当调用堆栈回到 main 函数, dataset->images[0]
不再是合法表达式, ImageWatch 直接显示为 invalid:
而仔细检查了代码后,发现此时 dataset
类型是 DataSet
而非 DataSet*
后,改为使用 dataset. Images[0]
,就能正常显示:
第二个不足: @mem(address, type, channels, width, height, stride)
并不能把一块内存当作图像显示
2. 化繁为简,在控制台显示图像
c
void print_sample(const DataSet* dataset, int index)
{
DE_GrayImage* image = &dataset->images[index];
printf("label: %d\n", (int)dataset->labels[index]);
for (int i=0; i<28; i++)
{
for (int j=0; j<28; j++)
{
for (int k=0; k<3;k++)
printf("%c", image->data[i * 28 + j] > 128 ? '#' : ' ');
}
printf("\n");
}
}
完整代码
对于 MNIST 数据的读取,由于我们已经很熟悉它的格式,这里直接给出 C 风格的文件读取写法.
cpp
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <string.h>
long get_filesize(FILE* fp)
{
fseek(fp, 0, SEEK_END);
long filesize = ftell(fp);
fseek(fp, 0, SEEK_SET);
return filesize;
}
typedef enum Endian {
ENDIAN_LSB = 0,
ENDIAN_MSB = 1
} Endian;
int read_int_from_4_bytes(unsigned char* buf, Endian endian)
{
int x = 0;
int c[2][4] = {
{ (1 << 0), (1 << 8), (1 << 16), (1 << 24) },
{ (1 << 24), (1 << 16), (1 << 8), (1 << 0) }
};
for (int i=0; i<4; i++)
x += buf[i] * c[endian][i];
return x;
}
typedef struct DE_GrayImage
{
unsigned int width;
unsigned int height;
unsigned char* data;
} DE_GrayImage;
typedef struct DataSet
{
DE_GrayImage* images;
uint8_t* labels;
uint8_t* image_buf;
uint8_t* label_buf;
int num_images;
int num_labels;
} DataSet;
void destroy_dataset(DataSet* dataset)
{
if (dataset)
{
free(dataset->image_buf);
dataset->image_buf = NULL;
free(dataset->label_buf);
dataset->labels = NULL;
free(dataset->images);
dataset->images = NULL;
}
}
void load_labels(DataSet* dataset, const char* filename)
{
FILE* fin = fopen(filename, "rb");
long filesize = get_filesize(fin);
unsigned char* buf = (unsigned char*)malloc(filesize + 1);
if (buf == NULL)
exit(1);
buf[filesize] = '\0';
dataset->label_buf = buf;
fread((void*)buf, filesize, 1, fin);
fclose(fin);
dataset->num_labels = read_int_from_4_bytes(buf + 4, ENDIAN_MSB);
dataset->labels = buf + 8;
}
void load_images(DataSet* dataset, const char* filename)
{
FILE* fin = fopen(filename, "rb");
long filesize = get_filesize(fin);
unsigned char* buf = (unsigned char*)malloc(filesize + 1);
if (buf == NULL)
exit(1);
dataset->image_buf = buf;
buf[filesize] = '\0';
fread((void*)buf, filesize, 1, fin);
fclose(fin);
uint8_t magic[4] = { buf[0], buf[1], buf[2], buf[3] };
int num_images = read_int_from_4_bytes(buf + 4, ENDIAN_MSB);
int rows = read_int_from_4_bytes(buf + 8, ENDIAN_MSB);
int cols = read_int_from_4_bytes(buf + 12, ENDIAN_MSB);
DE_GrayImage* images = (DE_GrayImage*)malloc(sizeof(DE_GrayImage) * num_images);
if (images == NULL)
exit(1);
dataset->images = images;
for (int i=0; i<num_images; i++)
{
images[i].height = rows;
images[i].width = cols;
images[i].data = buf + 16 + i * rows * cols;
}
}
void print_sample(const DataSet* dataset, int index)
{
DE_GrayImage* image = &dataset->images[index];
printf("label: %d\n", (int)dataset->labels[index]);
for (int i=0; i<28; i++)
{
for (int j=0; j<28; j++)
{
for (int k=0; k<3;k++)
printf("%c", image->data[i * 28 + j] > 128 ? '#' : ' ');
}
printf("\n");
}
}
int main()
{
DataSet dataset;
load_images(&dataset, "C:/work/digit_eye/data/train-images.idx3-ubyte");
load_labels(&dataset, "C:/work/digit_eye/data/train-labels.idx1-ubyte");
print_sample(&dataset, 0);
print_sample(&dataset, 233);
print_sample(&dataset, 666);
printf("wait\n");
destroy_dataset(&dataset);
return 0;
}
总结
这一篇尝试了以最少依赖的方式,实现 MNIST 数据集的读取,假定了读者已经熟悉 MNIST 数据集格式。 使用 C 语言而非 C++,在图像可视化方面去掉了对于 OpenCV 的依赖,探索了使用 ImageWatch 插件、 在控制台输出这两种方式;在文件读取方面使用 C标准库的 fopen, fread, ftell 等 API 替代了 C++ 的 std::fstream
;