使用OpenCV dnn c++加载YOLOv8生成的onnx文件进行实例分割

news2025/1/11 21:53:38

      在网上下载了60多幅包含西瓜和冬瓜的图像组成melon数据集,使用 EISeg 工具进行标注,然后使用 eiseg2yolov8 脚本将.json文件转换成YOLOv8支持的.txt文件,并自动生成YOLOv8支持的目录结构,包括melon.yaml文件,其内容如下:

path: ../datasets/melon_seg # dataset root dir
train: images/train # train images (relative to 'path')
val: images/val  # val images (relative to 'path')
test: # test images (optional)
 
# Classes
names:
  0: watermelon
  1: wintermelon

      对melon数据集进行训练的Python实现如下:最终生成的模型文件有best.pt、best.onnx、best.torchscript

import argparse
import colorama
from ultralytics import YOLO
 
def parse_args():
	parser = argparse.ArgumentParser(description="YOLOv8 train")
	parser.add_argument("--yaml", required=True, type=str, help="yaml file")
	parser.add_argument("--epochs", required=True, type=int, help="number of training")
	parser.add_argument("--task", required=True, type=str, choices=["detect", "segment"], help="specify what kind of task")
 
	args = parser.parse_args()
	return args
 
def train(task, yaml, epochs):
	if task == "detect":
		model = YOLO("yolov8n.pt") # load a pretrained model
	elif task == "segment":
		model = YOLO("yolov8n-seg.pt") # load a pretrained model
	else:
		print(colorama.Fore.RED + "Error: unsupported task:", task)
		raise
 
	results = model.train(data=yaml, epochs=epochs, imgsz=640) # train the model
 
	metrics = model.val() # It'll automatically evaluate the data you trained, no arguments needed, dataset and settings remembered
 
	model.export(format="onnx") #, dynamic=True) # export the model, cannot specify dynamic=True, opencv does not support
	# model.export(format="onnx", opset=12, simplify=True, dynamic=False, imgsz=640)
	model.export(format="torchscript") # libtorch
 
if __name__ == "__main__":
	colorama.init()
	args = parse_args()
 
	train(args.task, args.yaml, args.epochs)
 
	print(colorama.Fore.GREEN + "====== execution completed ======")

      以下是使用opencv dnn接口加载onnx文件进行实例分割的C++实现代码:

namespace {

constexpr bool cuda_enabled{ false };
constexpr int input_size[2]{ 640, 640 }; // {height,width}, input shape (1, 3, 640, 640) BCHW and output shape(s): detect:(1,6,8400); segment:(1,38,8400),(1,32,160,160)
constexpr float confidence_threshold{ 0.45 }; // confidence threshold
constexpr float iou_threshold{ 0.50 }; // iou threshold
constexpr float mask_threshold{ 0.50 }; // segment mask threshold

#ifdef _MSC_VER
constexpr char* onnx_file{ "../../../data/best.onnx" };
constexpr char* torchscript_file{ "../../../data/best.torchscript" };
constexpr char* images_dir{ "../../../data/images/predict" };
constexpr char* result_dir{ "../../../data/result" };
constexpr char* classes_file{ "../../../data/images/labels.txt" };
#else
constexpr char* onnx_file{ "data/best.onnx" };
constexpr char* torchscript_file{ "data/best.torchscript" };
constexpr char* images_dir{ "data/images/predict" };
constexpr char* result_dir{ "data/result" };
constexpr char* classes_file{ "data/images/labels.txt" };
#endif

cv::Mat modify_image_size(const cv::Mat& img)
{
	auto max = std::max(img.rows, img.cols);
	cv::Mat ret = cv::Mat::zeros(max, max, CV_8UC3);
	img.copyTo(ret(cv::Rect(0, 0, img.cols, img.rows)));

	return ret;
}

std::vector<std::string> parse_classes_file(const char* name)
{
	std::vector<std::string> classes;

	std::ifstream file(name);
	if (!file.is_open()) {
		std::cerr << "Error: fail to open classes file: " << name << std::endl;
		return classes;
	}
	
	std::string line;
	while (std::getline(file, line)) {
		auto pos = line.find_first_of(" ");
		classes.emplace_back(line.substr(0, pos));
	}

	file.close();
	return classes;
}

auto get_dir_images(const char* name)
{
	std::map<std::string, std::string> images; // image name, image path + image name

	for (auto const& dir_entry : std::filesystem::directory_iterator(name)) {
		if (dir_entry.is_regular_file())
			images[dir_entry.path().filename().string()] = dir_entry.path().string();
	}

	return images;
}

float image_preprocess(const cv::Mat& src, cv::Mat& dst)
{
	cv::cvtColor(src, dst, cv::COLOR_BGR2RGB);

	float scalex = src.cols * 1.f / input_size[1];
	float scaley = src.rows * 1.f / input_size[0];

	if (scalex > scaley)
		cv::resize(dst, dst, cv::Size(input_size[1], static_cast<int>(src.rows / scalex)));
	else
		cv::resize(dst, dst, cv::Size(static_cast<int>(src.cols / scaley), input_size[0]));

	cv::Mat tmp = cv::Mat::zeros(input_size[0], input_size[1], CV_8UC3);
	dst.copyTo(tmp(cv::Rect(0, 0, dst.cols, dst.rows)));
	dst = tmp;

	return (scalex > scaley) ? scalex : scaley;
}

void get_masks(const cv::Mat& features, const cv::Mat& proto, const std::vector<int>& output1_sizes, const cv::Mat& frame, const cv::Rect box, cv::Mat& mk)
{
	const cv::Size shape_src(frame.cols, frame.rows), shape_input(input_size[1], input_size[0]), shape_mask(output1_sizes[3], output1_sizes[2]);
	
	cv::Mat res = (features * proto).t();
	res = res.reshape(1, { shape_mask.height, shape_mask.width });
	// apply sigmoid to the mask
	cv::exp(-res, res);
	res = 1.0 / (1.0 + res);
	cv::resize(res, res, shape_input);

	float scalex = shape_src.width * 1.0 / shape_input.width;
	float scaley = shape_src.height * 1.0 / shape_input.height;
	cv::Mat tmp;
	if (scalex > scaley)
		cv::resize(res, tmp, cv::Size(shape_src.width, static_cast<int>(shape_input.height * scalex)));
	else
		cv::resize(res, tmp, cv::Size(static_cast<int>(shape_input.width * scaley), shape_src.height));

	cv::Mat dst = tmp(cv::Rect(0, 0, shape_src.width, shape_src.height));
	mk = dst(box) > mask_threshold;
}

void draw_boxes_mask(const std::vector<std::string>& classes, const std::vector<int>& ids, const std::vector<float>& confidences,
	const std::vector<cv::Rect>& boxes, const std::vector<cv::Mat>& masks, const std::string& name, cv::Mat& frame)
{
	std::cout << "image name: " << name << ", number of detections: " << ids.size() << std::endl;

	std::random_device rd;
	std::mt19937 gen(rd());
	std::uniform_int_distribution<int> dis(100, 255);
	cv::Mat mk = frame.clone();

	std::vector<cv::Scalar> colors;
	for (auto i = 0; i < classes.size(); ++i)
		colors.emplace_back(cv::Scalar(dis(gen), dis(gen), dis(gen)));

	for (auto i = 0; i < ids.size(); ++i) {
		cv::rectangle(frame, boxes[i], colors[ids[i]], 2);

		std::string class_string = classes[ids[i]] + ' ' + std::to_string(confidences[i]).substr(0, 4);
		cv::Size text_size = cv::getTextSize(class_string, cv::FONT_HERSHEY_DUPLEX, 1, 2, 0);
		cv::Rect text_box(boxes[i].x, boxes[i].y - 40, text_size.width + 10, text_size.height + 20);

		cv::rectangle(frame, text_box, colors[ids[i]], cv::FILLED);
		cv::putText(frame, class_string, cv::Point(boxes[i].x + 5, boxes[i].y - 10), cv::FONT_HERSHEY_DUPLEX, 1, cv::Scalar(0, 0, 0), 2, 0);

		mk(boxes[i]).setTo(colors[ids[i]], masks[i]);
	}

	cv::addWeighted(frame, 0.5, mk, 0.5, 0, frame);

	//cv::imshow("Inference", frame);
	//cv::waitKey(-1);

	std::string path(result_dir);
	cv::imwrite(path + "/" + name, frame);
}

void post_process_mask(const cv::Mat& output0, const cv::Mat& output1, const std::vector<int>& output1_sizes, const std::vector<std::string>& classes, const std::string& name, cv::Mat& frame)
{
	std::vector<int> class_ids;
	std::vector<float> confidences;
	std::vector<cv::Rect> boxes;
	std::vector<std::vector<float>> masks;

	float scalex = frame.cols * 1.f / input_size[1]; // note: image_preprocess function
	float scaley = frame.rows * 1.f / input_size[0];
	auto scale = (scalex > scaley) ? scalex : scaley;

	const float* data = (float*)output0.data;
	for (auto i = 0; i < output0.rows; ++i) {
		cv::Mat scores(1, classes.size(), CV_32FC1, (float*)data + 4);
		cv::Point class_id;
		double max_class_score;

		cv::minMaxLoc(scores, 0, &max_class_score, 0, &class_id);

		if (max_class_score > confidence_threshold) {
			confidences.emplace_back(max_class_score);
			class_ids.emplace_back(class_id.x);
			masks.emplace_back(std::vector<float>(data + 4 + classes.size(), data + output0.cols)); // 32

			float x = data[0];
			float y = data[1];
			float w = data[2];
			float h = data[3];

			int left = std::max(0, std::min(int((x - 0.5 * w) * scale), frame.cols));
			int top = std::max(0, std::min(int((y - 0.5 * h) * scale), frame.rows));
			int width = std::max(0, std::min(int(w * scale), frame.cols - left));
			int height = std::max(0, std::min(int(h * scale), frame.rows - top));
			boxes.emplace_back(cv::Rect(left, top, width, height));
		}

		data += output0.cols;
	}

	std::vector<int> nms_result;
	cv::dnn::NMSBoxes(boxes, confidences, confidence_threshold, iou_threshold, nms_result);

	cv::Mat proto = output1.reshape(0, { output1_sizes[1], output1_sizes[2] * output1_sizes[3] });

	std::vector<int> ids;
	std::vector<float> confs;
	std::vector<cv::Rect> rects;
	std::vector<cv::Mat> mks;
	for (size_t i = 0; i < nms_result.size(); ++i) {
		auto index = nms_result[i];
		ids.emplace_back(class_ids[index]);
		confs.emplace_back(confidences[index]);
		boxes[index] = boxes[index] & cv::Rect(0, 0, frame.cols, frame.rows);

		cv::Mat mk;
		get_masks(cv::Mat(masks[index]).t(), proto, output1_sizes, frame, boxes[index], mk);
		mks.emplace_back(mk);
		rects.emplace_back(boxes[index]);
	}

	draw_boxes_mask(classes, ids, confs, rects, mks, name, frame);
}

} // namespace

int test_yolov8_segment_opencv()
{
	namespace fs = std::filesystem;

	auto net = cv::dnn::readNetFromONNX(onnx_file);
	if (net.empty()) {
		std::cerr << "Error: there are no layers in the network: " << onnx_file << std::endl;
		return -1;
	}

	if (cuda_enabled) {
		net.setPreferableBackend(cv::dnn::DNN_BACKEND_CUDA);
		net.setPreferableTarget(cv::dnn::DNN_TARGET_CUDA);
	} else {
		net.setPreferableBackend(cv::dnn::DNN_BACKEND_OPENCV);
		net.setPreferableTarget(cv::dnn::DNN_TARGET_CPU);
	}

	if (!fs::exists(result_dir)) {
		fs::create_directories(result_dir);
	}

	auto classes = parse_classes_file(classes_file);
	if (classes.size() == 0) {
		std::cerr << "Error: fail to parse classes file: " << classes_file << std::endl;
		return -1;
	}

	std::cout << "classes: ";
	for (const auto& val : classes) {
		std::cout << val << " ";
	}
	std::cout << std::endl;

	for (const auto& [key, val] : get_dir_images(images_dir)) {
		cv::Mat frame = cv::imread(val, cv::IMREAD_COLOR);
		if (frame.empty()) {
			std::cerr << "Warning: unable to load image: " << val << std::endl;
			continue;
		}

		auto tstart = std::chrono::high_resolution_clock::now();
		cv::Mat bgr = modify_image_size(frame);

		cv::Mat blob;
		cv::dnn::blobFromImage(bgr, blob, 1.0 / 255.0, cv::Size(input_size[1], input_size[0]), cv::Scalar(), true, false);
		net.setInput(blob);

		std::vector<cv::Mat> outputs;
		net.forward(outputs, net.getUnconnectedOutLayersNames());
		if (outputs.size() != 2) {
			std::cerr << "Error: output must have 2 layers: " << outputs.size() << std::endl;
			return -1;
		}

		// output0
		cv::Mat data0 = cv::Mat(outputs[0].size[1], outputs[0].size[2], CV_32FC1, outputs[0].data).t();

		// output1
		std::vector<int> sizes;
		for (int i = 0; i < 4; ++i)
			sizes.emplace_back(outputs[1].size[i]);
		cv::Mat data1 = cv::Mat(sizes, CV_32F, outputs[1].data);

		auto tend = std::chrono::high_resolution_clock::now();
		std::cout << "elapsed millisenconds: " << std::chrono::duration_cast<std::chrono::milliseconds>(tend - tstart).count() << " ms" << std::endl;

		post_process_mask(data0, data1, sizes, classes, key, frame);
	}

	return 0;
}

      labels.txt文件内容如下:仅2类

watermelon 0
wintermelon 1

      说明:

      1.通过指定变量cuda_enabled判断走cpu还是gpu流程 ;

      2.opencv使用4.9.0版本,编译opencv使用的shell脚本如下:执行gpu时结果总不对,yolov8 issues中说因有不支持的layer导致

#! /bin/bash
 
if [ $# != 2 ]; then
    echo "Error: requires two parameters: 1: windows windows_cuda or linux; 2: relese or debug"
    echo "For example: $0 windows debug"
    exit -1
fi
 
if [ $1 != "windows" ] && [ $1 != "windows_cuda" ] && [ $1 != "linux" ]; then
    echo "Error: the first parameter can only be windows or linux"
    exit -1
fi
 
if [ $2 != "release"  ] && [ $2 != "debug" ]; then
    echo "Error: the second parameter can only be release or debug"
    exit -1
fi
 
if [[ ! -d "build" ]]; then
    mkdir build
    cd build
else
    cd build
fi

if [ $2 == "release" ]; then
    build_type="Release"
else
    build_type="Debug"
fi

# copy the contents of the bin,include,lib/x64 cudnn directories to the corresponding CUDA directories: cuda 11.8+cudnn8.7.x
# cudnn8.9.x: init.hpp:32 cv::dnn::cuda4dnn::checkVersions cuDNN reports version 8.7 which is not compatible with the version 8.9 with which OpenCV was built
# net_impl.cpp:178 cv::dnn::dnn4_v20231225::Net::Impl::setUpNet DNN module was not built with CUDA backend; switching to CPU: SET: CUDA_ARCH_BIN, OPENCV_DNN_CUDA
if [ $1 == "windows_cuda" ]; then
    cuda_options="-DWITH_CUDA=ON \
        -DWITH_CUDNN=ON \
        -DCUDA_FAST_MATH=ON \
        -DWITH_CUBLAS=ON \
        -DOPENCV_DNN_CUDA=ON \
        -DCUDA_ARCH_BIN=5.0;5.2;6.0;6.1;7.0;7.5;8.0;8.6;8.9;9.0"
else
    cuda_options=""
fi

if [ $1 == "windows" ] || [ $1 == "windows_cuda" ]; then
    cmake \
        -G"Visual Studio 17 2022" -A x64 \
        ${cuda_options} \
        -DCMAKE_BUILD_TYPE=${build_type} \
        -DCMAKE_CONFIGURATION_TYPES=${build_type} \
        -DBUILD_SHARED_LIBS=ON \
        -DBUILD_opencv_world=ON \
        -DBUILD_PERF_TESTS=OFF \
        -DBUILD_TESTS=OFF \
        -DCMAKE_INSTALL_PREFIX=../install \
        -DOPENCV_EXTRA_MODULES_PATH=../../opencv_contrib/modules \
        ..
    cmake --build . --target install --config $2
fi
 
if [ $1 == "linux" ]; then
    cmake \
        -DCMAKE_C_COMPILER=/usr/bin/gcc \
        -DCMAKE_CXX_COMPILER=/usr/bin/g++ \
        -DCMAKE_BUILD_TYPE=${build_type} \
        -DBUILD_SHARED_LIBS=ON \
        -DBUILD_opencv_world=ON \
        -DBUILD_PERF_TESTS=OFF \
        -DBUILD_TESTS=OFF \
        -DCMAKE_INSTALL_PREFIX=../install \
        -DOPENCV_EXTRA_MODULES_PATH=../../opencv_contrib/modules \
        ..
    make -j2
    make install
fi

rc=$?
if [[ ${rc} != 0 ]]; then
    echo -e "\033[0;31mError: there are some errors in the above operation, please check: ${rc}\033[0m"
	exit ${rc}
fi

      执行结果如下图所示:同样的预测图像集,与onnxruntime结果相似,但并不完全相同,它们具有相同的后处理流程;下面显示的耗时是在cpu下,gpu下仅20毫秒左右

      其中一幅图像的分割结果如下图所示:

      GitHub:https://github.com/fengbingchun/NN_Test

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1803388.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

数字校园的优势有哪些

数字化时代下&#xff0c;数字校园已成为教育领域一股显著趋势。数字校园旨在借助信息技术工具对传统校园进行改造&#xff0c;提供全新的教学、管理和服务方式。那么&#xff0c;数字校园究竟具备何种优势&#xff1f;现从三个方面为您详细介绍。 首先&#xff0c;数字校园为教…

平衡二叉树详解

目录 平衡二叉树的定义 平衡二叉树的基本操作 查找 插入 AVL树的建立 平衡二叉树的定义 平衡二叉树仍然是一棵二叉查找树&#xff0c;只是在其基础上增加了平衡的要求&#xff0c;也就是其左右子树的高度之差的绝对值不超过1。 在定义树的结构时需要加入一个变量height&…

使用树莓派和 L298N 来 DIY 小车底盘

树莓派小车可以作为 STEM&#xff08;科学、技术、工程、数学&#xff09;教育的工具&#xff0c;在实际操作中帮助学生理解和学习电子技术、编程和机器人原理。可以培养学生的动手能力、解决问题的能力和创新思维。 随着近年 AI 技术的高速发展&#xff0c;SLAM、VSLAM 甚至带…

SpringBoot+Vue图书管理系统(前后端分离)

技术栈 JavaSpringBootMavenMySQLMyBatisVueShiroElement-UI 角色对应功能 用户管理员 功能截图

STM32关于uc/OS-III的多任务程序

目录 一、UCOS-III源码获取 二、HAL库工程的建立 1.RCC配置 2.SYS配置 3.USART1配置 4.GPIO配置 5.时钟配置 6.项目配置 三、KEil文件添加 1.文件复制 2.KEil工程添加 3.添加文件路径 四、代码修改 1. 2.修改文件app_cfg.h中代码 3.修改include.h的代码 4.修改…

常见的api: BigInteger

一.获取一个大的随机整数 1.代码: BigInteger bd1 new BigInteger(4, new Random());System.out.println(bd1); 2.打印的结果:2 3.注释获取的是0-16之间的随机整数 二.获取一个指定的大的数 1.代码&#xff1a; BigInteger bd2 new BigInteger("100");System.o…

计网总结☞物理层

五层协议体系结构->各层的功能有&#xff1a; 物理层 物理层的任务就是尽可能地屏蔽传输媒体的差异&#xff0c;透明地传送比特流&#xff08;注意&#xff1a;传递信息的物理媒体&#xff0c;如双绞线、同轴电缆、光缆等&#xff0c;是在物理层的下面&#xff0c;当做第 0…

flink读取hive写入http接口

目录 0、创建hive数据 1、pom.xml 2、flink代码 3、sink 4、提交任务jar 5、flink-conf.yaml 6、数据接收 flink-1.17.2jdk1.8hive-3.1.3hadoop3.3.6passwordhttp0、创建hive数据 /cluster/hive/bin/beeline !connect jdbc:hive2://ip:10000 create database demo; d…

HPC: perf入门

如果你想查看你的程序在cpu上运行时&#xff0c;耗时时如何分布的&#xff0c;那么perf是一个合理的选择。 准备工作 为了支持使用perf&#xff0c;首先你要安装相关的库 sudo apt install linux-tools-5.15.0-67-generic此外&#xff0c;因为使用perf进行benchmark&#xf…

【C语言】预处理详解(上卷)

前言 预处理也是C语言中非常重要的存在。那么就详细地来了解一下吧。 预定义符号 C语言设置了一些预定义符号&#xff0c;可以直接使用&#xff0c;预定义符号也是在预处理期间处理的。 1 __FILE__ //进行编译的源文件 2 __LINE__ //文件当前的…

移动端 UI 风格,视觉盛宴

移动端 UI 风格&#xff0c;视觉盛宴

【Proteus8.16】Proteus8.16.SP3.exe的安装包,安装方法

下载&#xff1a; 链接&#xff1a;https://pan.baidu.com/s/14ZlETF7g4Owh8djLaHwBOw?pwd2bo3 提取码&#xff1a;2bo3 管理员打开proteus8.16.SP3.exe一路装就行了&#xff0c;许可证选Licence2.lxk,点安装后关闭&#xff0c;然后继续装完。 然后打开Patch-Proteus-8.16-…

tomcat8w.exe指向了别的tomcat

这种情况通常发生是因为Tomcat服务在注册表中的配置指向了错误的可执行文件路径。tomcat8w.exe是一个Windows服务配置工具&#xff0c;它用于管理Tomcat服务&#xff0c;包括设置Path to executable&#xff0c;即指向Tomcat服务实际启动的.exe文件的路径。如果Path to executa…

基于深度学习的在线选修课程推荐系统

基于深度学习的在线选修课程推荐系统 1、效果图 点我查看Demo 2、功能 可联系我-微-信(1257309054) 登录注册、点赞收藏、评分评论&#xff0c;课程推荐&#xff0c;热门课程&#xff0c;个人中心&#xff0c;可视化&#xff0c;后台管理&#xff0c;课程选修3、核心推荐代…

htb_office

端口扫描 namp -sSVC 10.10.11.1380&#xff0c;445 80端口 robots.txt 只有/administrator可以访问 Joomla joomscan扫描 joomscan --url http://10.10.11.3/ 版本为4.2.7&#xff0c;存在cve CVE-2023-23752 Joomla未授权访问Rest API漏洞 访问路径 /api/index.php/…

linux经典例题编程

编写Shell脚本&#xff0c;计算1~100的和 首先vi 1.sh,创建一个名为1.sh的脚本&#xff0c;然后赋予这个脚本权限&#xff0c;使用命令chmod 755 1.sh&#xff0c;然后就可以在脚本中写程序&#xff0c;然后运行。 shell脚本内容 运行结果&#xff1a; 编写Shell脚本&#xf…

【JavaScript】内置对象 - 字符串对象 ⑤ ( 判断对象中是否有某个属性 | 统计字符串中每个字符出现的次数 )

文章目录 一、判断对象中是否有某个属性1、获取对象属性2、判定对象是否有某个属性 二、统计字符串中每个字符出现的次数1、算法分析2、代码示例 String 字符串对象参考文档 : https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/String 一、判…

OpenCV学习(4.8) 图像金字塔

1.目的 在这一章当中&#xff0c; 我们将了解图像金字塔。我们将使用图像金字塔创建一个新的水果&#xff0c;“Orapple”我们将看到这些功能&#xff1a; cv.pyrUp&#xff08;&#xff09; &#xff0c; cv.pyrDown&#xff08;&#xff09; 在通常情况下我们使用大小恒定…

Rust 标记一个属性或函数为废弃

如题&#xff0c;演示Rust 标记一个属性或函数为废弃的基本使用方法&#xff1a; 示例&#xff1a; use serde::{Deserialize, Serialize};#[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct GrpcOptions {pub addr: String,pub max_recv_message_size: u…

MathType7.8学术必备神器 科研利器 教学好帮手

【MathType 7.8】数学公式编辑器&#xff0c;让学术和专业文档制作更高效、更准确&#xff01;&#x1f9ee;✨ 作为一名软件技术专家&#xff0c;我一直在寻找一款能够轻松创建复杂数学公式的工具。幸运的是&#xff0c;我终于找到了MathType 7.8&#xff0c;这款强大的数学公…