深度学习项目实例(一)—— 实时AI换脸项目

一、前言

人工智能(AI)技术的快速发展为各个领域带来了革命性的变化,其中之一就是人脸识别与图像处理技术。在这之中,AI换脸技术尤其引人注目。这种技术不仅在娱乐行业中得到广泛应用,如电影制作、视频特效等,还在社交媒体上掀起了一股风潮。AI换脸技术不仅可以实现实时的面部替换,还能够在图像和视频中生成高度逼真的换脸效果。

AI换脸技术的核心在于多种机器学习和深度学习模型的结合。它通常涉及几个关键步骤:人脸检测、人脸特征点检测、人脸对齐、换脸处理以及图像增强。每个步骤都依赖于不同的深度学习模型,以确保最终的换脸效果逼真且自然。

本项目实现了一个完整的AI换脸系统,集成了多个深度学习模型,包括YOLO人脸检测模型、68关键点检测模型、ArcFace人脸识别模型、InSwapper换脸模型以及GFPGAN人脸增强模型。通过这些模型的协同工作,我们能够从源图像中提取人脸特征,并将其无缝地替换到目标图像或视频中,生成自然的换脸效果。

接下来,我们将详细介绍这个AI换脸系统的实现细节和工作原理。通过这些介绍,读者可以深入了解AI换脸技术的实际应用和技术实现过程。

二、系统架构与工作流程

2.1 系统整体架构

在这里插入图片描述

2.1 主要模块与功能介绍(附代码)

该项目主要由5个主要模块组成,他们分别是人脸检测,人脸关键点检测,人脸对齐,换脸处理和图像增强。

2.1.1 人脸检测

首先我们需要检测源图像和目标图像中的人脸相关数据,获取图像中包含的人脸坐标,即由左上和右下坐标决定的矩阵框,对应的面部关键点和置信度分数。在该部分中所采用的检测模型是YOLOv8,它是最新一代的 YOLO(You Only Look Once)系列模型之一,专为实时目标检测任务而设计。它在精度和速度方面相比之前的模型均有显著提升,非常适用于需要快速响应的应用场景,如视频监控、自动驾驶和增强现实等。所以在实时换脸项目中,YOLOv8显然非常适合用于人脸检测。以下是具体步骤:

  1. 模型初始化
    首先设定模型的参数置信度阈值和iou阈值,之后加载YOLOv8的ONNX 模型,并设置推理会话的选项。需要在初始化中获取模型的输入名称和形状,以便后续进行图像预处理。

    def __init__(self, modelpath, conf_thres=0.5, iou_thresh=0.4):self.conf_threshold = conf_thresself.iou_threshold = iou_threshsession_option = onnxruntime.SessionOptions()session_option.log_severity_level = 3self.session = onnxruntime.InferenceSession(modelpath, sess_options=session_option)model_inputs = self.session.get_inputs()self.input_names = [model_inputs[i].name for i in range(len(model_inputs))]self.input_shape = model_inputs[0].shapeself.input_height = int(self.input_shape[2])self.input_width = int(self.input_shape[3])
    
  2. 图像预处理
    在使用YOLOv8进行推理之前需要先调整输入图像大小并进行边界填充,还需要将图像像素值归一化到 [-1, 1] 的范围,并调整通道顺序,使其符合模型的输入要求。

    def preprocess(self, srcimg):height, width = srcimg.shape[:2]temp_image = srcimg.copy()if height > self.input_height or width > self.input_width:scale = min(self.input_height / height, self.input_width / width)new_width = int(width * scale)new_height = int(height * scale)temp_image = cv2.resize(srcimg, (new_width, new_height))self.ratio_height = height / temp_image.shape[0]self.ratio_width = width / temp_image.shape[1]input_img = cv2.copyMakeBorder(temp_image, 0, self.input_height - temp_image.shape[0], 0, self.input_width - temp_image.shape[1], cv2.BORDER_CONSTANT, value=0)input_img = (input_img.astype(np.float32) - 127.5) / 128.0input_img = input_img.transpose(2, 0, 1)input_img = input_img[np.newaxis, :, :, :]return input_img
    
  3. 进行推理
    在推理过程中,首先要调用 preprocess 方法对输入图像进行预处理获得符合模型要求的输入。再使用 ONNX Runtime 进行推理,得到检测结果。之后调用 postprocess 方法(下面提到)处理输出结果。

    def detect(self, srcimg):input_tensor = self.preprocess(srcimg)outputs = self.session.run(None, {self.input_names[0]: input_tensor})[0]boxes, kpts, scores = self.postprocess(outputs)return boxes, kpts, scores
    
  4. 后处理
    在执行推理后调用后处理函数解析模型输出,获取边界框、关键点和得分。同时使用非极大值抑制(NMS)去除冗余的检测框。再根据缩放比例调整边界框和关键点的坐标。

    def postprocess(self, outputs):bounding_box_list, face_landmark5_list, score_list = [], [], []outputs = np.squeeze(outputs, axis=0).Tbounding_box_raw, score_raw, face_landmark_5_raw = np.split(outputs, [4, 5], axis=1)keep_indices = np.where(score_raw > self.conf_threshold)[0]if keep_indices.any():bounding_box_raw, face_landmark_5_raw, score_raw = bounding_box_raw[keep_indices], face_landmark_5_raw[keep_indices], score_raw[keep_indices]bboxes_wh = bounding_box_raw.copy()bboxes_wh[:, :2] = bounding_box_raw[:, :2] - 0.5 * bounding_box_raw[:, 2:]bboxes_wh *= np.array([[self.ratio_width, self.ratio_height, self.ratio_width, self.ratio_height]])face_landmark_5_raw *= np.tile(np.array([self.ratio_width, self.ratio_height, 1]), 5).reshape((1, 15))score_raw = score_raw.flatten()indices = cv2.dnn.NMSBoxes(bboxes_wh.tolist(), score_raw.tolist(), self.conf_threshold, self.iou_threshold)if isinstance(indices, np.ndarray):indices = indices.flatten()if len(indices) > 0:bounding_box_list = list(map(lambda x: np.array([x[0], x[1], x[0] + x[2], x[1] + x[3]], dtype=np.float64), bboxes_wh[indices]))score_list = list(score_raw[indices])face_landmark5_list = list(face_landmark_5_raw[indices])return bounding_box_list, face_landmark5_list, score_list
  5. 绘制检测结果
    最后将得到的边界框,关键点以及对应的置信度绘制在输入图像上,这里为了方便换脸后前后对比,把输入图像复制了一份,在该副本上进行绘制。得到的结果如下:
    在这里插入图片描述

2.1.2人脸关键点检测

这里我们来介绍一个可以识别人脸图像关键点的模型,2DFAN4 模型。该模型可以检测人脸上的68个关键点,这些关键点包括眼睛、眉毛、鼻子、嘴巴和面部轮廓等。

  1. 模型初始化:
    和上一步类似,初始化 ONNX 模型会话,设置模型路径并获取模型输入信息。
  2. 图像预处理
    计算缩放比例和平移量,使边界框居中到 256x256 的图像中。使用 warp_face_by_translation 方法进行仿射变换,返回裁剪后的图像和仿射矩阵。转置图像通道顺序,并进行归一化处理。
    def preprocess(self, srcimg, bounding_box):'''bounding_box里的数据格式是[xmin. ymin, xmax, ymax]'''scale = 195 / np.subtract(bounding_box[2:], bounding_box[:2]).max()natranslation = (256 - np.add(bounding_box[2:], bounding_box[:2]) * scale) * 0.5crop_img, affine_matrix = warp_face_by_translation(srcimg, translation, scale, (256, 256))crop_img = crop_img.transpose(2, 0, 1).astype(np.float32) / 255.0crop_img = crop_img[np.newaxis, :, :, :]return crop_img, affine_matrix
    
  3. 人脸关键点检测
    调用 preprocess 方法,得到输入张量和仿射矩阵,再使用 ONNX 模型进行推理,得到人脸的 68 个关键点。对关键点进行归一化处理,并应用逆仿射变换,将关键点坐标转换回原图像坐标系中。将 68 个关键点转换为 5 个关键点(这里其实和上面的YOLOv8实现的功能类似)。
        def detect(self, srcimg, bounding_box):'''如果直接crop+resize,最后返回的人脸关键点有偏差'''input_tensor, affine_matrix = self.preprocess(srcimg, bounding_box)face_landmark_68 = self.session.run(None, {self.input_names[0]: input_tensor})[0]face_landmark_68 = face_landmark_68[:, :, :2][0] / 64face_landmark_68 = face_landmark_68.reshape(1, -1, 2) * 256face_landmark_68 = cv2.transform(face_landmark_68, cv2.invertAffineTransform(affine_matrix))face_landmark_68 = face_landmark_68.reshape(-1, 2)face_landmark_5of68 = convert_face_landmark_68_to_5(face_landmark_68)return face_landmark_68, face_landmark_5of68
    
  4. 绘制检测结果
    最后将得到的68个人脸面部关键点绘制在输入图像上。得到的结果如下:
    在这里插入图片描述

2.1.3 人脸对齐

  1. 模型初始化
    同上一步,所有onnx模型初始化的步骤都是一样的。
  2. 图像预处理
    使用 warp_face_by_face_landmark_5 函数按人脸特征点进行裁剪和对齐。将图像像素值从原始范围 [0, 255] 转换到范围 [-1, 1]。转置图像通道顺序,使其符合模型的输入格式。
       def preprocess(self, srcimg, face_landmark_5):crop_img, _ = warp_face_by_face_landmark_5(srcimg, face_landmark_5, 'arcface_112_v2', (112, 112))crop_img = crop_img / 127.5 - 1crop_img = crop_img[:, :, ::-1].transpose(2, 0, 1).astype(np.float32)crop_img = np.expand_dims(crop_img, axis = 0)return crop_img
    
  3. 特征向量提取
    首先调用 preprocess 方法对输入图像进行预处理。使用 ONNX Runtime 进行推理,提取人脸特征向量(embedding)。对特征向量进行归一化处理,得到归一化后的特征向量(normed_embedding)。
        def detect(self, srcimg, face_landmark_5):input_tensor = self.preprocess(srcimg, face_landmark_5)# Perform inference on the imageembedding = self.session.run(None, {self.input_names[0]: input_tensor})[0]embedding = embedding.ravel()normed_embedding = embedding / np.linalg.norm(embedding)return embedding, normed_embedding
    

该模型的主要功能是通过人脸对齐来提取人脸特征向量。人脸对齐是人脸识别任务中的关键步骤,它有助于将输入的人脸图像标准化,使其在不同的拍摄角度、光照和表情变化下具有一致的表示。

2.1.4换脸处理

前面做了那么多处理,终于我们来到了关键步骤:换脸处理!此处用到的模型是inswapper_128,该模型通过将源图像中的人脸特征嵌入到目标图像中的人脸区域,实现自然逼真的换脸效果。

  1. 模型初始化
    继续同样地加载 ONNX 模型,并创建 ONNX Runtime 会话,并获取模型的输入名称和输入形状。和之前不同的是这一步需要加载模型矩阵,用于对源人脸特征向量进行变换。
    def __init__(self, modelpath):# Initialize modelsession_option = onnxruntime.SessionOptions()session_option.log_severity_level = 3self.session = onnxruntime.InferenceSession(modelpath, sess_options=session_option)model_inputs = self.session.get_inputs()self.input_names = [model_inputs[i].name for i in range(len(model_inputs))]self.input_shape = model_inputs[0].shapeself.input_height = int(self.input_shape[2])self.input_width = int(self.input_shape[3])self.model_matrix = np.load('model_matrix.npy')
    
  2. 图像处理和换脸
    • 图像预处理
      • 人脸对齐:使用 warp_face_by_face_landmark_5 函数将目标图像按人脸特征点进行裁剪和对齐。
      • 创建遮罩:使用 create_static_box_mask 创建静态盒子遮罩,方便后续将换脸结果融合回原图像。
      • 归一化处理:将图像像素值从原始范围 [0, 255] 转换到 [0, 1],并进行标准化处理,使其符合模型的输入要求。
    • 特征向量变换
      • 源人脸特征变换:将源人脸特征向量进行变换,并归一化处理,以符合模型的输入要求。
    • 模型推理
      • 换脸推理:使用 ONNX Runtime 对预处理后的图像和源人脸特征向量进行推理,得到换脸结果。
      • 结果处理:将换脸结果图像转换回原始图像格式。
    • 融合换脸结果
      • 融合处理:将换脸结果图像融合回原图像中,确保换脸区域自然逼真。
      def process(self, target_img, source_face_embedding, target_landmark_5):###preprocesscrop_img, affine_matrix = warp_face_by_face_landmark_5(target_img, target_landmark_5, 'arcface_128_v2', (128, 128))crop_mask_list = []box_mask = create_static_box_mask((crop_img.shape[1],crop_img.shape[0]), FACE_MASK_BLUR, FACE_MASK_PADDING)crop_mask_list.append(box_mask)crop_img = crop_img[:, :, ::-1].astype(np.float32) / 255.0crop_img = (crop_img - INSWAPPER_128_MODEL_MEAN) / INSWAPPER_128_MODEL_STDcrop_img = np.expand_dims(crop_img.transpose(2, 0, 1), axis = 0).astype(np.float32)source_embedding = source_face_embedding.reshape((1, -1))source_embedding = np.dot(source_embedding, self.model_matrix) / np.linalg.norm(source_embedding)###Perform inference on the imageresult = self.session.run(None, {'target':crop_img, 'source':source_embedding})[0][0]###normalize_crop_frameresult = result.transpose(1, 2, 0)result = (result * 255.0).round()result = result[:, :, ::-1]crop_mask = np.minimum.reduce(crop_mask_list).clip(0, 1)dstimg = paste_back(target_img, result, crop_mask, affine_matrix)return dstimg

2.1.5图像增强

此处采用的模型是gfpgan_1.4,用于人脸图像增强,旨在提高图像的清晰度和质量,使得换脸效果更为自然逼真。

  1. 模型初始化
    同上上一步一致。
  2. 图像处理和增强
  • 图像预处理
    • 人脸对齐:使用 warp_face_by_face_landmark_5 函数将目标图像按人脸特征点进行裁剪和对齐。
    • 创建遮罩:使用 create_static_box_mask 创建静态盒子遮罩,方便后续将增强结果融合回原图像。
    • 归一化处理:将图像像素值从原始范围 [0, 255] 转换到 [-1, 1],这有助于提高模型的性能。
  • 模型推理
    • 图像增强推理:使用 ONNX Runtime 对预处理后的图像进行推理,得到增强后的图像。
    • 结果处理:将增强后的图像从 [-1, 1] 转换回 [0, 255] 的范围,并转换为 uint8 类型。(这一步是不是量化?)
  • 融合增强结果
    • 融合处理:将增强后的图像融合回原图像中,确保增强区域自然逼真。
    def process(self, target_img, target_landmark_5):###preprocesscrop_img, affine_matrix = warp_face_by_face_landmark_5(target_img, target_landmark_5, 'ffhq_512', (512, 512))box_mask = create_static_box_mask((crop_img.shape[1],crop_img.shape[0]), FACE_MASK_BLUR, FACE_MASK_PADDING)crop_mask_list = [box_mask]crop_img = crop_img[:, :, ::-1].astype(np.float32) / 255.0crop_img = (crop_img - 0.5) / 0.5crop_img = np.expand_dims(crop_img.transpose(2, 0, 1), axis = 0).astype(np.float32)###Perform inference on the imageresult = self.session.run(None, {'input':crop_img})[0][0]###normalize_crop_frameresult = np.clip(result, -1, 1)result = (result + 1) / 2result = result.transpose(1, 2, 0)result = (result * 255.0).round()result = result.astype(np.uint8)[:, :, ::-1]crop_mask = np.minimum.reduce(crop_mask_list).clip(0, 1)paste_frame = paste_back(target_img, result, crop_mask, affine_matrix)dstimg = blend_frame(target_img, paste_frame)return dstimg
    

最终结果展示

  • 源图片
    在这里插入图片描述
  • 目标图片
    在这里插入图片描述
  • 最终结果
    在这里插入图片描述

四、结论

本项目通过使用多个先进的深度学习模型,实现了高效且逼真的AI换脸功能。首先,利用YOLOface_8n模型进行人脸检测,并通过face_68_landmarks模型获取面部68个关键点,确保了检测结果的精确性和一致性。接着,arcface_w600k_r50.onnx模型提取源人脸的高维特征向量,通过对齐和归一化处理,确保特征向量的稳定性和准确性。然后,inswapper_128.onnx模型负责将源人脸特征嵌入到目标人脸图像中,实现自然逼真的人脸替换。最后,使用gfpgan_1.4.onnx模型对换脸结果进行图像增强和修复,进一步提高图像的清晰度和细节,使最终结果更加自然逼真。本项目展示了AI换脸技术的强大潜力和广泛应用前景,为影视制作、社交媒体和隐私保护等领域提供了有力的技术支持

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

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

相关文章

static在C/C++中的作用

C语言中 static 的作用: 文件作用域的全局变量: 当static修饰一个全局变量时,这个变量只在定义它的文件内部可见,不会被其他文件访问。 静态局部变量: 在函数内部,static关键字确保局部变量的生命周期贯穿整…

目前公认最好用充电宝!四款高性价比充电宝推荐,一文看懂!

当我们在旅行途中,手机和相机等设备必不可少。长时间使用这些设备,电量很容易耗尽。此时,充电宝就能派上用场,让我们在欣赏美景、记录美好时光的同时,不再担心电量不足。特别在假期出游的时候在我们玩的特别尽兴的时候…

力扣2815.数组中的最大数对和

力扣2815.数组中的最大数对和 遍历每个元素 并求其中最大的数字 将每个数字对应的最大元素存在数组中每遍历到一个新的元素 &#xff0c;将其存在对应的数组位置中 class Solution {public:int maxSum(vector<int>& nums) {vector<int> cnt(10,INT_MIN);int…

【C++】关于虚函数的理解

深入探索C虚函数&#xff1a;原理、应用与实例分析 一、虚函数的原理二、虚函数的应用三、代码实例分析四、总结 在C面向对象编程的世界里&#xff0c;虚函数&#xff08;Virtual Function&#xff09;扮演着至关重要的角色。它不仅实现了多态性这一核心特性&#xff0c;还使得…

查看linux服务器cpu,硬盘,内存

lscpu 查看cpu 释义 Architecture: x86_64 // 指定系统架构&#xff0c;这里是 x86_64&#xff0c;表示一个64位系统。 CPU op-mode(s): 32-bit, 64-bit // 指示支持的 CPU 操作模式&#xff0c;显示了32位和64位两种模式。 Byte Order: Little…

红酒与珠宝:璀璨与醇香的奢华交响,双重诱惑难挡

在璀璨的灯光下&#xff0c;红酒与珠宝各自闪耀着迷人的光芒&#xff0c;它们如同夜空中的繁星&#xff0c;交相辉映&#xff0c;共同演绎着奢华的双重诱惑。今天&#xff0c;就让我们一起走进这个充满魅力的世界&#xff0c;感受红酒与珠宝带来的无尽魅力。 首先&#xff0c;让…

java中自定义线程池最佳实践

java中自定义线程池最佳实践 在现代应用程序中&#xff0c;线程池是一种常用的技术&#xff0c;可以有效管理和复用线程资源&#xff0c;从而提升系统的并发性能和稳定性。本文将详细介绍自定义线程池的最佳实践&#xff0c;涵盖从线程池大小配置、队列选择到拒绝策略、任务设…

基于STM32+ESP8266打造智能家居温湿度监控系统(附源码接线图)

摘要: 本文将介绍如何使用STM32单片机、ESP8266 Wi-Fi模块和Python Flask框架构建一个完整的物联网系统&#xff0c;实现传感器数据采集、无线传输、云端存储及Web可视化展示。 关键词: STM32, ESP8266, 传感器, Flask, 物联网, 云平台, 数据可视化 1. 系统概述 本系统以STM…

Spring底层原理之proxyBeanMenthod实例 动态代理 反射 Bean的拦截

proxyBeanMenthod 假设我们要进行一个系统的二次开发 然后第一次开发我们实用的是XML声明bean 二次开发的时候要用注解 我们如何把bean都加载上来呢 我们首先创建一个全新的配置类 package com.bigdata1421.config;public class SpringConfig32 { } 我们创建一个APP 加载…

Perl语言入门学习读物

1. PERL 是什么? Perl 最初的设计者为Larry Wall&#xff0c;Perl借取了C、sed、awk、shell scripting以及很多其他程序语言的特性。Perl一般被称为“实用报表提取语言”(PracticalExtraction andReportLanguage)&#xff0c;有时也被称做“病态折中垃圾列表器”(Pathologica…

springboot+vue3无感知刷新token实战

目录 一、java后端 1、token构造实现类 ①验证码方式实现类 ②刷新token方式实现类 2、token相关操作&#xff1a;setCookie ①createToken ②refreshToken 二、前端&#xff08;vue3axios&#xff09; web网站中&#xff0c;前后端交互时&#xff0c;通常使用token机制…

全球最快的 JSON 文件查询工具

本文字数&#xff1a;1684&#xff1b;估计阅读时间&#xff1a;5分钟 审校&#xff1a;庄晓东&#xff08;魏庄&#xff09; 本文在公众号【ClickHouseInc】首发 介绍 在 ClickHouse&#xff0c;我们热衷于基准测试和性能优化。所以当我第一次看到 Hacker News 上那篇“查询大…

代码随想录算法训练营day31|134.加油站、135. 分发糖果、406.根据身高重建队列

134.加油站 如下图所示&#xff1a; 当索引一道2的时候&#xff0c;剩余油量的总量13-6 < 0&#xff0c;这个时候说明以索引0为起点不合适&#xff0c;将起点更新为索引3. 两点证明&#xff1a; 1.如果我们从蓝色段中间选一个点开始&#xff0c;是不是最后sumGas就不小于0…

从灵感到成品:使用AI生成博客文章的完整指南

在信息爆炸的时代&#xff0c;每个人都有讲述自己故事的权利和需求。博客作为一种表达方式&#xff0c;不仅能记录个人经历&#xff0c;还能分享知识和观点。然而&#xff0c;许多人在写博客文章时&#xff0c;常常会遇到灵感枯竭、时间不够用或者不知道如何开始等问题。幸运的…

光伏储能系统/安科瑞DTSD1352-CF双向计量表-安科瑞 蒋静

1 长期以来&#xff0c;我国施行居民用电低价政策&#xff0c;居民电价大幅低于供电成本&#xff0c;虽然实施了全天分三时段的阶梯电价政策&#xff0c;但过去近10年中高峰节电的效果却不够明显。从分时用电运作机制来看&#xff0c;居民用电价格的波动幅度不大&#xff0c;但…

华为云鲲鹏架构docker部署2048小游戏

华为云鲲鹏架构docker部署2048小游戏 1. 鲲鹏架构ESC2. 配置docker3. 上传2048镜像4. 删除容器,镜像 1. 鲲鹏架构ESC 2. 配置docker 安装dockeryum -y install docker开机启动 systemctl enable docker启动docker服务 systemctl start docker查询docker的运行版本 docker -v3…

时序分析基本概念介绍——min pulse width 最小脉冲宽度

文章目录 前言一、什么是 min pulse width&#xff1f;二、为什么检查 min pulse width&#xff1f;三、如何设置 min pulse width约束&#xff1f;1. 在sdc里面定义2. library里面定义 四、如何检查 min pulse width&#xff1f;五、如何修复 min pulse width&#xff1f;总结…

docker启动ws-scrcpy和redroid记录

git克隆最新的ws-scrcpy代码 git clone gitgithub.com:NetrisTV/ws-scrcpy.git进入ws-scrcpy目录新建Dockerfile文件&#xff0c;内容如下 FROM node:16-alpine WORKDIR /appRUN npm config set registry http://mirrors.cloud.tencent.com/npm/ RUN npm install -g node-gyp…

攻防世界-Web题目1

目录 cookie 1、题目 2、知识点 3、思路 get_post 1、题目 2、知识点 3、思路 disabled_button 1、题目 2、知识点 3、思路 backup 1、题目 2、知识点 3、思路 cookie 1、题目 2、知识点 cookie&#xff0c;数据包 3、思路 题目提示我们cookie&#xff0c;抓…

Markdown中如何插入空行和空格

Markdown 是一种轻量级的标记语言&#xff0c;它的主要目标是以易读易写为优先&#xff0c;并兼容 HTML。虽然 Markdown 本身对于排版的要求比较宽松&#xff0c;但在某些情况下&#xff0c;我们可能需要在文档中插入空行或空格来达到特定的排版效果。 插入空行 在Markdown中…