小游戏和GUI编程(7) | SimpleNN 界面源码解析

时间:2024-02-18 12:08:04

小游戏和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.cppmnist_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::Texturesf::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::RenderTexturesf::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 给的解释是:

  1. 复杂度增加:与直接使用sf::RectangleShape相比,从 texture 到 sprite 的方法在实现上更加复杂。你需要处理纹理的加载和管理,以及精灵的创建和属性设置。
  2. 资源管理:使用 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