如何在Flutter应用中使用 OpenCV和 CC++库进行图像流处理

这篇具有很好参考价值的文章主要介绍了如何在Flutter应用中使用 OpenCV和 CC++库进行图像流处理。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

本文将帮助你在 Android 和 iOS 中为 Flutter 应用程序集成 C/C++ 插件。

问题1: Flutter camera 插件没有为快速复杂的图像流处理提供完整的指南。

问题2: Flutter camera 插件处理图像流太慢。

问题3:图像处理需要OpenCV包

问题4:你当前的图像流处理实现正在阻塞 UI 并导致你的应用程序滞后和内存泄漏

问题5:缺乏实现接口和使用 Dart FFI 库有效集成 C/C++ 库的知识

本指南将通过使用 OpenCV C++ 构建适用于 Android 和 iOS 的示例应用程序来解决每个问题,以进行图像流处理。

先决条件

在开始之前,需要一个带有 OpenCV C++ 的 Flutter 插件及其适用于 Android 的静态库以及适用于 iOS 的 xcframework。本文不会介绍如何执行此操作,但你可以按照下方链接提供的指南或在我提供的 GitHub 存储库中运行脚本文件。

  • 指南:https://medium.com/@khaifunglim97/how-to-build-a-flutter-app-with-c-c-libraries-via-ffi-on-android-and-ios-including-opencv-1e2124e85019

  • 脚本文件:https://github.com/khaifunglim97/flutter_ffi_examples

对于 Android,确保 CMakeLists.txt 构建所需的库,并将 build.gradle 设置为以下值,防止OpenCV 产生错误。

externalNativeBuild {            
    cmake {                
        cppFlags "-frtti -fexceptions -std=c++17"
        abiFilters 'armeabi-v7a', 'arm64-v8a'            
    }        
}

对于 iOS,确保 podspec 下的 vendored_frameworks 包含 OpenCV 的 xcframework。

此外,本指南假设有一个工作状态小部件,类似于 flutter camera 插件中提供的简单示例,其中包含一个 CameraPreview 小部件及其所需的设置。

示例:https://pub.dev/packages/camera

假设

本指南仅承诺在手机后置摄像头上以纵向模式进行图像处理的工作模型,设置捕获分辨率为720p (1280x720)。但是,它可以作为读者集成其他相机、设备方向模式和捕获分辨率的指南。

注意:本指南仅在适用于 Android 的 Android NDK 版本 25.0.8775105 上进行测试。它可能无法使用其他 NDK 版本成功构建 C/C++ 文件。

建议的结构

如何在Flutter应用中使用 OpenCV和 CC++库进行图像流处理

相机图像流处理的建议结构

移动设备上任何流处理的主要挑战是处理帧的速度足够快,以每秒至少处理 60 帧(每帧约 16 毫秒)。传统的图像处理过程几乎总是需要比每帧 16 毫秒多得多的时间。因此,必须使用后台线程来不阻塞 UI 并导致应用程序卡顿。

我们需要利用 Dart Isolates 来实现这一点。包含 CameraPreview 小部件的视图旁边将有一个 CameraProcessor 小部件,这样每个帧都将被传递给处理器进行处理。处理器将依次生成Isolate,以运行 C/C++ OpenCV 代码,并异步等待它们完成。

最后,从处理器中的Isolate接收到的任何结果都将传递给 CameraPainter 以更新 UI 状态并显示结果。尽管具有流畅的 UI,但这种方法的缺点是响应延迟,其中 UI 和Painter将显示不匹配,其中绘制的结果来自先前捕获的帧,具体取决于设备和处理速度

相机视图/相机预览

camera 插件页面中提供的示例足以构建最小的相机视图。我们只需要在小部件内添加帧捕获和相机处理器,如下所示:

// Start frame capture in controller under initState()
...
try {
    await controller?.initialize();
    controller?.startImageStream(_processCameraImage);
    setState(() {});
} catch (e) {
...

// Set what to do with frames inside stateful widget
Future _processCameraImage(CameraImage camImage) async {
   if (!mounted) return;    
   setState(() {
      cameraImage = camImage;
   });
}

// Pass each frame into CameraProcessor under build()
CameraPreview(
    camController,
    child: CameraProcessor(
        image: camImg,
    ),
)

CameraPainter

此处的指南为Painter 展示了一些不错的模板:https://github.com/bharat-biradar/Google-Ml-Kit-plugin/tree/master/packages/google_ml_kit/example/lib/vision_detector_views)

我们需要将要绘制到画布上的对象传递给这个小部件,并使用绘制函数来显示结果。需要注意的最重要的事情是图像的旋转,以及它如何影响 CameraPreview 和 CameraImage 的相对坐标。

CameraProcessor

CameraProcessor 将处理从_processCameraImage 下的CameraView 传递给它的每一帧。通过让 CameraProcessor 成为 CameraPreview 的覆盖,CameraProcessor 将负责通过CameraPainter 更新预览覆盖。

@override
Widget build(BuildContext context) {
    _processCameraImage(widget.image);
    return Scaffold(
        extendBodyBehindAppBar: true,
        appBar: AppBar(backgroundColor: Colors.transparent,),
        backgroundColor: Colors.transparent,
        body: Stack(
            children: <Widget>[
                if (customPaint != null) customPaint!
            ],
        ),
    );
}

注意:示例应用程序将 CameraPreview 设计为全屏小部件,因此同一页面下没有 Scaffolds 来显示 snackbar 错误。因此,一个 Scaffold 被添加到处理器中,以显示在 CameraPreview 之上的流处理期间引发的任何错误。

相机帧的所有处理都将在*_processCameraImage中,包括生成和等待isolates。因此,有必要确保一次运行一个_processCameraImage*,以避免产生大量繁重任务导致应用程序和设备崩溃。

举个例子:

bool isBusy = false;

Future _processCameraImage(CameraImage camImage) async {
    // Ensure a single process is ran at a time   
    if (isBusy) return;
    isBusy = true;
    final resConvert = await spawnAndConvertImage(camImage);
    
    Uint8List? rgbaBytes = resConvert[keyConvert];
    if (haarCascadesLoaded && rgbaBytes != null) {
        // Portrait capture of Android cam images are in landscape
        // while iOS cam images are in portrait
        int height = Platform.isAndroid 
                     ? camImage.width 
                     : camImage.height;
        int width = Platform.isAndroid 
                    ? camImage.height 
                    : camImage.width;
        final resDetect = 
            await spawnAndDetect(rgbaBytes, height, width);
        Faces? detected = resDetect[keyDetect];
        
        if (detected != null && detected.count > 0) {
            if (!mounted) return;
            setState(() {
                customPaint = CustomPaint(
                    size: Size.infinite, // Full screen preview
                    painter: CameraPainter(
                        detected,
                        Size(width.toDouble(), height.toDouble())
                    )
                );
           });
       } else {
           if (!mounted) return;
           setState(() {
               // To remove any painted elements on overlay
               customPaint = null;
           });
       }
    isBusy = false;
}

Isolates

如 CameraProcessor 所示,spawnAndConvertImagespawnAndDetect都是生成 Isolates 的函数,以便后台线程处理它们。这是必要的,这样缓慢的进程不会阻塞 UI 并允许应用程序继续运行。

示例指南:https://github.com/dart-lang/samples/blob/master/isolates/bin/send_and_receive.dart

只要你知道如何在 OpenCV Mat 中读取,OpenCV 中的处理可以支持任何图像格式。Flutter 相机捕获图像并传递诸如 Android 的 yuv420 和 iOS 的 bgra888 之类的 CameraImage。

将图像字节数据的任何传递标准化为 OpenCV C++ 是一个很好的做法,我发现 BGRA 是 OpenCV 读取输入以及使用图像包(https://pub.dev/packages/image)转换为 Flutter Image 的最简单格式。

为什么我们使用isolate和OpenCV将CameraImage转换为BGRA字节数据?

因为在 Flutter 中将 CameraImage 转换为 Flutter Image 与 C++ 转换相比非常慢,这反过来会导致 UI 卡顿且无响应。这个问题相信大部分使用前文提到的 camera 插件的用户都会遇到。

static Future<Map<String, dynamic>> spawnAndConvertImage(
    CameraImage image) async {    
    final p = ReceivePort();    
    await Isolate.spawn(_convertCamImageToBgra, [      
        p.sendPort,      
        image    
    ]);    
    return (await p.first) as Map<String, dynamic>;  
}   

static void _convertCamImageToBgra(List<dynamic> args) async { 
    SendPort responsePort = args[0];    
    CameraImage image = args[1];    
    Uint8List? outBgra;    
    
    try {      
        if (image.format.group == ImageFormatGroup.yuv420) { 
            outBgra = FFIBindings.convertAndroidCamImage2Bgra( 
                    image.planes[0].bytes,
                    image.planes[1].bytes,
                    image.planes[2].bytes,
                    image.planes[0].bytesPerRow,
                    image.planes[0].bytesPerPixel!,
                    image.planes[1].bytesPerRow,
                    image.planes[1].bytesPerPixel!,
                    image.width,
                    image.height);      
       } else if (image.format.group == ImageFormatGroup.bgra8888) {
           outBgra = image.planes[0].bytes;      
       }
    } catch (e) {
    // Catch errors
    }
    
    Map result = <String, dynamic>{};
    result[keyConvert] = outBgra;
    Isolate.exit(responsePort, result);  
}

官方 isolates 模板:https://github.com/dart-lang/samples/blob/master/isolates/bin/send_and_receive.dart

由于 iOS 已经返回 BGRA 格式的图像字节,我们不需要转换图像。

FFI 绑定和 C/C++ 代码

FFI 绑定充当 C/C++ 代码和 Dart 代码之间的接口。这是应该正确处理动态内存的地方,这样来自 Dart 的调用者就不需要手动管理内存。如果你使用现成的 C/C++ 文件,ffigen 包可以帮助你自动生成此类绑定,但该包通常会返回带有指针的绑定,其中调用者仍需要内存分配。

ffigen 包:https://pub.dev/packages/ffigen

因此,鼓励在 ffigen 生成的绑定文件之后编写自己的绑定作为一个很好的参考。

这里是 Dart 绑定的工作示例和FFIBindings.convertAndroidCamImage2Bgra的 C 代码。

  • https://github.com/khaifunglim97/flutter-image-processing/blob/master/lib/camera/camera_bindings.dart

  • https://github.com/khaifunglim97/flutter-image-processing/blob/master/ios/Classes/converter.c

使用 OpenCV 加快处理速度的建议

注 1:由于我们正在传递 BGRA,因此需要以 CV_8UC4 格式读取字节数据(将图像数据从 CameraPreview 导入 C++ 代码的最快且一致的方式)到 OpenCV Mat 对象中。

如果要将 BGRA 转换为 BGR,则格式将为 CV_8UC3,而传递灰度(直接从 Android CameraImage 的平面 0 字节/在 iOS 中将 BGRA 转换为灰色)将为 CV_8UC1。

注 2:大多数情况下,整个相机预览并不构成感兴趣的区域。因此,建议尽可能减小处理图像的大小,以加快每个处理循环。可以通过根据相机控制器中设置的 ResolutionPreset 的固定大小裁剪图像或将图像调整为较低分辨率来完成。

注 3:由于初始化和用户对目标聚焦的调整,相机图像流通常会出现模糊期。因此,可以使用诸如快速傅里叶变换 (FFT) 或拉普拉斯算子之类的模糊检测技术,来确定任何进一步的处理。

每当需要进一步处理时,可以将得分最高的帧/图像保存在有状态小部件 (CameraProcessor) 中。请注意,得分最高的帧将需要在一段时间后重置,以避免处理旧帧而不是最近的帧。

注 4:任何需要加载模型文件的处理都应该从initState调用C++的初始化和释放调用,并从处理器的有状态小部件释放。这是为了确保模型只加载一次并正确处理以避免内存泄漏。

要使用此类模型,你需要将文件添加到资源中,从 rootBundle 将它们加载到内存中并编写一个新文件,以便你可以获得要传递给需要模型路径的 OpenCV 函数的文件路径。

这里有一个例子:https://github.com/khaifunglim97/flutter-image-processing/blob/master/lib/face_detector/face_detector_bindings.dart

但是,如果这对你来说仍然太慢,更好的方法是将模型文件的内容转换为可以包含并直接从 C++ 代码调用的变量。

注5:图像的输入/返回字节数据将是Uint32而不是Uint8。因此,需要分配 sizeOf() * width * height 的内存。但是,我们可以使用现有的 ptr.ref.asTypedList(width * height).buffer.asUint8List() 函数将 Uint32List 转换回 Uint8List 以供外部使用。

FFI 绑定指南

  1. 将查找代码添加到将与插件一起构建的共享库中。例子:

final DynamicLibrary nativeLib = Platform.isAndroid
    ? DynamicLibrary.open('libnative.so')
    : DynamicLibrary.process();
  1. 在 Uint8List 上准备一个通用扩展来创建一个指针:

// Credits to Tims !
extension Uint8ListBlobConversion on Uint8List {
    Pointer<Uint8> allocatePointer() {
        final blob = calloc<Uint8>(length);
        final blobBytes = blob.asTypedList(length);
        blobBytes.setAll(0, this);
        return blob;
    }
}
  1. 通过特征而不是同一文件中的所有绑定将绑定添加到插件类中。例子:

Future<String?> getPlatformVersion() {
    ...
}
static final cameraBindings = CameraBindings(nativeLib);
static final FaceDetectorBindings = FaceDetectorBindings(nativeLib);
...
  1. 在每个绑定类中,公共函数应该有参数和 Dart 类型的返回对象,而不是 FFI 指针。这是为了确保指针的所有内存管理都包含在绑定中。

Uint8List convertAndroidCamImage2Bgra(
    Uint8List plane0Bytes,
    Uint8List plane1Bytes,
    Uint8List plane2Bytes,
    int yBytesPerRow,
    int yBytesPerPixel,
    int uvBytesPerRow,
    int uvBytesPerPixel,
    int width,
    int height
) {
    ...
    return Uint8List.fromList(...); // Copy the value out
}

注意:从指针返回值总是需要将指针的 ref 值复制到 Dart 对象中,这样当指针被释放时,值仍然存在。

  1. 在动态内存分配(malloc / calloc)之后始终将你的代码放入 try 块中,以便在任何失败的情况下,将执行malloc.free() ,释放所有先前分配的内存的 finally 块,以避免任何内存泄漏。 例子:

Pointer<ExampleStruct> ptrStruct = malloc<ExampleStruct>();
Pointer<Uint8> ptrData = uInt8ListData.allocatePointer();
Pointer<Utf8> ptrStr = "test".toNativeUtf8();

try {
    ... // Perform call to C/C++ code
} finally {
    malloc.free(ptrStruct);
    malloc.free(ptrData);
    malloc.free(ptrStr);
}
  1. 对于结构体,它需要遵循与 Dart 中 C/C++ 结构完全相同的类型。

    此外,建议创建一个扩展 FFI Struct 的类,并在 Dart 代码中使用相同的类。例子:

// C++ struct
typedef struct Coordinate {
    int x;
    int y;
    unsigned char* data;
} Coordinate;

// FFI Bindings struct
class Coordinate extends ffi.Struct {
    @Int32()
    external int x;
    @Int32()
    external int y;
    external Pointer<Uint8> data;
}

// Dart struct
class DartCoordinate {
    late int x;
    late int y;
    late Uint8List data;
    DartCoordinate.fromCoordinate(Coordinate coord) {
        x = coord.x;
        y = coord.y;
        data = Uint8List.fromList(coord.data.asTypedList().buffer)
    }
}

// To allocate a struct
Pointer<Coordinate> ptr = malloc<Coordinate>();
Pointer<Uint8List> ptrData = data.allocatePointer();
ptr.ref.x = x;
ptr.ref.y = y;
ptr.ref.data = ptrData;
  1. 处理列表(C/C++ 数组和双指针)。例子:

// C++ struct
typedef struct Point {
    Coordinate* coordinates;
    int count // there should always be a count for list size
} Point;

// FFI Bindings struct
class Point extends ffi.Struct {
    external Pointer<Point> coordinates;
    @Int32()
    external int count;
}

// Dart struct
class DartPoint {
    late List<DartCoordinate> coordinates;
    DartPoint.fromPoint(Point point) {
        final dartCoords = <DartCoordinate>[];
        for (int i = 0; i < point.count; i++) {
            final coord = point.coordinates[i];
            dartCoords.add(DartCoordinate.fromCoordinate(coord);
        }
        coordinates = dartCoords;
    }
}

// To allocate (should be in C/C++ function)
Point* point = (Point*) malloc(pointCount * sizeof(*point));
point.count = pointCount;
for (int i=0; i < pointCount; i++) {
    point[i] = (Coordinate) {x, y};
    point[i].data = data;
}

// To free (should be in Dart)
// ensure each child pointer is freed
for (int=0; i < pointPtr.count; i++) {
    malloc.free(pointPtr.coordinates[i]);
}
malloc.free(pointPtr); // then free the struct

C/C++ 代码指南

  1. 确保所有静态库的导入都用 <> 而不是“”括起来。只有 /ios/Classes/ 目录中的 C/C++ 文件才能用“”括起来以表示#include。

  2. 当某些静态库使用不同的头文件构建或 Android 和 iOS 平台的要求不同时,对 Android 使用 #ifdef ANDROID ,对 iOS 使用 #else。

  3. Android 的日志记录将与 iOS 不同。

  4. .cpp 文件的头文件 (.h) 必须具有外部“C”及其保护。

// OpenCV tutorials often #include "opencv2/imgproc.hpp"
#include <opencv2/imgproc.hpp>
#include "test.h"

// Include different files for Android and iOS
#ifdef __ANDROID__
#include <android/log.h>
#include <someAndroidHeader.h>
#else
#include <someiOSHeader.h>
#endif

// Logging for Android and iOS
#ifdef __ANDROID__
    __android_log_print(ANDROID_LOG_DEBUG, "title", "%d", integer);
#else
    std::cout << "title: " << integer << "\n";
#endif

// Cpp header files must haves
#ifdef __cplusplus
extern "C" {
#endif
    int GetCoordinate(Coordinate* outCoordinate);
#ifdef __cplusplus
}
#endif

5 — C/C++ 函数应该返回整数(状态码)并使用指针来修改和返回值。这是为了确保错误代码可以从 C/C++ 函数传回以进行日志记录,并将内存管理仅推送到 FFI 绑定而不是内部 C/C++ 代码。

// in C file
int GetCoordinate(Coordinate* outCoordinate) {
    outCoordinate->x = getX();
    outCoordinate->y = getY();
    outCoordinate->data = getData();
    return 0;
}

6 — 如果需要从 C++ 函数返回 std::string,则该函数的返回类型应为 const char *。

// in C++ file
const char* GetString() {
    std::string strTest = "test"
    return strTest.c_str();
}

// in Dart
getString().toDartString();

GitHub 仓库

https://github.com/khaifunglim97/flutter-image-processing

这个 repo 带有使用 OpenCV 的简单面部和眼睛检测,旨在为读者提供一个基本的模板和指南来构建他们的图像处理应用程序。

☆ END ☆

如果看到这里,说明你喜欢这篇文章,请转发、点赞。微信搜索「uncle_pn」,欢迎添加小编微信「 woshicver」,每日朋友圈更新一篇高质量博文。

扫描二维码添加小编↓文章来源地址https://www.toymoban.com/news/detail-475246.html

到了这里,关于如何在Flutter应用中使用 OpenCV和 CC++库进行图像流处理的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • “探索图像处理的奥秘:使用Python和OpenCV进行图像和视频处理“

     1、上传图片移除背景后下载。在线抠图软件_图片去除背景 | remove.bg – remove.bg 2、对下载的图片放大2倍。ClipDrop - Image upscaler  3、对放大后的下载照片进行编辑。  4、使用deepfacelive进行换脸。 1)将第三步的照片复制到指定文件夹。C:myAppdeepfakelivetempDeepFaceLive_NVIDIAuserda

    2024年02月16日
    浏览(96)
  • 使用Python和OpenCV进行图像处理和分析

    简介: 图像处理和分析是计算机视觉领域的重要组成部分。本文将介绍如何使用Python编程语言和OpenCV库进行图像处理和分析。我们将涵盖图像读取、显示、滤波、边缘检测和图像分割等常见的图像处理操作,并提供相应的代码示例。 安装OpenCV: 首先,我们需要安装OpenCV库。

    2024年02月12日
    浏览(62)
  • 如何使用OpenCV库进行图像检测

    import cv2 # 加载Haar级联分类器 face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + \\\'haarcascade_frontalface_default.xml\\\') # 读取输入图像 img = cv2.imread(\\\'input_image.jpg\\\') gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 使用Haar级联分类器进行人脸检测 faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors

    2024年02月16日
    浏览(41)
  • 谈谈如何使用 opencv 进行图像识别

    原文由hakaboom发表于TesterHome社区,点击原文链接可与作者直接交流。 从18年开始,我接触了叉叉助手(平台已经被请喝茶了),通过图色识别,用来给常玩的游戏写挂机脚本,写了也有两三年.也算是我转行当游戏测试的理由. 去年11月,也是用了这身技术,混进了外包,薪资还不错,属于是

    2024年02月10日
    浏览(66)
  • 如何使用OpenCV进行图像读取和显示?

    使用OpenCV进行图像读取和显示是计算机视觉领域中的常见任务之一。下面是关于如何使用OpenCV进行图像读取和显示的简要步骤和示例代码。    首先,你需要安装OpenCV库并确保正确导入它。然后,按照以下步骤执行图像读取和显示操作: 导入OpenCV库: 读取图像文件: 在这个

    2024年02月06日
    浏览(47)
  • Python图像处理:使用OpenCV对图像进行HSV和RGB表示法的转换

    Python图像处理:使用OpenCV对图像进行HSV和RGB表示法的转换 在图像处理中,我们经常需要使用不同的颜色表示法来处理图像。在OpenCV中,我们可以使用HSV(色相、饱和度、亮度)表示法来替代标准的RGB(红、绿、蓝)表示法来处理图像。HSV表示法更为直观和易于使用,因为它将

    2024年02月06日
    浏览(77)
  • 详细介绍如何使用 OpenCV 对图像进行锐化

    将了解锐化图像的过程 , 我们将使用内核来突出显示每个特定像素并增强其发出的颜色。 它与模糊过程非常相似,只不过现在我们不是创建一个内核来平均每个像素强度,而是创建一个内核,该内核将使像素强度更高,因此对人眼来说更加突出。 很高兴知道 内核用于模糊

    2024年02月12日
    浏览(43)
  • 数字图像处理(实践篇)二十二 使用opencv进行人脸、眼睛、嘴的检测

    目录 1 xml文件 2 涉及的函数 3 实践 使用opencv进行人脸、眼睛、嘴的检测。 1 xml文件 方法① 下载  地址:https://github.com/opencv/opencv/tree/master/data/haarcascades 点击haarcascade_frontalface_default.xml文件 对着Raw右键,选择“链接另存为”,选择代码所在的路径即可,就可以下载这个文件啦

    2024年02月03日
    浏览(48)
  • Opencv C++ SIFT特征提取(单图像,多图像)+如何设置阈值+如何对文件夹进行批处理+如何设置掩膜裁剪影像

    SIFT(Scale-Invariant Feature Transform)是一种用于图像处理和计算机视觉的特征提取算法。由David Lowe于1999年首次提出,它是一种非常有效的局部特征描述符,具有尺度不变性、旋转不变性和对部分遮挡的鲁棒性。 SIFT特征提取的主要步骤包括: 尺度空间极值检测(Scale-Space Extrem

    2024年01月19日
    浏览(45)
  • 如何使用Matlab进行图像处理

    图像处理是操纵图像的数字属性以提高其质量或从图像中获得所需信息的过程。它需要在图像处理应用程序中导入图像,分析图像,然后对图像进行操作,以获得能够产生预期结果的适当输出。 在这篇文章中,我们将讨论使用Matlab进行图像处理和分析的基础知识,以确定图像

    2023年04月10日
    浏览(57)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包