哈喽!又和大家见面了,昨天我们针对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>

总结

至此,我们的相机服务端就算完成了,有什么问题欢迎大家私信/评论区沟通,谢谢大家能坚持到最后,下一章节我们将完成机械臂的动作控制模块,下一部分会相对简单,代码也较为简单,大家也可以根据自己手里的机械臂提前进行调试,有兴趣的同学动手试一下吧!!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐