这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » DIY与开源设计 » 电子DIY » [ESP-IDF]使用ESP32-S31实现剪刀石头布手势识别

共2条 1/1 1 跳转至

[ESP-IDF]使用ESP32-S31实现剪刀石头布手势识别

高工
2026-07-04 20:22:01     打赏

简介

本篇文章将会简介如何使用ESP32-S31使用摄像头进行输入,然后实现TF-lite的石头剪刀布的图像识别分类。之前我们已经介绍过了如何使用tensorflow来训练Mnist模型。我最近一直在学习使用Pytorch。最初的模型训练是使用的是Pytorch,但是Pytorch训练后的模型到Onnx再到tensorflow lite 的转换相对比较麻烦一点。还是建议大家直接使用Tensorflow进行模型的训练。


模型的数据集采用的是:

sanikamal/rock-paper-scissors-dataset

image.png

在Code选项内已经有很多的作者写了针对这个数据集的不同的代码的实现。我们这里不要使用它们已经实现过的代码。主要的问题是在进行Resnet或者其他模型的迁移学习时。训练出来的模型虽然正确率非常的高,但是模型可能比较大。局限于ESP32S31的flash大小的显示和CPU的主频不建议使用模型进行迁移学习。


这里是直接构建了一个CNN来对模型进行训练。

首先是数据处理的步骤

import random
from pathlib import Path

import kagglehub
import numpy as np
import tensorflow as tf

SEED = 42
tf.keras.utils.set_random_seed(SEED)

print("TensorFlow:", tf.__version__)
print("GPUs:", tf.config.list_physical_devices("GPU"))

CLASS_NAMES = ["paper", "rock", "scissors"]
LABEL_MAP = {name: idx for idx, name in enumerate(CLASS_NAMES)}
IMG_SIZE = 128
BATCH_SIZE = 32
AUTOTUNE = tf.data.AUTOTUNE
DATASET_NAME = "sanikamal/rock-paper-scissors-dataset"

def locate_dataset_root() -> Path:
    download_root = Path(kagglehub.dataset_download(DATASET_NAME))
    candidates = [
        download_root,
        download_root / "Rock-Paper-Scissors",
        download_root / "rock-paper-scissors",
        download_root / "rock_paper_scissors",
    ]
    for candidate in candidates:
        if all((candidate / split_name).exists() for split_name in ("train", "validation", "test")):
            return candidate
    for train_dir in download_root.rglob("train"):
        candidate = train_dir.parent
        if all((candidate / split_name).exists() for split_name in ("train", "validation", "test")):
            return candidate
    raise FileNotFoundError(f"Could not locate train/validation/test under {download_root}")


def infer_label_from_path(path: Path) -> str:
    lowered_name = path.name.lower()
    for class_name in CLASS_NAMES:
        if class_name in lowered_name:
            return class_name
    for part in reversed(path.parts):
        lowered_part = part.lower()
        if lowered_part in LABEL_MAP:
            return lowered_part
    raise ValueError(f"Could not infer label from {path}")


def collect_image_files(split_dir: Path):
    allowed_suffixes = {".png", ".jpg", ".jpeg", ".bmp"}
    files = [
        str(path)
        for path in sorted(split_dir.rglob("*"))
        if path.is_file() and path.suffix.lower() in allowed_suffixes
    ]
    labels = [LABEL_MAP[infer_label_from_path(Path(file_path))] for file_path in files]
    return files, labels


def load_image(path, label):
    image = tf.io.read_file(path)
    image = tf.image.decode_image(image, channels=3, expand_animations=False)
    image.set_shape([None, None, 3])
    image = tf.image.resize(image, [IMG_SIZE, IMG_SIZE], antialias=True)
    image = tf.cast(image, tf.float32) / 255.0
    label = tf.cast(label, tf.int32)
    return image, label


def augment_image(image, label):
    image = tf.image.random_flip_left_right(image)
    image = tf.image.random_contrast(image, lower=0.8, upper=1.2)
    image = tf.clip_by_value(image + tf.random.uniform([], -0.05, 0.05), 0.0, 1.0)
    return image, label


def build_dataset(files, labels, training=False):
    dataset = tf.data.Dataset.from_tensor_slices((files, labels))
    if training:
        dataset = dataset.shuffle(len(files), seed=SEED, reshuffle_each_iteration=True)
    dataset = dataset.map(load_image, num_parallel_calls=AUTOTUNE)
    if training:
        dataset = dataset.map(augment_image, num_parallel_calls=AUTOTUNE)
    dataset = dataset.batch(BATCH_SIZE).prefetch(AUTOTUNE)
    return dataset

然后构建模型

dataset_root = locate_dataset_root()
print("Dataset root:", dataset_root)

train_files, train_labels = collect_image_files(dataset_root / "train")
val_files, val_labels = collect_image_files(dataset_root / "validation")
test_files, test_labels = collect_image_files(dataset_root / "test")

print(f"Train / val / test: {len(train_files)} / {len(val_files)} / {len(test_files)}")

train_ds = build_dataset(train_files, train_labels, training=True)
val_ds = build_dataset(val_files, val_labels, training=False)
test_ds = build_dataset(test_files, test_labels, training=False)

inputs = tf.keras.Input(shape=(IMG_SIZE, IMG_SIZE, 3), name="image")
x = tf.keras.layers.Conv2D(32, 3, padding="same", activation="relu")(inputs)
x = tf.keras.layers.MaxPooling2D()(x)
x = tf.keras.layers.Conv2D(64, 3, padding="same", activation="relu")(x)
x = tf.keras.layers.MaxPooling2D()(x)
x = tf.keras.layers.Conv2D(128, 3, padding="same", activation="relu")(x)
x = tf.keras.layers.MaxPooling2D()(x)
x = tf.keras.layers.Conv2D(128, 3, padding="same", activation="relu")(x)
x = tf.keras.layers.GlobalAveragePooling2D()(x)
x = tf.keras.layers.Dropout(0.3)(x)
x = tf.keras.layers.Dense(128, activation="relu")(x)
outputs = tf.keras.layers.Dense(len(CLASS_NAMES), name="logits")(x)
model = tf.keras.Model(inputs, outputs, name="rock_paper_scissors_cnn")

model.compile(
    optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3),
    loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
    metrics=["accuracy"],
)

model.summary()

训练和模型转换成Tflite

export_dir = Path("exports")
export_dir.mkdir(exist_ok=True)
keras_model_path = export_dir / "rock_paper_scissors_tf.keras"
tflite_path = export_dir / "rock_paper_scissors_tf_int8.tflite"
labels_path = export_dir / "rock_paper_scissors_labels.txt"

callbacks = [
    tf.keras.callbacks.ModelCheckpoint(
        filepath=keras_model_path.as_posix(),
        monitor="val_accuracy",
        save_best_only=True,
        save_weights_only=False,
        verbose=1,
    ),
    tf.keras.callbacks.EarlyStopping(
        monitor="val_accuracy",
        patience=5,
        restore_best_weights=True,
        verbose=1,
    ),
    tf.keras.callbacks.ReduceLROnPlateau(
        monitor="val_loss",
        factor=0.5,
        patience=2,
        verbose=1,
    ),
]

history = model.fit(
    train_ds,
    validation_data=val_ds,
    epochs=20,
    callbacks=callbacks,
)

best_model = tf.keras.models.load_model(keras_model_path.as_posix())
test_loss, test_acc = best_model.evaluate(test_ds, verbose=0)
print(f"Test loss: {test_loss:.4f} | Test accuracy: {test_acc:.4f}")

labels_path.write_text("\n".join(CLASS_NAMES) + "\n", encoding="utf-8")
print("Saved labels:", labels_path)

calibration_ds = (
    tf.data.Dataset.from_tensor_slices((train_files, train_labels))
    .map(load_image, num_parallel_calls=AUTOTUNE)
    .batch(1)
    .take(100)
 )

def representative_dataset():
    for images, _ in calibration_ds:
        yield [tf.cast(images, tf.float32)]

converter = tf.lite.TFLiteConverter.from_keras_model(best_model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8

tflite_model = converter.convert()
tflite_path.write_bytes(tflite_model)
print("Saved TFLite model:", tflite_path)

interpreter = tf.lite.Interpreter(model_path=tflite_path.as_posix())
interpreter.allocate_tensors()
input_details = interpreter.get_input_details()[0]
output_details = interpreter.get_output_details()[0]
print("TFLite input details:", input_details)
print("TFLite output details:", output_details)

sample_images, sample_labels = next(iter(test_ds.take(1)))
sample_image = sample_images[:1].numpy()
scale, zero_point = input_details["quantization"]
if scale == 0:
    raise RuntimeError("TFLite input quantization scale is zero.")
sample_int8 = np.round(sample_image / scale + zero_point).astype(input_details["dtype"])
interpreter.set_tensor(input_details["index"], sample_int8)
interpreter.invoke()
predictions = interpreter.get_tensor(output_details["index"])
predicted_class = int(np.argmax(predictions, axis=-1)[0])
true_class = int(sample_labels[0].numpy())
print("TFLite sample prediction:", CLASS_NAMES[predicted_class], "| label:", CLASS_NAMES[true_class])


训练结果如下所示

Epoch 1/20
2026-07-04 14:58:00.788736: I external/local_xla/xla/stream_executor/cuda/cuda_dnn.cc:454] Loaded cuDNN version 8904
2026-07-04 14:58:01.145795: I external/local_tsl/tsl/platform/default/subprocess.cc:304] Start cannot spawn child process: No such file or directory
2026-07-04 14:58:02.225370: I external/local_xla/xla/service/service.cc:168] XLA service 0x77d315bdc260 initialized for platform CUDA (this does not guarantee that XLA will be used). Devices:
2026-07-04 14:58:02.225421: I external/local_xla/xla/service/service.cc:176]   StreamExecutor device (0): NVIDIA GeForce RTX 3060, Compute Capability 8.6
2026-07-04 14:58:02.229966: I tensorflow/compiler/mlir/tensorflow/utils/dump_mlir_util.cc:269] disabling MLIR crash reproducer, set env var `MLIR_CRASH_REPRODUCER_DIRECTORY` to enable.
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1783148282.310841    2936 device_compiler.h:186] Compiled cluster using XLA!  This line is logged at most once for the lifetime of the process.
79/79 [==============================] - ETA: 0s - loss: 1.0988 - accuracy: 0.3294
Epoch 1: val_accuracy improved from -inf to 0.33333, saving model to exports/rock_paper_scissors_tf.keras
79/79 [==============================] - 9s 49ms/step - loss: 1.0988 - accuracy: 0.3294 - val_loss: 1.0927 - val_accuracy: 0.3333 - lr: 0.0010
Epoch 2/20
76/79 [===========================>..] - ETA: 0s - loss: 1.0418 - accuracy: 0.4379
Epoch 2: val_accuracy improved from 0.33333 to 0.75758, saving model to exports/rock_paper_scissors_tf.keras
79/79 [==============================] - 2s 23ms/step - loss: 1.0370 - accuracy: 0.4397 - val_loss: 0.7098 - val_accuracy: 0.7576 - lr: 0.0010
Epoch 3/20
78/79 [============================>.] - ETA: 0s - loss: 0.5082 - accuracy: 0.7909
Epoch 3: val_accuracy improved from 0.75758 to 0.78788, saving model to exports/rock_paper_scissors_tf.keras
79/79 [==============================] - 2s 24ms/step - loss: 0.5055 - accuracy: 0.7921 - val_loss: 0.4539 - val_accuracy: 0.7879 - lr: 0.0010
Epoch 4/20
79/79 [==============================] - ETA: 0s - loss: 0.2975 - accuracy: 0.8817
Epoch 4: val_accuracy improved from 0.78788 to 0.84848, saving model to exports/rock_paper_scissors_tf.keras
79/79 [==============================] - 2s 22ms/step - loss: 0.2975 - accuracy: 0.8817 - val_loss: 0.3370 - val_accuracy: 0.8485 - lr: 0.0010
Epoch 5/20
76/79 [===========================>..] - ETA: 0s - loss: 0.2256 - accuracy: 0.9165
Epoch 5: val_accuracy improved from 0.84848 to 0.96970, saving model to exports/rock_paper_scissors_tf.keras
79/79 [==============================] - 2s 20ms/step - loss: 0.2233 - accuracy: 0.9175 - val_loss: 0.2346 - val_accuracy: 0.9697 - lr: 0.0010
Epoch 6/20
78/79 [============================>.] - ETA: 0s - loss: 0.1284 - accuracy: 0.9527
Epoch 6: val_accuracy did not improve from 0.96970
79/79 [==============================] - 2s 20ms/step - loss: 0.1284 - accuracy: 0.9528 - val_loss: 0.2528 - val_accuracy: 0.9394 - lr: 0.0010
Epoch 7/20
76/79 [===========================>..] - ETA: 0s - loss: 0.1031 - accuracy: 0.9671
...
Epoch 10: early stopping
Test loss: 0.0984 | Test accuracy: 0.9731
Saved labels: exports/rock_paper_scissors_labels.txt
INFO:tensorflow:Assets written to: /tmp/tmpn07660zz/assets
Output is truncated. View as a scrollable element or open in a text editor. Adjust cell output settings...
INFO:tensorflow:Assets written to: /tmp/tmpn07660zz/assets
/home/king/miniconda3/envs/tf-env/lib/python3.10/site-packages/tensorflow/lite/python/convert.py:953: UserWarning: Statistics for quantized inputs were expected, but not specified; continuing anyway.
  warnings.warn(
2026-07-04 14:58:25.856865: W tensorflow/compiler/mlir/lite/python/tf_tfl_flatbuffer_helpers.cc:378] Ignored output_format.
2026-07-04 14:58:25.856920: W tensorflow/compiler/mlir/lite/python/tf_tfl_flatbuffer_helpers.cc:381] Ignored drop_control_dependency.
2026-07-04 14:58:25.857177: I tensorflow/cc/saved_model/reader.cc:83] Reading SavedModel from: /tmp/tmpn07660zz
2026-07-04 14:58:25.858088: I tensorflow/cc/saved_model/reader.cc:51] Reading meta graph with tags { serve }
2026-07-04 14:58:25.858101: I tensorflow/cc/saved_model/reader.cc:146] Reading SavedModel debug info (if present) from: /tmp/tmpn07660zz
2026-07-04 14:58:25.861346: I tensorflow/compiler/mlir/mlir_graph_optimization_pass.cc:388] MLIR V1 optimization pass is not enabled
2026-07-04 14:58:25.862214: I tensorflow/cc/saved_model/loader.cc:233] Restoring SavedModel bundle.
2026-07-04 14:58:25.898759: I tensorflow/cc/saved_model/loader.cc:217] Running initialization op on SavedModel bundle at path: /tmp/tmpn07660zz
2026-07-04 14:58:25.909544: I tensorflow/cc/saved_model/loader.cc:316] SavedModel load for tags { serve }; Status: success: OK. Took 52368 microseconds.
Summary on the non-converted ops:
---------------------------------
 * Accepted dialects: tfl, builtin, func
 * Non-Converted Ops: 13, Total Ops 26, % non-converted = 50.00 %
 * 13 ARITH ops

- arith.constant:   13 occurrences  (f32: 12, i32: 1)



  (f32: 4)
  (f32: 2)
  (f32: 3)
  (f32: 1)
Saved TFLite model: exports/rock_paper_scissors_tf_int8.tflite
TFLite input details: {'name': 'serving_default_image:0', 'index': 0, 'shape': array([  1, 128, 128,   3], dtype=int32), 'shape_signature': array([ -1, 128, 128,   3], dtype=int32), 'dtype': <class 'numpy.int8'>, 'quantization': (0.003921570256352425, -128), 'quantization_parameters': {'scales': array([0.00392157], dtype=float32), 'zero_points': array([-128], dtype=int32), 'quantized_dimension': 0}, 'sparsity_parameters': {}}
TFLite output details: {'name': 'StatefulPartitionedCall:0', 'index': 23, 'shape': array([1, 3], dtype=int32), 'shape_signature': array([-1,  3], dtype=int32), 'dtype': <class 'numpy.int8'>, 'quantization': (0.09822399169206619, 21), 'quantization_parameters': {'scales': array([0.09822399], dtype=float32), 'zero_points': array([21], dtype=int32), 'quantized_dimension': 0}, 'sparsity_parameters': {}}
TFLite sample prediction: paper | label: paper
fully_quantize: 0, inference_type: 6, input_inference_type: INT8, output_inference_type: INT8
INFO: Created TensorFlow Lite XNNPACK delegate for CPU.

可以看到 准确率已经到达了97% , 已经是一个相当高的一个程度了。


如下图所示为训练好的模型和量化之后的版本。

image.png其中的Model.h 则是对模型转换权重后的数组文件。至此模型的处理已经完成了。


ESP32S31 侧: 摄像头输入和屏幕的展示


这里的代码比较多和负责,这里不会对具体的实现细节进行深入探讨。但是会介绍具体实现的思想。像这种乐鑫的开发板一般都会在组件管理器中具备对应的BSP, 因此在我们使用这样的开发板的话一般可以直接先去组件管理器中进行查询支持情况。如下所示。

image.png这里在Example里有一个display_camera_video的例程。我们可以直接在这个例程上面进行修改也可以创建一个新的项目让AI来参考这个项目实现我们定制的需求: 我这里实现的是屏幕左侧显示摄像头画面,右侧显示文字。

c7120fde9504b24d4d54b85e55e212b0.jpg

在完成屏幕的显示摄像头画面之后在当前的项目中增加tflite的支持。

## IDF Component Manager Manifest File
dependencies:
  ## Required IDF version
  idf:
    version: '>=4.1.0'
  espressif/esp32_s31_korvo_1: ^1.0.0~1
  espressif/esp_video: "~2.2"
  espressif/esp-tflite-micro: "^1.3.7"

然后将上文中我们转换好的model.h 文件复制到当前的项目目录中。然后根据Tflitedemo中的样式来写对应的初始化和推理的函数。

esp_err_t tflite_init(void)
{
    ESP_LOGI(TAG, "Initializing TFLite Micro...");

    /* Initialize TFLite target */
    tflite::InitializeTarget();

    /* Create model from external binary */
    auto model = tflite::GetModel(rock_paper_scissors_tf_int8_tflite);
    if (model->version() != TFLITE_SCHEMA_VERSION) {
        ESP_LOGE(TAG, "Model schema version mismatch");
        return ESP_FAIL;
    }

    /* Create ops resolver with required operations for CNN image classification */
    static tflite::MicroMutableOpResolver<11> resolver;
    resolver.AddConv2D();
    resolver.AddDepthwiseConv2D();
    resolver.AddFullyConnected();
    resolver.AddMaxPool2D();
    resolver.AddAveragePool2D();
    resolver.AddMean();
    resolver.AddReshape();
    resolver.AddSoftmax();
    resolver.AddRelu();
    resolver.AddQuantize();
    resolver.AddDequantize();

    /* Allocate tensor arena in PSRAM (model needs ~640KB) */
    const size_t arena_size = 1024 * 1024;  /* 1MB */
    s_tensor_arena = (uint8_t*)heap_caps_malloc(arena_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
    if (!s_tensor_arena) {
        ESP_LOGE(TAG, "Failed to allocate tensor arena in PSRAM");
        return ESP_FAIL;
    }
    memset(s_tensor_arena, 0, arena_size);

    /* Create interpreter */
    s_interpreter = new tflite::MicroInterpreter(model, resolver, s_tensor_arena, arena_size);
    if (!s_interpreter) {
        ESP_LOGE(TAG, "Failed to create interpreter");
        return ESP_FAIL;
    }

    /* Allocate tensors */
    if (s_interpreter->AllocateTensors() != kTfLiteOk) {
        ESP_LOGE(TAG, "Failed to allocate tensors");
        return ESP_FAIL;
    }

    /* Get input/output tensors */
    s_input_tensor = s_interpreter->input(0);
    s_output_tensor = s_interpreter->output(0);

    if (!s_input_tensor || !s_output_tensor) {
        ESP_LOGE(TAG, "Failed to get input/output tensors");
        return ESP_FAIL;
    }

    ESP_LOGI(TAG, "Init successful!");
    ESP_LOGI(TAG, "Input: %dx%dx%d (bytes=%d)",
             s_input_tensor->dims->data[1],
             s_input_tensor->dims->data[2],
             s_input_tensor->dims->data[3],
             s_input_tensor->bytes);
    ESP_LOGI(TAG, "Input quant: type=%d, scale=%.6f, zero_point=%d",
             s_input_tensor->type,
             s_input_tensor->params.scale,
             s_input_tensor->params.zero_point);
    ESP_LOGI(TAG, "Output: %d classes (bytes=%d)",
             s_output_tensor->dims->data[1],
             s_output_tensor->bytes);

    return ESP_OK;
}

需要注意的是,在这里使用的Resolver里面具备的模型的层级一定要和我们训练时候的使用的CNN的层保持一致。否则的话Tflite将无法解析对应的块。

tflite_result_t tflite_predict_float(uint8_t* input_rgb888, int32_t* class_id, float* confidence)
{
    if (!s_interpreter || !s_input_tensor || !s_output_tensor) {
        ESP_LOGE(TAG, "TFLite not initialized");
        return TFLITE_ERROR;
    }

    /* Convert RGB888 to model input */
    if (s_input_tensor->type == kTfLiteUInt8) {
        memcpy(s_input_tensor->data.uint8, input_rgb888, s_input_tensor->bytes);
    } else if (s_input_tensor->type == kTfLiteInt8) {
        int8_t* input_int8 = s_input_tensor->data.int8;
        for (size_t i = 0; i < s_input_tensor->bytes; i++) {
            input_int8[i] = (int8_t)(input_rgb888[i] - 128);
        }
    } else {
        ESP_LOGE(TAG, "Unsupported input type: %d", s_input_tensor->type);
        return TFLITE_ERROR;
    }

    /* Run inference */
    if (s_interpreter->Invoke() != kTfLiteOk) {
        ESP_LOGE(TAG, "Inference failed");
        return TFLITE_ERROR;
    }

    /* For quantized model, output is int8, need to dequantize */
    /* out_float = (out_int8 - zero_point) * scale */
    const float scale = s_output_tensor->params.scale;
    const int32_t zero_point = s_output_tensor->params.zero_point;

    int32_t best_class = 0;
    float best_score = (float)(s_output_tensor->data.int8[0] - zero_point) * scale;

    for (int i = 1; i < TFLITE_NUM_CLASSES; i++) {
        float score = (float)(s_output_tensor->data.int8[i] - zero_point) * scale;
        if (score > best_score) {
            best_score = score;
            best_class = i;
        }
    }

    *class_id = best_class;
    *confidence = best_score;

    return TFLITE_OK;
}

之后便是将图像的输入,来输入到模型中来进行推理。输出对应的Class ID 和对应的置信度。

image.png上图为摄像头捕获为剪刀时候的打印输出。这里会带来图像内容损失的有一个重大的原因就是摄像头的输入分辨率和模型的输入大小。摄像头的输入比较大。但是模型的输入是128 * 128 .图像需要缩放和变换才能正确的输入到图像中。但是图像的缩放和变换在IDF上并没有像PY那么好的处理的函数,所以会丢失很多细节。在模型的推理中,并不会能带来像我们训练之后一样的置信度。就比如在我们当前97%的正确率下,在ESP32S31上,它会倾向于将我手势的输入识别为布。 对于拳头的识别非常难。如下所示。

26bdc0621f5a3dc388299a075f0673b9.jpg

误识别的布

8004f48077fc4b19e91df1408355b9ec.jpg

这里我觉得影响到图像识别和分类的还有一点原因是因为两者摄像头数据采集的数据的质量是不一样的,改进办法就是使用ESp32-S31上的摄像头采集原始图像然后直接使用摄像头输入分辨率的图像作为数据输入在图像上直接进行CNN然后再做INT8的量化这样再图像的推理阶段就不会有图像的缩放问题了。


结论

可能是工程能力问题也可能是图像的输入的问题,当前的图像的推理和分类的延迟还是非常的大的。基本上就目前的延迟会在5S左右而在之前使用ESP32-S3-CAM进行推理的时候。 帧率和响应的速度也是远远的大于当前的版本。


附件如下

cam_display.zip




关键词: ESP-IDF     ESP32-S31     手势识别    

专家
2026-07-05 07:59:08     打赏
2楼

谢谢分享


共2条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]