小游戏和GUI编程(7) | SimpleNN 界面源码解析
0. 简介
SimpleNN 是 AdamYuan 在高中一年级时用 1 天时间写出来的简易 CNN, 使用 SFML 做 UI, 用于交互式输入手写数字,这个数字被训练好的 CNN 网络执行推理得到识别结果, 它的运行效果如下:
这一篇我们来分析 UI 界面的代码, 规划如下:
- 完成本地构建 (预计5分钟)
- 确定和粗读 UI 代码 (预计30分钟)
- 拆解 UI 部件和自行重新实现 (预计2小时)
实际用时: 10:40~14:30
1. 完成本地构建: 添加 CMakeLists.txt
原版代码使用 Makefile, 其中添加了 -std=c++11, 换了 g++ 为 clang++, 我是在 macOS 下:
all: MnistTrainer MnistUI MnistTest
MnistTrainer: mnist_trainer.cpp */*.hpp */*.cpp
clang++ -std=c++11 mnist_trainer.cpp */*.cpp -Ofast -o MnistTrainer -lm -lpthread
MnistUI: mnist_ui.cpp NN/NN.* NN/Util.hpp MNIST/Util.hpp
clang++ -std=c++11 mnist_ui.cpp NN/NN.cpp -Ofast -o MnistUI -lm -lsfml-system -lsfml-window -lsfml-graphics
MnistTest: mnist_test.cpp NN/NN.* MNIST/Loader.* NN/Util.hpp MNIST/Util.hpp
clang++ -std=c++11 mnist_test.cpp NN/NN.cpp MNIST/Loader.cpp -Ofast -o MnistTest -lm
为什么不用 Makefile: 因为 makefile 没有内置的包管理器, pkg-config 配置多个包的话感觉很麻烦. 使用 CMake 稍微缓解一些。
找到了 3 个 main(
函数, 和 makefile 里的 3 个 target 对应:
➜ SimpleNN git:(master) ✗ ag 'main\(' --ignore-dir build
mnist_ui.cpp
113:int main(int argc, char **argv)
mnist_test.cpp
6:int main(int argc, char **argv)
mnist_trainer.cpp
7:int main(int argc, char **argv)
对于 UI 界面显示, 不需要 mnist_trainer.cpp
和 mnist_test.cpp
, 因此写出 CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(SimpleNN)
set(CMAKE_CXX_STANDARD 11)
add_executable(MnistUI
mnist_ui.cpp
MNIST/Loader.cpp
NN/NN.cpp
NN/Trainer.cpp
)
find_package(SFML 2.6 COMPONENTS system window graphics REQUIRED)
target_link_libraries(MnistUI PRIVATE
pthread
sfml-system
sfml-window
sfml-graphics
)
为了后续源码分析和测试方便, 再增加一个 MnistUI_my
的可执行文件目标:
add_executable(MnistUI_my
mnist_ui_my.cpp
MNIST/Loader.cpp
NN/NN.cpp
NN/Trainer.cpp
)
target_link_libraries(MnistUI_my PRIVATE
pthread
sfml-system
sfml-window
sfml-graphics
)
2. 确定和粗读 UI 代码
拆解为: 确定 UI 相关的代码文件; 粗略分析 UI 代码组成部分.
涉及的文件:
-
mnist_ui.cpp
: UI 代码, 170 行 -
ui/VCR_OSD_MONO_1.001.ttf
: 字体文件
下面是 mnist_ui.cpp
的简单解读:
2.1 通过命令行参数传入网络文件
使用了全局变量 snn
, 从传入的参数表示的文件来加载 cnn 网络相关的内容:
SimpleNN snn;
int main(int argc, char **argv)
{
if(argc != 2)
{
printf("Usage: ./MnistUI [snn filename]\n");
return EXIT_FAILURE;
}
snn.Load(argv[1]);
...
}
2.2 UI 整体代码逻辑
InitWindow(); // 窗口部件的创建、 布局的设定
Clear(); // 设定鼠标绘制区域的颜色
while(window.isOpen())
{
while(window.pollEvent(event))
{
// 事件处理
}
// 如果鼠标左键按下了, 那么渲染鼠标的轨迹
if(mouse_down)
Paint();
window.draw(paint_sprite);
// 渲染输入纹理
window.draw(input_sprite);
// 渲染输出纹理
window.draw(output_sprite);
// 渲染输出数字纹理
window.draw(output_digits_sprite);
// 渲染鼠标为圆形
Cursor();
window.display(); // 绘制
}
3. 详细解读
这一节是通过拆解 UI 代码的部件, 对每个部件进行代码粗略分析, 并摘录出用到的代码到单独的文件 Mnist_UI_my.cpp 中验证效果.
3.1 窗口部件、布局
整体布局
这一小节,需要看的是 InitWindow()
函数, 以及 main()
函数里 window.draw()
相关的几句调用。
在 InitWindow()
里, 设置了各个部件的大小:
- paint_tex: 560x560的方格, main()中创建了它的匿名 Sprite 并且没设置位置, 因此位置是默认的 (0,0), 也就是整个窗口左边一半
window.draw(sf::Sprite(paint_tex.getTexture()));
- input_tex: 和 paint_tex 大小一致,结合 main() 里的代码, 是位于窗口右侧
sf::Sprite input_sprite{input_tex.getTexture()};
input_sprite.setPosition(kSize, 0);
window.draw(input_sprite);
- output_tex: 56x560的竖条, 结合 main() 里的代码, 是位于整个窗口最右侧
sf::Sprite output_sprite{output_tex.getTexture()};
output_sprite.setPosition(kSize*2, 0);
window.draw(output_sprite);
InitWindow() 详细注释
void InitWindow()
{
window.create(sf::VideoMode(kSize*2 + kOutSize, kSize), "Mnist Demo", sf::Style::Titlebar | sf::Style::Close);
paint_tex.create(kSize, kSize); // kSize=20*28, 这是560x560方形纹理
input_tex.create(kSize, kSize);
output_tex.create(kOutSize, kSize); // kOutSize=kSize/10=2*28=56, 56x560的大小
output_digits_tex.create(kOutSize, kSize); // 56x560的大小, 是一个竖条形状
sf::Font font; font.loadFromFile("./ui/VCR_OSD_MONO_1.001.ttf");
sf::Text text;
text.setFont(font); text.setCharacterSize(kOutSize);
text.setFillColor(sf::Color(0, 0, 0, 255));
// 竖条分成 10 部分, 每个部分是 56x56 的方格, 每个方格绘制一个数字
for(unsigned i = 0; i < 10; ++i)
{
text.setPosition(0, i * kOutSize);
text.setString(std::to_string(i));
output_digits_tex.draw(text);
}
output_digits_tex.display();
// sf::CircleShape brush_circle, cursor_circle; 这里猜测是鼠标绘制时, 鼠标自身 以及 刷子 的形状
brush_circle.setFillColor(sf::Color(0, 0, 0));
cursor_circle.setFillColor(sf::Color(0, 0, 0, 100));
brush_circle.setRadius(radius);
cursor_circle.setRadius(radius);
// sf::RectangleShape input_rect, output_rect; 这里暂时没看出来用途。
input_rect.setSize(sf::Vector2f(kGridSize, kGridSize)); //20x20
output_rect.setSize(sf::Vector2f(kOutSize, kOutSize)); //56x56
}
Clear()函数
void Clear()
{
paint_tex.clear(sf::Color(255, 255, 255));
}
Clear()
把屏幕左侧的 paint_tex 区域背景颜色设定为白色.
完整代码
这里说的完整代码, 是把刚刚分析的代码摘录出来, 放到 Mnist_UI_my.cpp 里, 并编译运行
#include <SFML/Graphics.hpp>
sf::RenderWindow window;
sf::Event event;
constexpr int kGridSize = 20, kSize = 28*kGridSize, kOutSize = kSize / 10;
constexpr float kMinRadius = 8.0, kMaxRadius = 30.0, kRadiusStep = 1.0;
sf::RenderTexture paint_tex, input_tex, output_tex, output_digits_tex;
float radius{(kMinRadius + kMaxRadius) * 0.5f};
sf::CircleShape brush_circle, cursor_circle;
sf::RectangleShape input_rect, output_rect;
void InitWindow()
{
window.create(sf::VideoMode(kSize*2 + kOutSize, kSize), "Mnist Demo", sf::Style::Titlebar | sf::Style::Close);
paint_tex.create(kSize, kSize);
input_tex.create(kSize, kSize);
output_tex.create(kOutSize, kSize);
output_digits_tex.create(kOutSize, kSize);
const std::string asset_dir = "../";
sf::Font font; font.loadFromFile(asset_dir+"/ui/VCR_OSD_MONO_1.001.ttf");
sf::Text text;
text.setFont(font); text.setCharacterSize(kOutSize);
text.setFillColor(sf::Color(0, 0, 0, 255));
for(unsigned i = 0; i < 10; ++i)
{
text.setPosition(0, i * kOutSize);
text.setString(std::to_string(i));
output_digits_tex.draw(text);
}
output_digits_tex.display();
brush_circle.setFillColor(sf::Color(0, 0, 0));
cursor_circle.setFillColor(sf::Color(0, 0, 0, 100));
brush_circle.setRadius(radius);
cursor_circle.setRadius(radius);
input_rect.setSize(sf::Vector2f(kGridSize, kGridSize));
output_rect.setSize(sf::Vector2f(kOutSize, kOutSize));
}
void Clear()
{
paint_tex.clear(sf::Color(255, 255, 255));
}
int main()
{
InitWindow();
Clear();
while(window.isOpen())
{
while(window.pollEvent(event))
{
if(event.type == sf::Event::EventType::Closed)
{
window.close();
}
}
sf::Sprite paint_sprite{paint_tex.getTexture()};
auto paint_sprite_position = paint_sprite.getPosition();
printf("paint_sprite_position: %f, %f\n", paint_sprite_position.x, paint_sprite_position.y);
window.draw(sf::Sprite(paint_tex.getTexture()));
sf::Sprite input_sprite{input_tex.getTexture()};
input_sprite.setPosition(kSize, 0);
window.draw(input_sprite);
sf::Sprite output_sprite{output_tex.getTexture()};
output_sprite.setPosition(kSize*2, 0);
window.draw(output_sprite);
sf::Sprite output_digits_sprite{output_digits_tex.getTexture()};
output_digits_sprite.setPosition(kSize*2, 0);
window.draw(output_digits_sprite);
window.display();
}
return 0;
}
由于省略了 event 的处理, 鼠标事件自然是没有响应的, 界面非常枯燥, 看起来只有左右的白色、黑色两个部分:
3.2 paint 区域的显示和清理
需要先开启鼠标和键盘事件的处理, 然后再启用 paint_tex 的绘制。
处理鼠标事件
main()
函数里处理鼠标事件:
while(window.pollEvent(event))
{
...
if(event.type == sf::Event::EventType::MouseButtonPressed)
mouse_down = true;
if(event.type == sf::Event::EventType::MouseButtonReleased)
mouse_down = false;
}
if(mouse_down)
Paint();
处理键盘事件
main()
函数中处理键盘事件: 如果用户按下了空格键, 那么调用 Clear()
函数来把左侧输入区域显示的内容清空:
while(window.pollEvent(event))
{
...
if(event.type == sf::Event::EventType::KeyReleased
&& event.key.code == sf::Keyboard::Space)
{
// window.setTitle("Recognize: " + std::to_string(Recognize())); 目前不需要调用 Recognize函数,先注释掉
Clear();
}
}
由于 Clear()
本身是一个不复杂的函数调用, 仅仅是把 input_tex 这个纹理的颜色设定为白色。 如果是稍微耗时一些的任务,通常是在事件处理函数的地方做判断, 在外部处理。
void Clear()
{
paint_tex.clear(sf::Color(255, 255, 255));
}
绘制 paint 区域
调用的 Paint()
函数是本小节的关键
void Paint()
{
// 获取鼠标在窗口 window 内的位置
sf::Vector2i xy = sf::Mouse::getPosition(window);
// 如果鼠标坐标在窗口内部
if(xy.x >= 0 && xy.x < kSize && xy.y >= 0 && xy.y < kSize)
{
// 如果鼠标不在左侧的 input_tex 范围, 那么就做 clip
int x = std::max(0, std::min(xy.x, kSize)) - radius;
// 在纵向方向上, 也做了 clip, 因此如果打算在界面布局上再增加底栏,也是能处理鼠标在 input_tex 的显示的
int y = std::max(0, std::min(xy.y, kSize)) - radius;
// 设置笔刷的坐标
brush_circle.setPosition(x, y);
// 在 paint_tex 上绘制笔刷
paint_tex.draw(brush_circle);
}
paint_tex.display();
}
其中存在 sf::CirleShape
-> sf::Texture
的对象“存放”关系: 把一个 shape 存放到一个 texture 中。
而在 main()
中则进一步做了 sf::Texture
-> sf::Sprite
的处理:
window.draw(sf::Sprite(paint_tex.getTexture()));
在官方教程 https://www.sfml-dev.org/tutorials/2.6/graphics-sprite.php 里给出了解释:
Most (if not all) of you are already familiar with these two very common objects, so let’s define them very briefly.
A texture is an image. But we call it “texture” because it has a very specific role: being mapped to a 2D entity.
A sprite is nothing more than a textured rectangle.
纹理(texture)是一幅图像(image)。但我们称它为 texture,因为它有一个非常具体的作用:被映射到一个2D实体上。
精灵(sprite)只不过是一个带有纹理的矩形.
为什么使用 texture + sprite, 而不是 RectangleShape?
从 SFML 的代码层更容易理解: window.draw()
我们目前写过的代码, 主要是绘制形状, 也绘制过顶点 sf::Vertex
. 对于绘制形状:
class Window
{
public:
...
void draw(const Drawable& drawable, const RenderStates& states = RenderStates::Default);
};
因此, 如果要绘制 texture, 就需要让 texture 继承自 sf::Drawable
. 但是 sf::Texture
和 sf::RenderTexture
都没有继承自 sf::Drawable
:
class SFML_GRAPHICS_API Texture : GlResource
{
...
};
class SFML_GRAPHICS_API RenderTexture : public RenderTarget
{
...
};
而 sf::Sprite
则是继承了 sf::Drawable
, 并且能从 sf::Texture
创建对象:
class SFML_GRAPHICS_API Sprite : public Drawable, public Transformable
{
public:
explicit Sprite(const Texture& texture); // 从整个 texture 创建 sprite
Sprite(const Texture& texture, const IntRect& rectangle); // 从 ROI 创建 sprite
...
};
因此, 目前遇到的三种绘制方式:
-
sf::CircleShape
->window.draw(circle)
-
sf::Vertex
->window.draw(vertex, 2, sf::Lines)
-
sf::CirleShape
->sf::Texture
->sf::Sprite
->window.draw(sprite)
第三种方式中的 Sprite 是为了承载 Texture, 那么 Texture 是为了什么呢? 准确的说, 是 sf::RenderTexture
对象的 .getTexture()
方法返回的 sf::Texture
对象:
sf::RenderTexture paint_tex, input_tex, output_tex, output_digits_tex;
...
sf::Sprite input_sprite{input_tex.getTexture()};
input_sprite.setPosition(kSize, 0);
window.draw(input_sprite);
而 sf::RenderTexture
和 sf::Texture
没有直接的继承关系:
class SFML_GRAPHICS_API RenderTexture : public RenderTarget
{
...
};
对于 input_tex
这个 sf::RenderTexture
来说, 它仅仅是被创建 (.create()
), 然后就没有主动调用什么方法了; input_sprite
则是对它设定了位置:
input_tex.create(kSize, kSize);
sf::Sprite input_sprite{input_tex.getTexture()};
input_sprite.setPosition(kSize, 0);
window.draw(input_sprite);
为什么能设定位置? 因为 sf::Sprite
继承了 Transformable
类:
class SFML_GRAPHICS_API Sprite : public Drawable, public Transformable
看起来好像用 sf::RectangleShape
也能完成同样功能, GPT4 给的解释是:
- 复杂度增加:与直接使用sf::RectangleShape相比,从 texture 到 sprite 的方法在实现上更加复杂。你需要处理纹理的加载和管理,以及精灵的创建和属性设置。
- 资源管理:使用 texture 和 sprite 可能需要更多的注意力来管理资源,比如确保纹理在使用前已经正确加载,以及在不再需要时释放资源。
sf::Texture
这个纹理数据是被上传到 GPU 显存中, GPU 处理的速度快; 如果有多个 sf::Sprite
实例共享使用同一个 texture, 那么不需要重新上传, 只需要上传一次, 减少了显存使用和数据传输的开销。
完整的代码
把用到的代码抽取出来, 放到 Mnist_UI_my.cpp 中, 本节的代码能够在左侧区域中,使用鼠标绘制, 使用空格键清理:
#include <SFML/Graphics.hpp>
sf::RenderWindow window;
sf::Event event;
constexpr int kGridSize = 20, kSize = 28*kGridSize, kOutSize = kSize / 10;
constexpr float kMinRadius = 8.0, kMaxRadius = 30.0, kRadiusStep = 1.0;
sf::RenderTexture paint_tex, input_tex, output_tex, output_digits_tex;
float radius{(kMinRadius + kMaxRadius