ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

python的机器人基础原理工业场景模拟第九十四篇:搬运机械臂,设置多组目标笛卡尔点位,批量执行逆运动学,统计求解失败,无解的点位数量。

python的机器人基础原理工业场景模拟第九十四篇:搬运机械臂,设置多组目标笛卡尔点位,批量执行逆运动学,统计求解失败,无解的点位数量。

《Python机器人基础原理》第九十四篇(修正版):搬运机械臂——多目标点逆运动学批量求解与无解点位统计

声明:本篇全部为课程离线仿真代码,不能直接用于真实机器人控制器,规避工程误导风险。

1. 实际工业应用场景

周五夜班,汽车零部件焊接产线做新批次切换。一台3-DOF平面搬运机械臂(臂展R=0.55 m,额定负载5.0 kg,单轴最大角速度ω_max=2.5 rad/s,重复定位精度±0.05 mm,末端最大线速度0.8 m/s)需要执行48个取放料点位。工艺工程师小王把CAD导出的目标笛卡尔坐标批量导入上位机,点击"自动规划"——系统报出6个点位逆解失败。

小王盯着屏幕皱眉:"这6个点位在CAD里明明在臂展圆内,为什么解不出来?是算法bug还是这些点确实超出工作空间?"

旁边的调试工程师老刘凑过来看:"你的目标点Z坐标不是全零——CAD导出时带了0.1 mm量级的噪声,有些点Z到了0.02 m。这台是纯2D平面搬运臂,第三关节是腕部旋转,没有Z方向的伸缩自由度。Z≠0的点它永远到不了。另外,就算XY在平面内,如果距离基座超过0.55 m,或者刚好在臂展边界上数值奇异,逆解也会失败。"

小王:"那我需要提前离线筛一遍——把所有目标点批量跑一遍逆运动学,统计哪些能解、哪些不能解、无解的原因是什么。别等到了产线上才发现某个点够不到,耽误整条线节拍。"

老刘点头:"写个离线仿真,用解析逆解,3-DOF平面臂,批量跑48个点,输出成功率、失败点坐标、失败原因分类。全部在PC上跑,不动真实设备。"

构型约定:本篇采用3-DOF平面搬运机械臂(2R+1R腕转),基座J1回转,大臂J2俯仰,腕部J3旋转(不改变末端位置,仅改变末端朝向)。正运动学使用标准DH(连杆1 a=0.25 m, α=0°;连杆2 a=0.30 m, α=0°;连杆3 a=0.0 m, d=0.0 m, α=0°)。逆运动学为解析解(几何法),仅求解位置(X, Y),Z固定为0。目标点Z≠0时直接判定为超出工作空间。技术栈限定numpy + matplotlib。

⚠️ 声明:本篇全部代码为课程离线仿真,运行在普通PC上,用于逆运动学批量求解验证。代码中不包含任何真实机器人控制器实时运算或安全逻辑。不可直接用于产线设备。

2. 痛点分析

课程模块 本篇对应的工程痛点

第3章 连杆参数及运动学求解 → 3.4 逆运动学 解析逆解的几何法推导与多解选择

第3章 → 3.3 齐次变换矩阵 正运动学验证逆解正确性

第6章 轨迹规划与控制 → 6.1 路径点生成 批量目标点的可达性预筛选

第9章 仿真实验 → 离线验证 上位机离线校验点位可行性

本篇代码解决 L2 离线仿真校核层。输入:DH参数集、目标笛卡尔点位列表(N个,含X/Y/Z坐标)。处理:①对每个点位检查Z是否为0(工作空间约束);②用几何法求逆解(余弦定理),判断是否在臂展范围内;③对可达点计算双解并按关节角变化量选优;④正运动学校验,误差>1e-6 m的判定为校验失败;⑤统计求解成功率、失败原因分类。输出:成功率饼图 + 失败点散点图 + 可达点双解分布图 + 工作空间边界图 + 4张分析图表。

不覆盖:数值迭代逆解、动力学、力控制、实时控制、多臂协同、带姿态约束的θ₃求解。

3. 核心逻辑讲解

物理直觉

逆运动学就是"已知手在哪里,反推每个关节该转多少度"。对于2R平面臂(两个旋转关节决定末端位置),这本质上是一个"已知三角形三边长度求角度"的几何问题。

想象你的手臂:大臂长L1,小臂长L2,末端要够到距离肩膀为R的一个点。只要R ≤ L1+L2(够得到)且R ≥ |L1-L2|(不会太近导致折叠),就能用余弦定理算出肘关节和肩关节的角度。如果R > L1+L2,点太远,够不到;如果R < |L1-L2|,点太近,小臂完全折叠也碰不到——这两种情况逆解都不存在。

本篇的第三个关节是腕转,不改变位置只改变朝向,所以逆解时先不管它,位置解出来后腕转角由末端朝向需求决定(本篇简化为θ₃=0,预留扩展接口)。

业务逻辑 → 代码映射

输入:目标点位列表 targets = [(x₁,y₁,z₁), (x₂,y₂,z₂), ..., (xₙ,yₙ,zₙ)]

↓

【步骤1:逐点过滤】

for each (x, y, z):

if |z| > ε: 标记"Z超界",continue

R = √(x² + y²)

if R > L1+L2: 标记"超出臂展"

elif R < |L1-L2|: 标记"进入奇异内圈"

else: 进入逆解

↓

【步骤2:几何法逆解】

用余弦定理求肘角θ₂

cos(θ₂) = (R² - L1² - L2²) / (2·L1·L2)

θ₂>0 → elbow-up, θ₂<0 → elbow-down

肩部角θ₁ = atan2(y,x) - atan2(L2·sin|θ₂|, L1+L2·cosθ₂)

(注意:θ₁ = φ - ψ,补偿肘部抬升带来的偏移)

↓

【步骤3:选优】

若提供当前关节角,选变化量小的构型;否则默认elbow-up

↓

【步骤4:正运动学校验】

将逆解代入正运动学,验证末端位置与目标的偏差

若偏差 > 1e-6 m → 标记为"校验失败"

↓

【步骤5:统计与可视化】

成功率、失败分类计数、4张图表

关键公式文字翻译

距离计算:

"R = √(x² + y²)"。目标点到基座的水平距离。如果R超过两臂之和,点在工作空间外。

余弦定理求肘角:

"cos(θ₂) = (R² - L1² - L2²) / (2·L1·L2)"。这是三角形三边求内角的标准公式。θ₂是肘关节的弯曲角(正值表示小臂向上弯,即elbow-up)。如果|cos(θ₂)| > 1,说明三角形不存在——目标点不可达。

肩部角:

"θ₁ = φ - ψ"。φ = atan2(y, x)是目标点的极角,ψ是三角形内角修正量 = atan2(L2·sinθ₂, L1 + L2·cosθ₂)。减号是关键:当肘部向上弯时,末端相对于肩部连线向外偏了ψ角,肩部必须向内收ψ才能把末端拉回目标点。如果写成加号,末端会偏到目标点外侧去——正运动学校验会暴露这个错误。

选优策略:计算两个解对应的关节角变化量(从当前位置到目标位),选变化量小的那个。这减少关节运动幅度和碰撞风险。若未提供当前角,默认选elbow-up(工程惯例,避免小臂向下碰撞工作台)。

4. OOP代码实现

技术栈

限定:numpy, matplotlib。不引入pybullet、roboticstoolbox等任何第三方重型库。全部自实现。

项目结构树

ik_batch_simulation/

├── run_ik_batch_simulation.py

├── requirements.txt

├── robot/

│ ├── __init__.py

│ ├── models.py

│ ├── dh_kinematics.py

│ ├── inverse_kinematics.py

│ ├── batch_processor.py

│ └── visualize.py

├── tests/

│ ├── __init__.py

│ └── test_ik_batch.py

├── results/

└── output/

核心源码

"robot/models.py" — 数据模型

"""

数据模型:DH参数、机器人配置、目标点位

全部参数内置,自包含可复现

"""

import numpy as np

import math

from dataclasses import dataclass, field

from typing import List, Optional

@dataclass

class DHTwistParams:

"""

DH参数集(标准DH)

theta_offset: θ偏移 (rad)

d: 连杆偏距 (m)

a: 连杆长度 (m)

alpha: 扭角 (rad)

name: 连杆名称

"""

theta_offset: float

d: float

a: float

alpha: float

name: str = ""

def __post_init__(self):

for name, val in [("theta", self.theta_offset),

("d", self.d), ("a", self.a),

("alpha", self.alpha)]:

if not math.isfinite(val):

raise ValueError(

f"{self.name} {name}必须为有限数值")

@dataclass

class RobotConfig:

"""

3-DOF平面搬运机械臂配置(2R+1R腕转)

连杆1: J1回转, a=0.25m, α=0°

连杆2: J2俯仰, a=0.30m, α=0°

连杆3: J3腕转, a=0.0m, α=0°

工作空间:Z=0平面,半径|L1-L2|到L1+L2

注意:逆解仅对theta_offset全为0的构型有效

"""

dh_params: List[DHTwistParams] = field(default_factory=list)

z_tolerance: float = 1e-4 # Z方向容差 (m)

dist_tolerance: float = 1e-9 # 距离边界容差 (m)

def __post_init__(self):

if not self.dh_params:

self.dh_params = [

DHTwistParams(0.0, 0.0, 0.25, 0.0, "L1_J1"),

DHTwistParams(0.0, 0.0, 0.30, 0.0, "L2_J2"),

DHTwistParams(0.0, 0.0, 0.0, 0.0, "L3_J3"),

]

# 检查theta_offset是否全为0(逆解前提条件)

for dh in self.dh_params:

if abs(dh.theta_offset) > 1e-12:

raise ValueError(

"逆解仅支持theta_offset=0的构型,"

"非零offset需修改逆解公式")

@property

def l1(self) -> float:

return self.dh_params[0].a

@property

def l2(self) -> float:

return self.dh_params[1].a

@property

def max_reach(self) -> float:

return self.l1 + self.l2

@property

def min_reach(self) -> float:

return abs(self.l1 - self.l2)

@dataclass

class TargetPoint:

"""单个目标点位"""

x: float # m

y: float # m

z: float # m

label: str = ""

def __post_init__(self):

for name, val in [("x", self.x), ("y", self.y), ("z", self.z)]:

if not math.isfinite(val):

raise ValueError(f"TargetPoint {name}必须为有限数值")

@dataclass

class IKResult:

"""单点逆解结果"""

target: TargetPoint

success: bool

reason: str = ""

theta1: Optional[float] = None

theta2: Optional[float] = None

theta3: Optional[float] = None

solution_type: str = "" # "elbow_up" / "elbow_down" / ""

pos_error: float = 0.0

verify_passed: bool = False

index: int = 0

"robot/dh_kinematics.py" — 正运动学

"""

标准DH正运动学:3-DOF平面搬运臂

用于逆解后的正确性校验

"""

import numpy as np

import math

from robot.models import RobotConfig

class DHKinematics:

def __init__(self, config: RobotConfig):

self.config = config

def dh_transform(self, theta, d, a, alpha):

"""单个连杆的标准DH齐次变换矩阵 (4×4)"""

c_t, s_t = math.cos(theta), math.sin(theta)

c_a, s_a = math.cos(alpha), math.sin(alpha)

return np.array([

[c_t, -s_t * c_a, s_t * s_a, a * c_t],

[s_t, c_t * c_a, -c_t * s_a, a * s_t],

[0, s_a, c_a, d],

[0, 0, 0, 1]

], dtype=float)

def forward_kinematics(self, joint_angles: list):

"""

正运动学:链式乘法

joint_angles: [θ1, θ2, θ3]

"""

if len(joint_angles) != 3:

raise ValueError("需要3个关节角[θ1, θ2, θ3]")

T_total = np.eye(4)

for i, dh in enumerate(self.config.dh_params):

theta = joint_angles[i] + dh.theta_offset

T_i = self.dh_transform(theta, dh.d, dh.a, dh.alpha)

T_total = T_total @ T_i

return T_total

def get_end_position(self, joint_angles: list):

"""返回末端位置 [x, y, z]"""

T = self.forward_kinematics(joint_angles)

return np.array([T[0, 3], T[1, 3], T[2, 3]], dtype=float)

"robot/inverse_kinematics.py" — 解析逆运动学

"""

3-DOF平面搬运臂解析逆运动学(几何法)

仅求解位置(X,Y),Z固定为0

第三关节为腕转,不改变末端位置

⚠ 逆解公式仅对theta_offset=0的构型有效

"""

import numpy as np

import math

from robot.models import RobotConfig, TargetPoint, IKResult

class GeometricInverseKinematics:

def __init__(self, config: RobotConfig):

self.config = config

self.l1 = config.l1

self.l2 = config.l2

self.max_reach = config.max_reach

self.min_reach = config.min_reach

self.z_tol = config.z_tolerance

self.dist_tol = config.dist_tolerance

def _angle_dist(self, a, b):

"""计算两个角之间的最短距离(考虑±π缠绕)"""

d = a - b

while d > math.pi:

d -= 2 * math.pi

while d < -math.pi:

d += 2 * math.pi

return abs(d)

def solve(self, target: TargetPoint, current_angles=None):

"""

对单个目标点求解逆运动学

current_angles: 当前关节角 [θ1, θ2, θ3],用于选优

返回 IKResult

"""

# 1. Z方向检查

if abs(target.z) > self.z_tol:

return IKResult(

target=target,

success=False,

reason="Z超界:本臂无Z自由度,仅支持Z=0平面"

)

# 2. 距离检查

r = math.sqrt(target.x**2 + target.y**2)

if r > self.max_reach + self.dist_tol:

return IKResult(

target=target,

success=False,

reason=f"超出臂展:R={r:.4f}m > {self.max_reach:.4f}m"

)

if r < self.min_reach - self.dist_tol:

return IKResult(

target=target,

success=False,

reason=f"进入奇异内圈:R={r:.4f}m < {self.min_reach:.4f}m"

)

# 3. 余弦定理求肘角θ₂

# cos(θ₂) = (r² - l1² - l2²) / (2·l1·l2)

cos_theta2 = (r**2 - self.l1**2 - self.l2**2) / (2 * self.l1 * self.l2)

# 数值安全:clamp到[-1, 1]

cos_theta2 = max(-1.0, min(1.0, cos_theta2))

# θ₂ > 0 为 elbow-up(小臂向上弯)

theta2_up = math.acos(cos_theta2)

theta2_down = -theta2_up

# 4. 肩部角θ₁

phi = math.atan2(target.y, target.x)

# ψ = atan2(l2*sin(θ₂), l1 + l2*cos(θ₂))

psi = math.atan2(self.l2 * math.sin(theta2_up),

self.l1 + self.l2 * math.cos(theta2_up))

# 关键:θ₁ = φ - ψ(减号,补偿肘部抬升偏移)

theta1_up = phi - psi # elbow-up 对应的肩部角

theta1_down = phi + psi # elbow-down 对应的肩部角

# 5. 选优策略

if current_angles is not None:

dist_up = (self._angle_dist(theta1_up, current_angles[0])

+ self._angle_dist(theta2_up, current_angles[1]))

dist_down = (self._angle_dist(theta1_down, current_angles[0])

+ self._angle_dist(theta2_down, current_angles[1]))

if dist_up <= dist_down:

chosen_t1, chosen_t2 = theta1_up, theta2_up

sol_type = "elbow_up"

else:

chosen_t1, chosen_t2 = theta1_down, theta2_down

sol_type = "elbow_down"

else:

# 无当前角时默认elbow-up(工程惯例)

chosen_t1, chosen_t2 = theta1_up, theta2_up

sol_type = "elbow_up"

# 6. 腕转角θ₃(本篇简化为0,预留扩展)

theta3 = 0.0

return IKResult(

target=target,

success=True,

reason="OK",

theta1=chosen_t1,

theta2=chosen_t2,

theta3=theta3,

solution_type=sol_type

)

def verify(self, ik_result, kinematics,

pos_error_threshold=1e-6):

"""

用正运动学校验逆解正确性

若误差超过阈值,将verify_passed置为False

"""

if not ik_result.success:

return ik_result

joint_angles = [ik_result.theta1, ik_result.theta2,

ik_result.theta3]

pos = kinematics.get_end_position(joint_angles)

target_pos = np.array([ik_result.target.x,

ik_result.target.y,

ik_result.target.z])

error = np.linalg.norm(pos - target_pos)

ik_result.pos_error = error

ik_result.verify_passed = error <= pos_error_threshold

return ik_result

"robot/batch_processor.py" — 批量处理

"""

批量逆运动学求解与统计

含正运动学校验二次分类:校验失败单独统计

"""

import numpy as np

from robot.models import RobotConfig, TargetPoint, IKResult

from robot.inverse_kinematics import GeometricInverseKinematics

from robot.dh_kinematics import DHKinematics

class BatchIKProcessor:

def __init__(self, config: RobotConfig):

self.config = config

self.ik = GeometricInverseKinematics(config)

self.kinematics = DHKinematics(config)

def process(self, targets: list, current_angles=None,

pos_error_threshold=1e-6):

"""

批量处理目标点位

返回:results列表, 统计字典

"""

results = []

stats = {

'total': len(targets),

'success': 0,

'failed': 0,

'verify_failed': 0,

'z_out_of_range': 0,

'beyond_reach': 0,

'below_min_reach': 0,

'elbow_up_count': 0,

'elbow_down_count': 0,

'max_pos_error': 0.0,

}

for i, target in enumerate(targets):

result = self.ik.solve(target, current_angles)

result.index = i

if result.success:

# 正运动学校验

result = self.ik.verify(

result, self.kinematics, pos_error_threshold)

if result.verify_passed:

stats['success'] += 1

if result.solution_type == 'elbow_up':

stats['elbow_up_count'] += 1

else:

stats['elbow_down_count'] += 1

else:

# 校验失败:逆解数学有误

stats['verify_failed'] += 1

result.success = False

result.reason = (

f"校验失败:pos_error="

f"{result.pos_error:.2e} > {pos_error_threshold}"

)

stats['max_pos_error'] = max(

stats['max_pos_error'], result.pos_error)

else:

stats['failed'] += 1

reason = result.reason

if 'Z超界' in reason:

stats['z_out_of_range'] += 1

elif '超出臂展' in reason:

stats['beyond_reach'] += 1

elif '奇异内圈' in reason:

stats['below_min_reach'] += 1

results.append(result)

stats['success_rate'] = stats['success'] / stats['total'] * 100

return results, stats

"robot/visualize.py" — 可视化

"""

逆运动学批量求解可视化:4张图表

① 成功率饼图(含校验失败)

② 失败点散点图(按失败原因着色)

③ 可达点双解分布(elbow-up vs elbow-down)

④ 工作空间边界图 + 全部目标点叠加

"""

import os

import numpy as np

import matplotlib.pyplot as plt

plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans',

'Noto Sans CJK SC']

plt.rcParams['axes.unicode_minus'] = False

class IKVisualizer:

def __init__(self, results_dir="results"):

self.results_dir = results_dir

os.makedirs(results_dir, exist_ok=True)

def plot_success_pie(self, stats):

"""① 成功率饼图(含校验失败子分类)"""

fig, ax = plt.subplots(figsize=(6, 6))

labels = ['成功', '失败(含校验失败)']

sizes = [stats['success'],

stats['failed'] + stats['verify_failed']]

colors = ['#4CAF50', '#F44336']

ax.pie(sizes, labels=labels, colors=colors,

autopct='%1.1f%%', shadow=False, startangle=90)

ax.set_title(f'逆解成功率\n'

f'({stats["success"]}/{stats["total"]} 点)')

plt.tight_layout()

path = os.path.join(self.results_dir, "01_success_pie.png")

plt.savefig(path, dpi=150)

plt.close()

return path

def plot_failed_points(self, results):

"""② 失败点散点图"""

fig, ax = plt.subplots(figsize=(8, 8))

success_x, success_y = [], []

z_fail_x, z_fail_y = [], []

reach_fail_x, reach_fail_y = [], []

min_fail_x, min_fail_y = [], []

for r in results:

if r.success:

success_x.append(r.target.x)

success_y.append(r.target.y)

else:

reason = r.reason

if 'Z超界' in reason:

z_fail_x.append(r.target.x)

z_fail_y.append(r.target.y)

elif '超出臂展' in reason:

reach_fail_x.append(r.target.x)

reach_fail_y.append(r.target.y)

elif '奇异内圈' in reason:

min_fail_x.append(r.target.x)

min_fail_y.append(r.target.y)

ax.scatter(success_x, success_y, c='lightgray', s=30,

label='成功', alpha=0.6, edgecolors='gray')

if z_fail_x:

ax.scatter(z_fail_x, z_fail_y, c='red', s=50,

label='Z超界', marker='x')

if reach_fail_x:

ax.scatter(reach_fail_x, reach_fail_y, c='orange', s=50,

label='超出臂展', marker='s')

if min_fail_x:

ax.scatter(min_fail_x, min_fail_y, c='purple', s=50,

label='奇异内圈', marker='^')

ax.set_xlabel('X (m)')

ax.set_ylabel('Y (m)')

ax.set_title('目标点位可达性分布')

ax.grid(True, alpha=0.3)

ax.set_aspect('equal')

ax.legend()

plt.tight_layout()

path = os.path.join(self.results_dir, "02_failed_points.png")

plt.savefig(path, dpi=150)

plt.close()

return path

def plot_solution_distribution(self, results):

"""③ 可达点双解分布"""

fig, ax = plt.subplots(figsize=(8, 8))

up_x, up_y = [], []

down_x, down_y = [], []

for r in results:

if r.success:

if r.solution_type == 'elbow_up':

up_x.append(r.target.x)

up_y.append(r.target.y)

else:

down_x.append(r.target.x)

down_y.append(r.target.y)

if up_x:

ax.scatter(up_x, up_y, c='blue', s=40,

label=f'Elbow-up ({len(up_x)}点)', alpha=0.7)

if down_x:

ax.scatter(down_x, down_y, c='green', s=40,

label=f'Elbow-down ({len(down_x)}点)', alpha=0.7)

ax.set_xlabel('X (m)')

ax.set_ylabel('Y (m)')

ax.set_title('逆解构型分布 (Elbow-up vs Elbow-down)')

ax.grid(True, alpha=0.3)

ax.set_aspect('equal')

ax.legend()

plt.tight_layout()

path = os.path.join(self.results_dir,

"03_solution_distribution.png")

plt.savefig(path, dpi=150)

plt.close()

return path

def plot_workspace_boundary(self, config, results=None):

"""④ 臂展边界与工作空间图 + 可选叠加目标点"""

fig, ax = plt.subplots(figsize=(8, 8))

l1 = config.l1

l2 = config.l2

max_r = l1 + l2

min_r = abs(l1 - l2)

theta = np.linspace(0, 2 * np.pi, 200)

ax.plot(max_r * np.cos(theta), max_r * np.sin(theta),

'r-', linewidth=2, label=f'最大臂展 {max_r:.2f} m')

if min_r > 1e-6:

ax.plot(min_r * np.cos(theta), min_r * np.sin(theta),

'r--', linewidth=1.5,

label=f'最小臂展 {min_r:.2f} m')

# 叠加所有目标点

if results is not None:

ok_x, ok_y = [], []

fail_x, fail_y = [], []

for r in results:

if r.success:

ok_x.append(r.target.x)

ok_y.append(r.target.y)

else:

fail_x.append(r.target.x)

fail_y.append(r.target.y)

if ok_x:

ax.scatter(ok_x, ok_y, c='blue', s=15, alpha=0.4,

label='可达点', edgecolors='none')

if fail_x:

ax.scatter(fail_x, fail_y, c='red', s=20, alpha=0.5,

label='不可达点', marker='x')

ax.set_xlabel('X (m)')

ax.set_ylabel('Y (m)')

ax.set_title('机械臂工作空间边界 + 目标点叠加')

ax.grid(True, alpha=0.3)

ax.set_aspect('equal')

ax.legend(fontsize=8)

plt.tight_layout()

path = os.path.join(self.results_dir,

"04_workspace_boundary.png")

plt.savefig(path, dpi=150)

plt.close()

return path

5. 可视化要求

序号 图表文件 服务结论

①

"01_success_pie.png" 逆解成功率(含校验失败子分类),直观展示批量点位的可达比例

②

"02_failed_points.png" 失败点空间分布,识别不可达区域(Z超界/臂展外/内圈奇异)

③

"03_solution_distribution.png" 可达点中elbow-up/down构型分布,评估选优策略效果

④

"04_workspace_boundary.png" 工作空间边界(最大/最小臂展圆)+ 全部目标点叠加,理论可达域与实测对比

6. README使用说明 + 核心知识点卡片

快速开始

pip install -r requirements.txt

python -m pytest tests/test_ik_batch.py -v # 3/

利用AI解决实际问题,如果你觉得这个工具好用,欢迎关注长安牧笛!

返回列表