SAM3+点云 机械臂3d抓取方案 (二、相机服务端)
哈喽!又和大家见面了,昨天我们针对SAM3进行了环境的配置,今天我们针对此模型进行可视化的操作,方便大家进行接口的调用。
如未进行环境配置,请跳转此章节查看:https://blog.csdn.net/m0_56498637/article/details/159646240?spm=1001.2014.3001.5502
相机服务端(HTTP接口)
服务端我们使用python flask服务来进行操作,能够方便大家通过网页可视化的方式直观感受sam3的效果。
模型文件下载
在此之前我们需要准备好模型文件,这里小亦整理好放在百度网盘供大家下载:
https://pan.baidu.com/s/1MiYRLo5K3lS_KuRT9QNSXQ?pwd=k38s 提取码: k38s
如果不想通过网盘下载sam3模型,也可以访问网页进行下载
下载链接:https://www.modelscope.cn/models/facebook/sam3/files

Detect.py (相机服务端)
Python库导入
首先我们导入此次py文件所需库,这里提醒一下直接复制可能会报错,由于Logger的导入方式不同。
import os
import cv2
import time
import threading
import numpy as np
import open3d as o3d
import pyrealsense2 as rs
from Log.logger import get_logger
from ultralytics.models.sam import SAM3SemanticPredictor
from flask import Flask, Response, jsonify, render_template
大家也可以选择logging的方式导入。
import logging
logger = logging.getLogger(__name__)
这里小亦将Logger.py提供给大家,放在Log文件下保存,这样复制我的导入方法就不会出错
Logger.py
import logging
import sys
from pathlib import Path
from logging.handlers import TimedRotatingFileHandler
class ColoredFormatter(logging.Formatter):
"""带颜色的日志格式化器"""
# ANSI 颜色代码
COLORS = {
'DEBUG': '\033[36m', # 青色
'INFO': '\033[32m', # 绿色
'WARNING': '\033[33m', # 黄色
'ERROR': '\033[31m', # 红色
'CRITICAL': '\033[35m', # 紫色
}
RESET = '\033[0m'
def format(self, record: logging.LogRecord) -> str:
# 保存原始级别名称
original_levelname = record.levelname
# 添加颜色
if record.levelname in self.COLORS:
record.levelname = f"{self.COLORS[record.levelname]}{record.levelname}{self.RESET}"
result = super().format(record)
# 恢复原级别名称
record.levelname = original_levelname
return result
def setup_logging(
log_level: str = "INFO",
log_dir: str = "logs",
enable_file: bool = True,
enable_console: bool = True,
colored_console: bool = True
) -> logging.Logger:
"""
配置日志系统
Args:
log_level: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
log_dir: 日志文件目录
enable_file: 是否启用文件日志
enable_console: 是否启用控制台日志
colored_console: 控制台是否使用颜色
Returns:
配置好的根日志记录器
"""
# 创建日志目录
log_path = Path(log_dir)
log_path.mkdir(exist_ok=True)
# 获取根记录器
root_logger = logging.getLogger()
root_logger.setLevel(getattr(logging, log_level.upper()))
# 清除现有处理器
root_logger.handlers.clear()
# 标准格式
standard_format = '%(asctime)s | %(levelname)-8s | %(name)s | %(message)s'
date_format = '%Y-%m-%d %H:%M:%S'
# 控制台处理器
if enable_console:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.DEBUG)
if colored_console and sys.stdout.isatty():
# 使用带颜色的格式化器
console_formatter = ColoredFormatter(
standard_format,
datefmt=date_format
)
else:
console_formatter = logging.Formatter(
standard_format,
datefmt=date_format
)
console_handler.setFormatter(console_formatter)
root_logger.addHandler(console_handler)
# 文件处理器
if enable_file:
file_handler = TimedRotatingFileHandler(
log_path / "grasp.log",
when="midnight",
interval=1,
backupCount=7,
encoding="utf-8"
)
file_handler.setLevel(logging.DEBUG)
file_formatter = logging.Formatter(
standard_format,
datefmt=date_format
)
file_handler.setFormatter(file_formatter)
root_logger.addHandler(file_handler)
# 错误日志单独文件
error_handler = TimedRotatingFileHandler(
log_path / "error.log",
when="midnight",
interval=1,
backupCount=30,
encoding="utf-8"
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(file_formatter)
root_logger.addHandler(error_handler)
# 设置第三方库日志级别
logging.getLogger("ultralytics").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("requests").setLevel(logging.WARNING)
return root_logger
def get_logger(name: str) -> logging.Logger:
"""
获取模块日志记录器
Args:
name: 日志记录器名称,通常使用 __name__
Returns:
配置好的日志记录器
"""
return logging.getLogger(name)
导入SAM3
这里大家可以看到小亦导入模型后,做了一次模型预加热的问题,这里我们是通过numpy的方式创建了一个黑色背景图执行一次sam3的推理。大家可能会忽略这一点,模型的预热是用于服务端启动前就将模型启动,而不用再后期的第一次调用时启动缓慢。
# todo 导入本地SAM3模型
predictor = SAM3SemanticPredictor(overrides=dict(
model=r"C:\Users\16095\PycharmProjects\Grasp_Anything\models\sam3.pt",
conf=0.3
))
print("SAM3模型导入完成")
# todo SAM3模型预加热 == 等同于相机的预加热
dummy = np.zeros((640, 480, 3), dtype=np.uint8)
predictor.set_image(dummy)
predictor(text=["object"])
print("SAM3预热完成")
定义全局变量
全局变量中,我添加了共5个变量,用于处理后的图像,当前的掩码,最新一帧的彩色/深度图,相机内参
# todo 定义全局变量
processed_frame = None
current_mask = None
latest_color_frame = None
latest_depth_frame = None
intr = None
相机配置
这里小亦使用的是RealsenseD435相机,大家也可以根据自己使用的相机sdk进行修改,这里的手眼矩阵是眼在手上的方案。
# todo Realsense相机初始化
# todo 创建管道
pipeline = rs.pipeline()
# todo 相机配置
config = rs.config()
config.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30)
config.enable_stream(rs.stream.depth, 640, 480, rs.format.z16, 30)
# todo 手眼标定矩阵 相机到工具坐标系的关系
T_cam_to_tool = np.array([
[-1, 0, 0, 0.04],
[0, 1, 0, -0.06],
[0, 0, -1, 0.176],
[0, 0, 0, 1]
])
这里小亦再多说一嘴,很多人手眼标定的结果并不是理想,往往存在一些偏差需要自己进行修正补偿,无论是采用Aruco码,棋盘格等等传统的标定步骤来执行,多少都会存在一些。那么我是怎么做的呢?由于眼在手上,相机与机械臂末端的关系相对固定,我们做垂直抓取时,相机肯定是平行于抓取面,则我们在机械臂末端建立工具坐标系,让工具坐标系的XYZ尽可能与相机坐标系的XYZ呈单位矩阵的关系,避免因为旋转矩阵的因素干扰到标定后的手眼矩阵。
2D转3D(像素点转三维坐标点)
在这个函数中,我们将最终抓取物体的像素中心点转换到相机坐标系下的点,这样方便我们通过手眼矩阵得到物体在工具坐标系下的偏移,更方便我们后面机械臂的运动规划。
# todo 将物体中心点的像素坐标转换为相机坐标系的三维坐标
def pixel2point3d(px, py, depth_mm, camera_param):
fx = camera_param.fx
fy = camera_param.fy
cx = camera_param.ppx
cy = camera_param.ppy
cam_z = depth_mm
cam_x = (px - cx) * cam_z / fx
cam_y = (py - cy) * cam_z / fy
return [cam_x, cam_y, cam_z]
SAM3推理过程
在这个过程中,我们可以看到其实SAM3的推理过程很简单,就是对当前最新的图像帧进行图像的推理,通过results中的信息,获取到掩码信息,这里面是通过掩码面积及置信度来进行排序,将最终效果最好的掩码保存到全局变量current_mask中,方便我们后续点云的处理。
# todo 触发SAM3推理
@app.route('/sam')
def sam():
global current_mask, processed_frame
with frame_lock:
img = latest_color_frame.copy()
predictor.set_image(img)
results = predictor(text=["object"])
# 默认没有有效掩码
best_mask = None
if results and results[0].masks is not None:
r = results[0]
masks = r.masks.data.cpu().numpy()
if r.boxes is not None:
boxes = r.boxes.xyxy.cpu().numpy()
confs = r.boxes.conf.cpu().numpy()
else:
boxes = []
confs = []
# 存在掩码才进行排序
if len(masks) > 0:
# todo 计算掩码面积
areas = [np.sum(mask) for mask in masks]
# todo 排序:按置信度降序,面积降序
scored = []
for idx in range(len(masks)):
conf = confs[idx] if idx < len(confs) else 0.0
area = areas[idx]
scored.append((-conf, -area, idx)) # 负数用于降序
scored.sort() # 升序
best_idx = scored[0][2]
best_mask = masks[best_idx]
# todo 无论是否有检测结果,都更新 current_mask(无检测则为 None)
with frame_lock:
current_mask = best_mask
# todo 如果有掩码则叠加绿色半透明和边框,否则只显示原图
vis = img.copy()
if best_mask is not None:
alpha = 0.5
green = np.array([0, 255, 0], dtype=np.uint8)
# todo 只显示最佳掩码,也可选择全都显示,看个人需求,predict文件夹中会存放所有掩码效果
mask_bool = best_mask.astype(bool)
vis[mask_bool] = (vis[mask_bool] * (1 - alpha) + green * alpha).astype(np.uint8)
# 如果存在边界框,也画出最佳掩码对应的边界框
if 'boxes' in locals() and len(boxes) > best_idx:
x1, y1, x2, y2 = boxes[best_idx].astype(int)
cv2.rectangle(vis, (x1, y1), (x2, y2), (0, 255, 0), 2)
if 'confs' in locals() and best_idx < len(confs):
label = f"{confs[best_idx]:.2f}"
cv2.putText(vis, label, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
processed_frame = vis
return jsonify({"status": "sam_done", "mask_found": best_mask is not None})
点云处理函数
在点云处理中,我们逐步解析
1、先通过降采样的方式获取点云,这里采用步长为2(如果想提高点云质量,步长可以减小),随后进行z值的阈值判断,限制深度范围
2、由于我们垂直抓取项目,物体可能摆放在桌面上,所以我们先打印第一次的点云数量,再通过去除桌面的方式,仅保留比最低点高0.1米内的点,可能切掉物体底部,再次打印,查看两次的点云数量结果,这里点云阈值可以根据自己摆放的物体来设置。
3、使用 Open3D 的 DBSCAN 算法对当前点云聚类如果没有任何簇(所有标签为 -1),返回 None,找出点数最多的簇作为目标物体点云。
4、取聚类后所有点的平均值作为物体中心(三维坐标)
5、对物体点云进行法向量估计(每个点的法向量通过邻近30个点拟合平面得到),将所有点的法向量求平均,再归一化,得到物体表面的平均法向量(PS:PCA法向量处理其实是为了后面获取角度而求,我们可选可不选,因为小亦并未使用法向量来获取角度)
6、利用相机内参,将三维点 (X, Y, Z) 投影回图像坐标系,得到中心点对应的像素位置 (u, v)
# todo 点云处理函数
def get_mask_pointcloud_center_and_normal(depth, mask, intr):
h, w = depth.shape
points = []
for v in range(0, h, 2):
for u in range(0, w, 2):
if mask[v, u] == 0:
continue
z = depth[v, u] * depth_scale
if z <= 0 or z > 1.2:
continue
x = (u - intr.ppx) * z / intr.fx
y = (v - intr.ppy) * z / intr.fy
points.append([x, y, z])
print(f"原始mask点云数量: {len(points)}")
if len(points) < 100: # todo 降低阈值
print("点云太少(原始)")
return None, None, None, None
pts = np.array(points)
# todo 去桌面点云
z_min = np.min(pts[:, 2])
pts = pts[pts[:, 2] < z_min + 0.1]
print(f"原始mask点云数量: {len(pts)}")
if len(pts) < 30: # todo 继续降低阈值
print("点云太少(原始)")
return None, None, None, None
# todo 聚类
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(pts)
labels = np.array(pcd.cluster_dbscan(eps=0.02, min_points=50))
if labels.max() < 0:
return None, None, None, None
largest_label = max(set(labels), key=list(labels).count)
obj_pts = pts[labels == largest_label]
# todo 中心
center = obj_pts.mean(axis=0)
# todo PCA法向量
pcd_obj = o3d.geometry.PointCloud()
pcd_obj.points = o3d.utility.Vector3dVector(obj_pts)
pcd_obj.estimate_normals(
search_param=o3d.geometry.KDTreeSearchParamKNN(knn=30)
)
normals = np.asarray(pcd_obj.normals)
normal = normals.mean(axis=0)
normal = normal / np.linalg.norm(normal)
# todo 获取像素位置
u = int(center[0] * intr.fx / center[2] + intr.ppx)
v = int(center[1] * intr.fy / center[2] + intr.ppy)
return center, normal, (u, v), obj_pts
点云处理接口
这里的功能就不多赘述,大家可以根据注释查看,这里特别注意一点就是角度的锁象限,这一点是一定要做的,不然后面因为角度的爆炸问题很让人头疼,不管是yolo-obb也好还是点云PCA也好,还是掩码主方向角度计算也好,都需要进行这一点,当然这个只是我的个人观点,如果有更好的方式可以评论区沟通。
# todo 3d点云处理接口(增加掩码有效性检查)
@app.route('/pointcloud')
def pointcloud():
global processed_frame, current_mask, angle_history
with frame_lock:
if latest_color_frame is None or latest_depth_frame is None:
return jsonify({"error": "相机未就绪"}), 500
if current_mask is None:
return jsonify({"error": "没有有效的掩码,请先执行SAM"}), 400 # 新增检查
if np.sum(current_mask) == 0:
return jsonify({"error": "掩码为空"}), 500
img = latest_color_frame.copy()
depth = latest_depth_frame.copy()
mask = current_mask.copy()
# ======================
# 1. 点云中心 + 法向量
# ======================
center, normal, uv, obj_pts = get_mask_pointcloud_center_and_normal(depth, mask, intr)
if center is None:
print("未检测到有效点云")
return jsonify({"status": "no_object"})
x, y, z = center
u, v = uv
# ======================
# SAM mask角度 (相较于点云的PCA处理获取角度更为稳定)
# ======================
ys, xs = np.where(mask > 0)
pts_2d = np.stack([xs, ys], axis=1)
mean = np.mean(pts_2d, axis=0)
centered = pts_2d - mean
cov = np.cov(centered.T)
eigvals, eigvecs = np.linalg.eig(cov)
main_vec = eigvecs[:, np.argmax(eigvals)]
# 原始角度
angle_rad = np.arctan2(main_vec[1], main_vec[0])
# 映射到 0~π
angle_rad = angle_rad % np.pi
# 压缩到 0~π/2 这一点很关键,锁定象限,避免角度的爆炸导致因为角度调整烦恼
if angle_rad > np.pi / 2:
angle_rad = np.pi - angle_rad
angle_rad = angle_rad - np.pi / 2
# 判断方向
direction = 1 if main_vec[0] >= 0 else -1
# 最终角度
grasp_theta = angle_rad * direction
# ======================
# 3. 坐标变换(相机 → 工具)
# ======================
point_cam = np.array([x, y, z, 1])
point_tool = T_cam_to_tool @ point_cam
tx, ty, tz = point_tool[:3]
# ======================
# 4. 打印
# ======================
print("\n====== 点云检测结果 ======")
print(f"像素坐标 (u, v): ({u}, {v})")
print(f"相机坐标 (x, y, z): [{x:.6f}, {y:.6f}, {z:.6f}]")
print(f"法向量 (nx, ny, nz): [{normal[0]:.6f}, {normal[1]:.6f}, {normal[2]:.6f}]")
print(f"抓取角度 (rad): {grasp_theta:.4f}")
print(f"原始方向向量: {main_vec}")
print(f"角度(0~90°): {np.degrees(angle_rad):.2f}")
print(f"方向: {'正' if direction == 1 else '反'}")
print(f"最终角度(rad): {grasp_theta:.4f}")
print("------ 坐标变换 ------")
print(f"工具坐标 (x, y, z): [{tx:.6f}, {ty:.6f}, {tz:.6f}]")
# ======================
# 5. 可视化
# ======================
vis = img.copy()
if 0 <= u < 640 and 0 <= v < 480:
cv2.circle(vis, (u, v), 6, (0, 255, 0), -1)
# 画mask主方向
scale = 80
dx = int(np.cos(grasp_theta) * scale)
dy = int(np.sin(grasp_theta) * scale)
cv2.arrowedLine(vis, (u, v), (u + dx, v + dy), (255, 0, 0), 2)
cv2.putText(vis, f"theta:{grasp_theta:.2f}", (u + 10, v),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 0), 2)
with frame_lock:
processed_frame = vis
# ======================
# 6. 返回结果
# ======================
return jsonify({
"status": "ok",
"center_camera": {
"x": float(x),
"y": float(y),
"z": float(z)
},
"normal": {
"nx": float(normal[0]),
"ny": float(normal[1]),
"nz": float(normal[2])
},
"tool_xyz": {
"x": float(tx),
"y": float(ty),
"z": float(tz)
},
"theta_rad": grasp_theta
})
相机线程
# todo 相机线程函数
def camera_thread():
"""持续获取相机画面的线程"""
global latest_color_frame
global latest_depth_frame
global intr
global depth_scale
profile = pipeline.start(config)
# 获取对齐工具
align_to = rs.stream.color
align = rs.align(align_to)
depth_sensor = profile.get_device().first_depth_sensor()
depth_scale = depth_sensor.get_depth_scale()
print(f"深度比例: {depth_scale}")
# 获取内参
intr = profile.get_stream(rs.stream.color).as_video_stream_profile().get_intrinsics()
try:
while True:
frames = pipeline.wait_for_frames()
color_frame = frames.get_color_frame()
if not color_frame:
continue
# 获取对齐帧
aligned_frames = align.process(frames)
color_frame = aligned_frames.get_color_frame()
depth_frame = aligned_frames.get_depth_frame()
# 转换为Open3D格式
color_image = np.asanyarray(color_frame.get_data())
depth_image = np.asanyarray(depth_frame.get_data())
# 更新全局帧
with frame_lock:
latest_color_frame = color_image.copy()
latest_depth_frame = depth_image.copy()
time.sleep(0.033) # ~30fps
finally:
pipeline.stop()
相机接口服务
# TODO flask接口服务
# todo 前端页面
@app.route('/')
def index():
return render_template('index.html')
@app.route('/capture')
def capture():
global processed_frame
with frame_lock:
if latest_color_frame is None:
return jsonify({"error": "相机未就绪"}), 500
processed_frame = latest_color_frame.copy()
return jsonify({"status": "ok"})
# todo 生成实时视频流
def generate():
while True:
with frame_lock:
if latest_color_frame is None:
continue
# 编码为JPEG
ret, jpeg = cv2.imencode('.jpg', latest_color_frame)
frame = jpeg.tobytes()
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n\r\n')
@app.route('/processed_feed')
def processed_feed():
def generate_processed():
while True:
with frame_lock:
if processed_frame is None:
continue
ret, jpeg = cv2.imencode('.jpg', processed_frame)
frame = jpeg.tobytes()
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n\r\n')
return Response(generate_processed(),
mimetype='multipart/x-mixed-replace; boundary=frame')
# todo 视频流
@app.route('/video_feed')
def video_feed():
"""视频流接口"""
return Response(generate(),
mimetype='multipart/x-mixed-replace; boundary=frame')
启动Flask
# todo flask服务
app = Flask(__name__)
# todo 线程锁
frame_lock = threading.Lock()
# todo 启动服务
if __name__ == '__main__':
# 启动相机线程
threading.Thread(target=camera_thread, daemon=True).start()
# 静态文件目录
os.makedirs('static', exist_ok=True)
app.run(host='0.0.0.0', port=5000, threaded=True)
前端页面
index.html放在templates文件夹下保存
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Realsense实时检测</title>
<style>
:root {
--bg-color: #0f172a;
--card-bg: #1e293b;
--accent-color: #3b82f6;
--text-color: #f1f5f9;
--btn-hover: #2563eb;
}
body {
font-family: 'Inter', -apple-system, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 2rem;
}
h1 {
margin-bottom: 2rem;
font-weight: 300;
letter-spacing: 2px;
color: var(--accent-color);
}
/* 视频容器布局 */
.container {
display: flex;
gap: 2rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 3rem;
}
.card {
background: var(--card-bg);
padding: 1rem;
border-radius: 12px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
text-align: center;
color: #94a3b8;
}
.card img {
display: block;
border-radius: 8px;
background: #000;
width: 100%;
max-width: 640px;
}
/* 按钮组样式 */
.controls {
display: flex;
gap: 1.5rem;
background: var(--card-bg);
padding: 1.5rem 2.5rem;
border-radius: 50px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
button {
padding: 0.8rem 2rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 25px;
cursor: pointer;
transition: all 0.3s ease;
background: var(--accent-color);
color: white;
display: flex;
align-items: center;
gap: 8px;
}
button:hover {
background: var(--btn-hover);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
button:active {
transform: translateY(0);
}
/* 特定按钮颜色区分 */
button[onclick*="capture"] { background: #10b981; } /* 绿色 */
button[onclick*="capture"]:hover { background: #059669; }
button[onclick*="pointcloud"] { background: #8b5cf6; } /* 紫色 */
button[onclick*="pointcloud"]:hover { background: #7c3aed; }
</style>
</head>
<body>
<h1>视觉检测</h1>
<div class="container">
<div class="card">
<h2>实时画面 SOURCE</h2>
<img src="{{ url_for('video_feed') }}" alt="Live Feed">
</div>
<div class="card">
<h2>处理结果 PROCESSED</h2>
<img src="{{ url_for('processed_feed') }}" alt="Processed Feed">
</div>
</div>
<div class="controls">
<button onclick="run('/capture')">拍照</button>
<button onclick="run('/sam')">SAM3</button>
<button onclick="run('/pointcloud')">点云</button>
</div>
<script>
function run(url) {
// 添加简单的点击反馈
console.log("正在请求: " + url);
fetch(url)
.then(res => res.json())
.then(data => {
console.log("响应:", data);
// 这里可以添加一个小的 Toast 提示告诉用户执行成功
})
.catch(err => console.error("发生错误:", err));
}
</script>
</body>
</html>
总结
至此,我们的相机服务端就算完成了,有什么问题欢迎大家私信/评论区沟通,谢谢大家能坚持到最后,下一章节我们将完成机械臂的动作控制模块,下一部分会相对简单,代码也较为简单,大家也可以根据自己手里的机械臂提前进行调试,有兴趣的同学动手试一下吧!!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)