机器人仿真笔记[6]-基于视觉大模型的无人机室内自主侦察

摘要

基于视觉语言大模型(VLM)的无人机室内自主侦察仿真.实现无人机自主起飞, 避开障碍物以及穿过门洞进入第二个房间, 移动到字母B前面发射激光.

关键词

ros;vlm;aircraft;xtdrone;ocr;auto;

关键信息

项目源码:[https://github.com/ByeIO/bye.orbslam3.rs/tree/dev1/crates/seekslam_webots]
项目配置:

[project]
name = "mission1"
version = "0.0.1"
description = "测试pid悬停"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
    # 异步处理
    "asyncio>=3.4.3",
    # 控制算法
    "control>=0.10.1",
    # gradio客户端
    "gradio-client>=1.8.0",
    # 线性代数
    "numpy>=2.2.5",
    # ollama客户端
    "ollama>=0.4.8",
    # 图像处理
    "opencv-python>=4.11.0.86",
    # 图像处理
    "pillow>=11.2.1",
    # 单元测试
    "pytest>=8.3.5",
    # 科学计算
    "scipy>=1.15.2",
    # webots接口
    "webots>=0.0.1",
    # pid算法封装
    "simple-pid>=2.0.1",
    # 网页代码编辑器
    "jupyter>=1.1.1",
]

整体架构

使用docker封印各个复杂算法模块, 实现函数式调用.

  • 核心算法:

    • ocr目标识别模块: paddleocr@ref
    • 建图模块: VGGT@ref
    • 大模型视觉、思考及决策模块:
      • MFDoom/deepseek-r1-tool-calling:1.5b@ref
      • minicpm-v@ref
    • 深度估计模块: depth-anything-v2@ref
  • 核心传感器:

    • 10轴传感器(加速度计+陀螺仪+磁力计+气压计)
    • 单目摄像头
  • 核心执行器

    • 电机x4
    • 激光器

详细描述

  1. 无人机起飞前初始化传感器及算法模块, 预热ollama模型
  2. 起飞后先悬停稳定, 然后缓慢环绕四周侦察并将图片送入建图模块进行初次室内建图
  3. 建图完成后调用深度估计模块估计四周的深度信息, 然后直接将深度图发布视觉大模型模块进行下一步方向的决策
  4. 决策完成后使用函数调用大模型调用封装的动作函数进行移动
  5. 使用关键途径点修正机器人轨迹
  6. 相机模块以1Hz速率向目标识别模块发布信息, 目标识别模块识别到目标后通知决策模块进行移动
  7. 决策模块在到达目标前方后使用深度估计模块估计与目标距离, 到达有效攻击范围后调用函数打开激光
  8. 原路返回然后降落

核心代码

_ollama.py

# 智慧无人机决策控制系统
# 特点: 接入ollama大模型能力, 实现智能决策
# 功能: 通过视觉大模型识别场景,使用函数调用模型进行多阶段决策和控制

# 攻击目标(与tasks中一致)
_TARGET = "B"
# 系统提示词
_SYSTEM_PROMPT = f"你是一架智慧无人机, 你的任务是找到{_TARGET}并进行攻击, 然后返回到起飞点.你可以通过深度图、OCR识别图、三维重建图进行决策.你可以使用up, down, left, right, forward, backward, rotate, attack函数; 优先使用forward以前进; 函数参数只需要提供数字, 不要带单位.你需要不断地进行思考以应对各种情况."
# 阶段1(起飞后在第一个房间建图后决策)提示词
_PROMPT1 = "现在你在第一个房间起飞了, 悬停在1.5米高度. 刚刚使用三维重建算法对房间进行了建图, 你需要找到通往第二个房间的方向并调用函数移动."
# 阶段2(在两个房间交界处悬停决策)提示词
_PROMPT2 = "你来到了两个房间的交界处, 悬停在1.5米高度, 先侦察一下第二个房间的状况. 刚刚使用深度估计算法获取了朝向面的深度图(通过颜色区分深度), 你需要判断深度信息{_DEPTH_INFO}以判断应该如何前进, 注意避开障碍物.刚刚使用OCR算法识别目标, 结果为{_OCR_RESULT}, 你需要调用函数移动到目标或者前进以继续搜索目标."
# 阶段3(在第二个房间建图后决策)提示词
_PROMPT3 = "刚刚对第二个房间建图成功, 发现目标方位{_OCR_RESULT}, 应该马上调用函数移动到目标进行攻击.移动时注意深度信息进行避障."
# 阶段4(在目标前判断是否攻击)提示词
_PROMPT4 = f"你来到了目标{_TARGET}前面, 在有效攻击范围内, 使用函数对其进行攻击, 然后原路撤离, 回到起飞点降落."
# 对话记录
_CHAT_HISTORY = [{"q":"", "a":""},]
# 视觉大模型
MODEL_VISION = 'minicpm-v:latest'  
# 函数调用模型
MODEL_TOOL = 'MFDoom/deepseek-r1-tool-calling:1.5b'  
# OLLAMA服务地址
OLLAMA_SERVER = "http://10.8.8.20:11435"
#####################################
# 大模型接口
from ollama import Client
# python版本兼容
from typing_extensions import MutableMapping 
# 单元测试
import pytest
# 图像处理
from PIL import Image
# 内置库
import time
import json
import requests
import base64
import io
import os
import sys
import tempfile
#####################################

# 输入: 原图, 深度图, ocr结果
# 输出: 控制指令 up, down, forward, backward, left, right, rotate, attack

class _Ollama():
    def __init__(self, robot, controller):
        # 初始化ollama客户端
        self.client = Client(host=OLLAMA_SERVER)
        # 用于函数调用的精简函数集合
        self.fn = _Move(robot, controller)
        # 添加完成数据的prompt
        self.prompt = ["", ]
        # 当前决策阶段
        self.current_stage = 1
        # 对话历史记录
        self.chat_history = []
        # 起飞前预加载模型
        self._preload_models()
    #########################################
    def _preload_models(self):
        """预加载并保持模型常驻内存"""
        try:
            print("[系统] 开始预加载AI模型...")
            # 预加载视觉模型
            requests.post(
                f'{OLLAMA_SERVER}/api/generate',
                json={'model': MODEL_VISION, 'prompt': ' ', 'keep_alive': -1}
            )
            # 预加载函数调用模型
            requests.post(
                f'{OLLAMA_SERVER}/api/generate',
                json={'model': MODEL_TOOL, 'prompt': ' ', 'keep_alive': -1}
            )
            print("[系统] 模型预加载完成")
        except Exception as e:
            print(f"[错误] 模型预加载失败: {str(e)}")
            raise
    #########################################
    # 对外接口, 预处理prompt
    def preprecess_prompt(self, *args, **kwargs):
        '''
        接受任意类型和任意数量的参数, 转换为str并格式化到prompt中
        
        参数:
            *args: 位置参数列表
            **kwargs: 关键字参数字典
            
        返回:
            str: 格式化后的prompt字符串
        '''
        prompt_parts = []
        
        # 处理位置参数
        for i, arg in enumerate(args):
            if isinstance(arg, (list, dict)):
                # 对列表和字典使用json格式化
                prompt_parts.append(f"参数{i+1}:\n{json.dumps(arg, indent=2, ensure_ascii=False)}")
            else:
                # 其他类型直接转字符串
                prompt_parts.append(f"参数{i+1}: {str(arg)}")
        
        # 处理关键字参数
        for key, value in kwargs.items():
            if isinstance(value, (list, dict)):
                # 对列表和字典使用json格式化
                prompt_parts.append(f"{key}:\n{json.dumps(value, indent=2, ensure_ascii=False)}")
            else:
                # 其他类型直接转字符串
                prompt_parts.append(f"{key}: {str(value)}")
        
        # 将所有部分用换行符连接
        formatted_prompt = "\n".join(prompt_parts)
        
        # 更新当前prompt
        if hasattr(self, 'prompt'):
            self.prompt.append(formatted_prompt)
        else:
            self.prompt = [formatted_prompt]
            
        return formatted_prompt
    #########################################
    # 对外接口, 每个阶段明确思考内容
    def process_stage(self, stage=0, image_base64=None, depth_imgs_base64=None, ocr_result=None):
        """
        处理当前任务阶段
        参数:
            image_base64: 原始视觉图像(图像base64数据)
            depth_imgs_base64: 深度估计结果png_base64
            ocr_result: OCR识别结果json
        返回:
            dict: 控制指令
        """
        try:
            # 根据阶段处理提示词
            if stage == 1:    
                prompt = _PROMPT1
            elif stage == 2:
                # 将OCR结果格式化进入
                prompt = _PROMPT2.replace("_OCR_RESULT", str(ocr_result))
            elif stage == 3:
                prompt = _PROMPT3.format(_OCR_RESULT=ocr_result)
            elif stage == 4:
                prompt = _PROMPT4
            else: 
                prompt = f"你是自主侦察无人机, 你的任务是在室内搜索{_TARGET}并攻击."

            # 1. 调用视觉模型分析图像
            vision_result = []
            
            # 拼接并分析图片
            vision_result_plain = self._analyze_ori_and_depth_imgs(image_base64, depth_imgs_base64)

            # 将视觉大模型返回的结果添加到prompt中(判断结果非空)
            this_prompt = ''
            if "_DEPTH_INFO" in prompt and vision_result_plain:
                this_prompt = prompt.replace("_DEPTH_INFO", str(vision_result_plain)) 
            else:
                print("可能不需要深度信息")
                this_prompt = prompt

            # 构建对话消息
            messages = [
                # 角色提示词
                {"role": "system", "content": _SYSTEM_PROMPT},
                # 对话历史
                *self._format_history(),
                # 阶段提示词
                {"role": "user", "content": this_prompt}
            ]
            
            # 2. 调用函数调用模型进行决策
            response = self.client.chat(
                model=MODEL_TOOL,
                messages=messages,
                tools=[self.fn.up, self.fn.down, self.fn.left, 
                      self.fn.right, self.fn.forward, self.fn.backward,
                      self.fn.rotate, self.fn.attack],
                stream=True
            )

            # 处理响应
            tool_calls = []
            full_response = []
            for chunk in response:
                if 'message' in chunk:
                    msg = chunk['message']
                    # 收集文本响应
                    if 'content' in msg:
                        print(msg['content'], end='', flush=True)
                        full_response.append(msg['content'])
                    # 收集函数调用
                    if 'tool_calls' in msg:
                        tool_calls.extend(msg['tool_calls'])
            
            # 执行函数调用
            if tool_calls:
                print("\n[系统] 执行函数调用...")
                for call in tool_calls:
                    self._execute_function_call(call)
            
            # 更新对话历史
            self._update_history(prompt, ''.join(full_response))
            
            # 检查阶段是否完成
            self._check_stage_completion()
            
            return {"status": "success", "stage": self.current_stage}
            
        except Exception as e:
            print(f"[错误] 决策处理失败: {str(e)}")
            return {"status": "error", "message": str(e)}
    #########################################
    def _execute_function_call(self, call):
        """执行函数调用"""
        func_name = call['function']['name']
        args = call['function']['arguments']
        
        try:
            # 转换参数格式
            if isinstance(args, str):
                args = json.loads(args.replace("'", '"'))
                
            print(f"[执行] {func_name}({args})")
            
            # 调用对应函数
            func = getattr(self.fn, func_name)
            if func_name == "attack":
                func()  # 无参数
            else:
                # 运动函数参数处理
                arg_name = 'cm' if func_name != 'rotate' else 'degrees'
                value = args.get(arg_name, 0)
                
                # 处理字符串值中的单位
                if isinstance(value, str):
                    if 'degrees' in value:
                        value = value.replace('degrees', '').strip()
                    elif 'cm' in value:
                        value = value.replace('cm', '').strip()
                
                # 转换为float
                value = float(value)
                
                func(value)
                
        except Exception as e:
            print(f"[错误] 函数执行失败: {str(e)}")
            raise
    #########################################
    def _format_history(self):
        """格式化对话历史"""
        return [
            dict_obj
            for item in self.chat_history[-20:]  # 保留最近20条
            for dict_obj in (
                {"role": "user", "content": item["q"]},
                {"role": "assistant", "content": item["a"]}
            )
        ]
    #########################################
    def _update_history(self, question, answer):
        """更新对话历史"""
        self.chat_history.append({"q": question, "a": answer})
        # 打印最后一条记录
        print("最新对话记录:", self.chat_history[-1])
    #########################################    
    def _check_stage_completion(self):
        """检查并更新任务阶段"""
        # 简单决策树:根据当前状态和响应判断是否进入下一阶段
        if self.current_stage == 1 and "move" in str(self.chat_history[-1]["a"]):
            pass
            # self.current_stage = 2
        elif self.current_stage == 2 and _TARGET in str(self.chat_history[-1]["a"]):
            pass
            # self.current_stage = 3
        elif self.current_stage == 3 and "attack" in str(self.chat_history[-1]["a"]):
            pass
            # self.current_stage = 4
        elif self.current_stage == 4 and "move" in str(self.chat_history[-1]["a"]):
            pass
            print("[系统] 任务完成!")
            # self.current_stage = 1  # 重置
    #############################################
    def _vlm_analyze_single_img(self, prompt, img_data_base64) -> str:
        '''使用VLM分析单张图片'''
        prompt_vision = prompt
        vision_result = ""
        
        try:
            print("\n[系统] 开始视觉深度分析处理...")
            
            # 假设image_base64_list是已经准备好的base64编码图像数据列表

            # 调用视觉模型进行深度分析,保持模型常驻
            stream = self.client.generate(
                model=MODEL_VISION,
                prompt=prompt_vision,
                # 注意: 一次只能推理一张图!!!
                images=[img_data_base64],
                stream=True,
                # 保持模型常驻内存
                keep_alive=-1,  
                # 设置上下文长度为最大值(根据模型和硬件限制调整)
                options={"num_ctx": 32768},  
            )

            # 收集和处理响应
            full_response = []
            print("[系统] 已提交视觉深度分析请求,等待响应...")
            
            for chunk in stream:
                print(chunk['response'], end='', flush=True)
                full_response.append(chunk['response'])
            
            # 获取完整的深度分析结果
            vision_result = ''.join(full_response)
            
            # 记录分析结果到日志
            with open('./result/depth_analysis.log', 'a', encoding='utf-8') as log_file:
                log_file.write(vision_result)
                log_file.write('\n')

            # 返回结果
            return vision_result
                
        except Exception as e:
            print(f"[错误] 视觉分析失败: {str(e)}")
            raise
    #############################################
    def _analyze_ori_and_depth_imgs(self, image_base64, depth_imgs_base64) -> str:
        '''拼接图片并分析原图和深度图'''
        vision_result = []
        if image_base64 and depth_imgs_base64:
            try:
                # 创建结果目录(如果不存在)
                os.makedirs("result", exist_ok=True)
                
                # 解码两张图片
                img1 = Image.open(io.BytesIO(base64.b64decode(image_base64)))  # 环境图片
                img2 = Image.open(io.BytesIO(base64.b64decode(depth_imgs_base64)))  # 深度图片
                
                # 获取两张图片中较大的宽度
                width = max(img1.width, img2.width)
                
                # 创建新图片(高度为两张图片之和)
                combined = Image.new('RGB', (width, img1.height + img2.height))
                
                # 拼接图片(如果宽度不同则居中放置)
                offset1 = (width - img1.width) // 2  # 第一张图片的水平偏移量(居中)
                combined.paste(img1, (offset1, 0))  # 粘贴第一张图片到顶部
                
                offset2 = (width - img2.width) // 2  # 第二张图片的水平偏移量(居中)
                combined.paste(img2, (offset2, img1.height))  # 粘贴第二张图片到底部
                
                # 保存到磁盘
                timestamp = str(time.time()).replace('.', '_')  # 生成时间戳作为文件名
                save_path = f"result/temp_images_{timestamp}.png"  # 保存路径
                combined.save(save_path)  # 保存合并后的图片
                
                # 将合并后的图片转换为base64编码
                buffered = io.BytesIO()  # 创建内存缓冲区
                combined.save(buffered, format="PNG")  # 将图片写入缓冲区
                combined_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')  # 编码为base64字符串
                
                # 分析合并后的图片
                combined_analysis = self._vlm_analyze_single_img("分析合并图片的环境和深度信息", combined_base64)
                vision_result.append(combined_analysis)  # 添加分析结果
            except Exception as e:
                print(f"图片合并失败: {e}")
                # 如果合并失败,回退到单独分析模式
                if image_base64:
                    vision_result.append(self._vlm_analyze_single_img("分析图片的环境信息", image_base64))
                if depth_imgs_base64:
                    vision_result.append(self._vlm_analyze_single_img("分析图片的深度信息", depth_imgs_base64))
        else:
            # 如果只有一张图片可用,执行原始单独分析逻辑
            if image_base64:
                vision_result.append(self._vlm_analyze_single_img("分析图片的环境信息", image_base64))
            if depth_imgs_base64:
                vision_result.append(self._vlm_analyze_single_img("分析图片的深度信息", depth_imgs_base64))
        
        # 合并所有视觉分析结果
        vision_result_plain = ''.join(vision_result)
        return vision_result_plain
        #############################################

#####################################
class _Move():
    '''给大模型函数调用(Function Call)的精简函数'''
    def __init__(self, robot, controller):
        self.controller = controller
        self.robot = robot
        self.drone = self.robot
    def up(self, cm):
        '''上升'''
        # 仿真步进
        if self.drone.step(self.drone.time_step) != -1:
            self.controller.move_up(cm)
            print(f"函数up:{cm}厘米")
    def down(self, cm):
        '''下降'''
        if self.drone.step(self.drone.time_step) != -1:
            self.controller.move_down(cm)
            print(f"函数down:{cm}厘米")
    def left(self, cm):
        '''左移'''
        # 仿真步进
        if self.drone.step(self.drone.time_step) != -1:
            self.controller.move_left(cm)
            print(f"函数left:{cm}厘米")
    def right(self, cm):
        '''右移'''
        # 仿真步进
        if self.drone.step(self.drone.time_step) != -1:
            self.controller.move_right(cm)
            print(f"函数right:{cm}厘米")
    def forward(self, cm):
        '''前进'''
        # 仿真步进
        if self.drone.step(self.drone.time_step) != -1:
            self.controller.move_forward(cm)
            print(f"函数forward:{cm}厘米")
    def backward(self, cm):
        '''后退'''
        # 仿真步进
        if self.drone.step(self.drone.time_step) != -1:
            self.controller.move_backward(cm)
            print(f"函数backward:{cm}厘米")
    def rotate(self, degrees):
        '''旋转'''
        # 仿真步进
        if self.drone.step(self.drone.time_step) != -1:
            self.controller.rotate(degrees)
            print(f"函数rotate:{degrees}度")
    def attack(self):
        '''攻击'''
        # 打开激光
        if self.drone.step(self.drone.time_step) != -1:
            self.controller.attack(True)
        # 等待一会
        time.sleep(5)
        # 关闭激光
        if self.drone.step(self.drone.time_step) != -1:
            self.controller.attack(False)
        print("函数attack完成")
#####################################
# 单元测试

# 测试例程
if __name__ == "__main__":
    # 阶段1的图片
    stage1_original = "./stage_images/1.png"
    stage1_depth = "./stage_images/1_depth.webp"
    stage1_ocr = """{"boxes":[[[[764,8],[787,8],[787,33],[764,33]]]],"texts":["X"],"scores":[0.8657664656639099]}"""
    
    # 阶段2的图片
    stage2_original = "./stage_images/2.png"
    stage2_depth = "./stage_images/2_depth.webp"
    stage2_ocr = """{"boxes":[[[[763,3],[790,3],[790,31],[763,31]]],[[[242,229],[330,231],[327,343],[239,340]]]],"texts":["X","C"],"scores":[0.7861287593841553,0.5938434600830078]}"""
    
    # 阶段3的图片
    stage3_original = "./stage_images/3.png"
    stage3_depth = "./stage_images/3_depth.webp"
    stage3_ocr = """{"boxes":[[[[345,184],[485,180],[490,382],[350,386]]]],"texts":["B"],"scores":[0.9931768178939819]}"""

    # 阶段4的图片
    stage4_original = "./stage_images/4.png"
    stage4_depth = "./stage_images/4_depth.webp"
    stage4_ocr = "错误"

    # 测试决策
    # 模拟测试
    #########################################
    class MockController:
        def move_up(self, cm): pass
        def move_down(self, cm): pass
        def move_left(self, cm): pass
        def move_right(self, cm): pass
        def move_forward(self, cm): pass
        def move_backward(self, cm): pass
        def rotate(self, degrees): pass
        def laser_on(self): print("激光开启")
        def laser_off(self): print("激光关闭")

    # 智能体
    _agent = _Ollama(None, MockController())

    #########################################
    # 辅助函数:将任意格式图像转换为PNG字节流
    def convert_to_png_bytes(image_path):
        """将任意格式图像转换为PNG格式的字节流"""
        try:
            with Image.open(image_path) as img:
                # 转换为RGB模式(兼容不支持透明通道的模型)
                if img.mode != 'RGB':
                    img = img.convert('RGB')
                
                # 转换为字节流
                img_byte_arr = io.BytesIO()
                img.save(img_byte_arr, format='PNG')
                return img_byte_arr.getvalue()
        except Exception as e:
            print(f"[错误] 图像转换失败({image_path}): {str(e)}")
            return None

    #########################################
    # 辅助函数:将图片转换为base64
    def image_to_base64(image_path):
        with open(image_path, "rb") as image_file:
            return base64.b64encode(image_file.read()).decode('ascii')
            
    #########################################
    # 辅助函数: 将任意格式图片转为png格式base64
    def any_image_to_base64(image_path):
        """将任意格式图像转换为PNG格式的base64编码字符串
        
        Args:
            image_path (str): 图像文件路径
            
        Returns:
            str: base64编码的PNG图像字符串,失败时返回None
        """
        try:
            with Image.open(image_path) as img:
                # 转换为RGB模式(兼容不支持透明通道的模型)
                if img.mode != 'RGB':
                    img = img.convert('RGB')
                
                # 转换为字节流
                img_byte_arr = io.BytesIO()
                img.save(img_byte_arr, format='PNG')
                img_bytes = img_byte_arr.getvalue()
                
                # 转换为base64编码
                base64_str = base64.b64encode(img_bytes).decode('ascii')
                return base64_str
                
        except Exception as e:
            print(f"[错误] 图像转换失败({image_path}): {str(e)}", file=sys.stderr)
            return None
    ##################################################
    
    # 模拟阶段1处理
    print("\n=== 阶段1测试 ===")
    _agent.process_stage(
        stage=1,
        image_base64=image_to_base64(stage1_original),
        depth_imgs_base64=image_to_base64(stage1_depth),
        ocr_result=stage1_ocr
    )

    # 模拟阶段2处理
    print("\n=== 阶段2测试 ===")
    _agent.process_stage(
        stage=2,
        image_base64=any_image_to_base64(stage2_original),
        depth_imgs_base64=any_image_to_base64(stage2_depth),
        ocr_result=stage2_ocr
    )

    # 模拟阶段3处理
    print("\n=== 阶段3测试 ===")
    _agent.process_stage(
        stage=3,
        image_base64=any_image_to_base64(stage3_original),
        depth_imgs_base64=any_image_to_base64(stage3_depth),
        ocr_result=stage3_ocr
    )

    # 模拟阶段4处理
    print("\n=== 阶段4测试 ===")
    _agent.process_stage(
        stage=4,
        image_base64=any_image_to_base64(stage4_original),
        depth_imgs_base64=any_image_to_base64(stage4_depth),
        ocr_result=stage4_ocr
    )

_ocr.py

# OCR识别模块
# 输入: 图片base64数据
# 输出: [{'txt':'识别文本', 'box': [[x1,y1],[x2,y2],[x3,y3],[x4,y4]], 'score': 置信度 }, ...]

# 坐标系说明: 图片左上角为原点(0,0),向右为x轴正方向,向下为y轴正方向

OCR_SERVICE_URL = "http://192.168.31.20:7861/"

#############################################
# 第三方库
from gradio_client import Client, handle_file
from PIL import Image, ImageDraw
# 内置库
import base64
import io
import os
import json
import requests
import logging
import time
#############################################

# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def check_ocr_service_available():
    """检查OCR服务是否可用"""
    try:
        response = requests.get(f"{OCR_SERVICE_URL}/", timeout=5)
        if response.status_code == 200:
            logger.info("OCR服务连接成功")
            return True
        logger.error(f"OCR服务返回状态码: {response.status_code}")
        return False
    except Exception as e:
        logger.error(f"连接OCR服务失败: {str(e)}")
        return False

def ocr_service(img_data_base64: str) -> list:
    """
    OCR识别服务接口
    :param img_data_base64: 图片的base64编码数据
    :return: 识别结果列表,每个元素包含文本、坐标框和置信度
    """
    temp_img_path = "./temp_ocr_input.png"
    print("hello_from_ocr_service")

    # 首先检查服务是否可用
    if not check_ocr_service_available():
        raise ConnectionError("OCR服务不可用, 请检查服务是否运行或URL是否正确")
    
    # 初始化Gradio客户端
    global client
    client = Client(OCR_SERVICE_URL)
    print("初始化gradio客户端成功")

    # 将base64数据转换为临时图片文件
    img_bytes = base64.b64decode(img_data_base64)
    with open(temp_img_path, "wb") as f:
        f.write(img_bytes)
    # exit(0)
    
    # 调用OCR接口
    logger.info("正在调用OCR服务...")
    result = client.predict(
        img=handle_file(temp_img_path),
        lang="en",  # 默认使用英文识别
        api_name="/predict"
    )
    
    logger.info("OCR服务调用成功,解析结果...")
    
    # 解析返回结果
    if not result or len(result) < 1:
        raise ValueError("OCR服务返回了无效的结果格式")
        
    _, ocr_data = result
    if not isinstance(ocr_data, dict):
        raise ValueError(f"OCR服务返回了意外的数据类型: {type(ocr_data)}")
    
    # 确保所有必要字段都存在
    required_fields = ['boxes', 'texts', 'scores']
    for field in required_fields:
        if field not in ocr_data:
            raise ValueError(f"OCR结果缺少必要字段: {field}")
    
    boxes = ocr_data['boxes']
    texts = ocr_data['texts']
    scores = ocr_data['scores']
    
    # 验证数据长度一致
    if not (len(boxes) == len(texts) == len(scores)):
        raise ValueError("OCR结果中boxes、texts和scores长度不一致")
    
    # 格式化输出结果
    output = []
    for box, text, score in zip(boxes, texts, scores):
        # 验证box格式
        if len(box) != 4 or any(len(point) != 2 for point in box):
            logger.warning(f"忽略无效的box格式: {box}")
            continue
            
        output.append({
            'txt': text,
            'box': [[float(point[0]), float(point[1])] for point in box],  # 转换为坐标点列表
            'score': float(score)
        })
    
    print("清理临时文件")
    # 清理临时文件
    if os.path.exists(temp_img_path):
        os.remove(temp_img_path)
        
    return output
    
def img_with_box(ocr_results: list, img_data_base64: str) -> str:
    """
    在图片上绘制OCR识别结果框和文本
    :param ocr_results: ocr_service返回的识别结果
    :param img_data_base64: 原始图片的base64数据
    :return: 带标注框的图片base64数据
    """
    # 将base64图片数据转换为PIL图像对象
    img_bytes = base64.b64decode(img_data_base64)
    img = Image.open(io.BytesIO(img_bytes))
    draw = ImageDraw.Draw(img)
    
    # 设置绘制样式
    box_color = (0, 0, 255)  # 蓝色框
    text_color = (255, 0, 0)  # 红色文字
    
    # 遍历所有识别结果并绘制
    for result in ocr_results:
        box = result['box']
        text = result['txt']
        score = result['score']
        
        # 绘制四边形框
        draw.polygon([
            (box[0][0], box[0][1]),
            (box[1][0], box[1][1]),
            (box[2][0], box[2][1]),
            (box[3][0], box[3][1])
        ], outline=box_color)
        
        # 在框上方添加识别文本和置信度
        text_with_score = f"{text}({score:.2f})"
        draw.text((box[0][0], box[0][1] - 25), 
                 text_with_score, 
                 fill=text_color)
    
    # 将处理后的图片转换为base64
    buffered = io.BytesIO()
    img.save(buffered, format="PNG")
    img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
    
    return img_base64

def ocr_img_with_box_and_save(img_data_base64:str) -> list:
    # 调用ocr服务
    ocr_result = ocr_service(img_data_base64)
    # 保存结果
    img_result_base64 = img_with_box(ocr_result, img_data_base64)
    # 保存结果图片
    output_path = f"images/ocr_result_{time.time()}.png"
    with open(output_path, "wb") as f:
        f.write(base64.b64decode(img_result_base64))
    return ocr_result
    
########################################
########################################
def _test():
    '''
    测试图片: img_0001.jpg
    '''
    img_data_base64 = ''
    # 读取测试图片并转换为base64
    test_img_path = "./img_0001.jpg"
    if not os.path.exists(test_img_path):
        raise FileNotFoundError(f"测试图片不存在: {test_img_path}")
        
    with open(test_img_path, "rb") as f:
        img_data_base64 = base64.b64encode(f.read()).decode('utf-8')
    
    # 调用OCR服务
    print("正在进行OCR识别...")
    ocr_results = ocr_service(img_data_base64)
    
    # 打印识别结果
    print("\n识别结果:")
    for i, result in enumerate(ocr_results, 1):
        print(f"{i}. 文本: {result['txt']}")
        print(f"   坐标: {result['box']}")
        print(f"   置信度: {result['score']:.2f}")
    
    # 生成带标注框的图片
    print("\n生成可视化结果...")
    annotated_img_base64 = img_with_box(ocr_results, img_data_base64)
    
    # 保存结果图片
    output_path = "ocr_result.png"
    with open(output_path, "wb") as f:
        f.write(base64.b64decode(annotated_img_base64))
    
    print(f"\n测试完成! 结果已保存到: {output_path}")
    
    # 尝试打开结果图片(仅当在支持的环境中)
    img = Image.open(output_path)
    img.show()

if __name__ == "__main__":
    _test()

_mapping.py

# 建图模块
# 输入: 列表-图片base64数据
# 输出: 三维模型glb格式: base64数据

# Gradio服务地址
OCR_SERVICE_URL = "http://192.168.31.20:7862/"

#############################################
# 第三方库
from gradio_client import Client, handle_file
from PIL import Image, ImageDraw
# 内置库
import base64
import io
import os
import json
import requests
import logging
import shutil
import time
#############################################

# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def check_mapping_service_available():
    """检查建图服务是否可用"""
    try:
        response = requests.get(f"{OCR_SERVICE_URL}/", timeout=5)
        if response.status_code == 200:
            logger.info("建图服务连接成功")
            return True
        logger.error(f"建图服务返回状态码: {response.status_code}")
        return False
    except Exception as e:
        logger.error(f"连接建图服务失败: {str(e)}")
        return False

def process_images_to_3dmodel(images_base64: list) -> str:
    """
    将多张图片转换为3D模型
    :param images_base64: 图片base64编码数据列表
    :return: glb格式3D模型的base64数据
    """
    # 检查服务是否可用
    if not check_mapping_service_available():
        raise ConnectionError("建图服务不可用, 请检查服务是否运行或URL是否正确")
    
    try:
        # 初始化Gradio客户端
        client = Client(OCR_SERVICE_URL)
        logger.info("Gradio客户端初始化成功")
        
        # 创建临时目录存放图片, 使用时间戳
        temp_dir = f"temp_images_{time.time()}"  
        if not os.path.exists(temp_dir):
            os.makedirs(temp_dir)
        
        # 将base64图片保存为临时文件
        image_paths = []
        for i, img_data in enumerate(images_base64):
            img_bytes = base64.b64decode(img_data)
            img_path = os.path.join(temp_dir, f"image_{i}.png")
            with open(img_path, "wb") as f:
                f.write(img_bytes)
            image_paths.append(img_path)
        
        # 调用建图API
        # 1. 上传文件到服务端
        gradio_files = [handle_file(img_path) for img_path in image_paths]
        _, target_dir, _, _ = client.predict(
            input_video=None,
            input_images=gradio_files,
            api_name="/update_gallery_on_upload_1"
        )
        
        # 2. 处理建图
        result = client.predict(
            target_dir=target_dir,
            conf_thres=50,
            frame_filter="All",
            mask_black_bg=False,
            mask_white_bg=False,
            show_cam=True,
            mask_sky=False,
            prediction_mode="Depthmap and Camera Branch",
            api_name="/gradio_demo"
        )
        
        # 获取GLB文件路径
        glb_path = result[0] if isinstance(result, (list, tuple)) else result
        print("本机glb路径: ", glb_path)
        
        # 读取GLB文件并转换为base64
        with open(glb_path, "rb") as f:
            glb_data = base64.b64encode(f.read()).decode('utf-8')
        
        # 清理临时文件
        for img_path in image_paths:
            if os.path.exists(img_path):
                os.remove(img_path)
        if os.path.exists(temp_dir):
            os.rmdir(temp_dir)
            
        return glb_data
        
    except Exception as e:
        logger.error(f"建图处理失败: {str(e)}")
        raise

def save_glb_base64(glb_base64: str, save_path: str):
    """
    保存base64格式的GLB文件到本地
    :param glb_base64: 输入GLB文件的base64数据
    :param save_path: 保存路径
    """
    try:
        # 确保目录存在
        # os.makedirs(os.path.dirname(save_path), exist_ok=True)
        
        # 解码并保存
        with open(save_path, "wb") as f:
            f.write(base64.b64decode(glb_base64))
            
        logger.info(f"GLB文件已保存到: {save_path}")
        return True
    except Exception as e:
        logger.error(f"保存GLB文件失败: {str(e)}")
        return False

def _test():
    """测试函数"""
    # 读取测试图片并转换为base64
    # test_images = ["../../../../assets/scene1.png"]
    test_images = ["./seekslam_apartment_1.png"]
    test_images_base64 = []

    if not test_images:
        raise ValueError("测试目录中没有找到图片文件")

    for image in test_images:
         with open(image, "rb") as f:
                test_images_base64.append(base64.b64encode(f.read()).decode('utf-8'))
    
    # 调用建图服务
    print("正在进行3D建图...")
    output_path = "output_model.glb"
    try:
        glb_base64 = process_images_to_3dmodel(test_images_base64)
        
        # 保存结果
        if save_glb_base64(glb_base64, output_path):
            print(f"\n测试完成! 3D模型已保存到: {output_path}")
        else:
            print("\n测试完成,但保存模型失败")
            
    except Exception as e:
        print(f"\n建图过程中出错: {str(e)}")

if __name__ == "__main__":
    _test()

_depth.py

# 深度估计模块
# 输入: 图片base64数据
# 输出: 深度图base64数据

DEPTH_SERVICE_URL = "http://10.8.8.20:7863/"

#############################################
# 第三方库
from gradio_client import Client, handle_file
from PIL import Image
# 内置库
import base64
import io
import os
import logging
import time
import requests
#############################################

# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def check_depth_service_available():
    """检查深度估计服务是否可用"""
    try:
        response = requests.get(f"{DEPTH_SERVICE_URL}/", timeout=5)
        if response.status_code == 200:
            logger.info("深度估计服务连接成功")
            return True
        logger.error(f"深度估计服务返回状态码: {response.status_code}")
        return False
    except Exception as e:
        logger.error(f"连接深度估计服务失败: {str(e)}")
        return False

def depth_service(img_data_base64: str) -> str:
    """
    深度估计服务接口
    :param img_data_base64: 输入图片的base64编码数据
    :return: 深度图的base64编码数据
    """
    temp_img_path = "./temp_depth_input.png"
    
    # 检查服务是否可用
    if not check_depth_service_available():
        raise ConnectionError("深度估计服务不可用, 请检查服务是否运行或URL是否正确")
    
    # 初始化Gradio客户端
    client = Client(DEPTH_SERVICE_URL)
    
    # 将base64数据转换为临时图片文件
    img_bytes = base64.b64decode(img_data_base64)
    with open(temp_img_path, "wb") as f:
        f.write(img_bytes)
    
    # 调用深度估计接口
    logger.info("正在调用深度估计服务...")
    result = client.predict(
        image=handle_file(temp_img_path),
        api_name="/on_submit"
    )
    
    logger.info("深度估计服务调用成功,处理结果...")
    
    # 处理返回结果(可能包含多个文件路径)
    depth_img_path = None
    for item in result:
        if isinstance(item, list):
            # 取第二个文件作为深度图
            if len(item) > 0:
                depth_img_path = item[1]
                break
        elif isinstance(item, str) and item.endswith(('.png', '.jpg', '.jpeg')):
            depth_img_path = item
            break
    
    if not depth_img_path or not os.path.exists(depth_img_path):
        raise ValueError("深度估计服务未返回有效的深度图路径")
    
    # 读取深度图并转换为base64
    with open(depth_img_path, "rb") as f:
        depth_img_bytes = f.read()
    depth_img_base64 = base64.b64encode(depth_img_bytes).decode('utf-8')
    
    # 清理临时文件
    if os.path.exists(temp_img_path):
        os.remove(temp_img_path)
        
    return depth_img_base64

def depth_img_and_save(img_data_base64:str, output_dir: str = "depth_results") -> str:
    """
    保存深度图结果到文件
    :param depth_img_base64: 深度图的base64数据
    :param output_dir: 输出目录路径
    :return: base64数据
    """
    # 获取深度图
    depth_img_base64 = depth_service(img_data_base64)
    
    # 创建输出目录
    os.makedirs(output_dir, exist_ok=True)
    
    # 生成带时间戳的输出文件名
    timestamp = int(time.time())
    output_path = os.path.join(output_dir, f"depth_result_{timestamp}.png")
    
    # 保存深度图
    with open(output_path, "wb") as f:
        f.write(base64.b64decode(depth_img_base64))
    
    return depth_img_base64

########################################
########################################
def _test():
    """
    测试深度估计模块
    测试图片: test_depth.jpg
    """
    # 读取测试图片并转换为base64
    test_img_path = "./seekslam_apartment_1.png"
    if not os.path.exists(test_img_path):
        raise FileNotFoundError(f"测试图片不存在: {test_img_path}")
        
    with open(test_img_path, "rb") as f:
        img_data_base64 = base64.b64encode(f.read()).decode('utf-8')
    
    # 调用深度估计服务
    print("正在进行深度估计...")
    depth_img_base64 = depth_img_and_save(img_data_base64)
    
    # 保存结果
    print(f"\n深度估计完成! 结果已保存")


if __name__ == "__main__":
    _test()

_pid.py

# 例程的pid控制器封装

# _pid.py
"""PID控制算法实现模块"""

#############################################
# 内置库
import time
# 第三方库
import numpy as np
import pytest
import simple_pid
#############################################
class _PIDController:
    """
    PID控制器实现类
    提供比例-积分-微分控制功能
    
    参数:
        kp (float): 比例增益
        ki (float): 积分增益
        kd (float): 微分增益
        output_limits (tuple): 输出限制 (min, max)
        sample_time (float): 采样时间(秒)
        auto_mode (bool): 是否自动模式(True=自动,False=手动)
    """
    
    def __init__(self, kp=1.0, ki=0.0, kd=0.0, output_limits=(None, None), 
                 sample_time=0.01, auto_mode=True):
        # 增益参数
        self.kp = kp
        self.ki = ki
        self.kd = kd
        
        # 输出限制
        self.output_limits = output_limits
        
        # 采样时间(秒)
        self.sample_time = sample_time
        
        # 控制器模式
        self.auto_mode = auto_mode
        
        # 控制器状态
        self._proportional = 0
        self._integral = 0
        self._derivative = 0
        self._last_error = 0
        self._last_output = 0
        self._last_time = None
        
        # 抗积分饱和
        self._integral_windup_guard = 20.0
        
        # 初始化
        self.reset()
    
    def __call__(self, error, dt=None):
        """
        计算PID输出
        
        参数:
            error (float): 当前误差(设定值-测量值)
            dt (float): 时间步长(秒)。如果为None,则使用预设的sample_time
            
        返回:
            float: PID控制输出
        """
        if not self.auto_mode:
            return self._last_output
            
        # 获取当前时间
        current_time = time.time()
        
        # 计算时间步长
        if dt is None:
            dt = current_time - self._last_time if self._last_time is not None else self.sample_time
        else:
            dt = float(dt)
            
        # 检查时间步长是否有效
        if dt <= 0:
            raise ValueError('dt必须为正数')
            
        # 存储当前时间用于下次计算
        self._last_time = current_time
        
        # 比例项
        self._proportional = self.kp * error
        
        # 积分项(使用梯形积分)
        self._integral += self.ki * (error + self._last_error) * 0.5 * dt
        
        # 抗积分饱和
        if self._integral > self._integral_windup_guard:
            self._integral = self._integral_windup_guard
        elif self._integral < -self._integral_windup_guard:
            self._integral = -self._integral_windup_guard
        
        # 微分项(防止设定值变化导致的微分冲击)
        self._derivative = self.kd * (error - self._last_error) / dt if dt > 0 else 0
        
        # 存储当前误差用于下次计算
        self._last_error = error
        
        # 计算输出
        output = self._proportional + self._integral + self._derivative
        
        # 应用输出限制
        if self.output_limits[0] is not None and output < self.output_limits[0]:
            output = self.output_limits[0]
            # 抗积分饱和(反向限制)
            if self._integral > 0:
                self._integral = 0
        elif self.output_limits[1] is not None and output > self.output_limits[1]:
            output = self.output_limits[1]
            # 抗积分饱和(反向限制)
            if self._integral < 0:
                self._integral = 0
        
        # 存储最后输出
        self._last_output = output
        
        return output
    
    def update(self, error, dt=None):
        """
        更新PID控制器(与__call__相同)
        """
        return self(error, dt)
    
    def reset(self):
        """
        重置PID控制器状态
        """
        self._proportional = 0
        self._integral = 0
        self._derivative = 0
        self._last_error = 0
        self._last_output = 0
        self._last_time = None
    
    def set_auto_mode(self, auto_mode, last_output=None):
        """
        设置控制器模式
        
        参数:
            auto_mode (bool): 是否自动模式
            last_output (float): 切换到自动模式时的初始输出值
        """
        if auto_mode and not self.auto_mode:
            # 从手动切换到自动
            self.reset()
            if last_output is not None:
                self._last_output = last_output
        
        self.auto_mode = auto_mode
    
    def set_tunings(self, kp=None, ki=None, kd=None):
        """
        设置PID参数
        
        参数:
            kp (float): 比例增益
            ki (float): 积分增益
            kd (float): 微分增益
        """
        if kp is not None:
            self.kp = float(kp)
        if ki is not None:
            self.ki = float(ki)
        if kd is not None:
            self.kd = float(kd)
    
    @property
    def components(self):
        """
        获取PID各项分量
        
        返回:
            tuple: (比例项, 积分项, 微分项)
        """
        return self._proportional, self._integral, self._derivative
    
    @property
    def tunings(self):
        """
        获取当前PID参数
        
        返回:
            tuple: (kp, ki, kd)
        """
        return self.kp, self.ki, self.kd

效果图

室内模拟环境 第二个房间的地图

参考文献

  • [1] K. Xiao, L. Ma, S. Tan, Y. Cong , X. Wang, "Implementation of UAV Coordination Based on a Hierarchical Multi-UAV Simulation Platform," Advances in Guidance, Navigation and Control. Lecture Notes in Electrical Engineering, 2022, vol 644. Springer, Singapore. doi: 10.1007/978-981-15-8155-7_423
  • [2] K. Xiao, S. Tan, G. Wang, X. An, X. Wang and X. Wang, "XTDrone: A Customizable Multi-rotor UAVs Simulation Platform," 2020 4th International Conference on Robotics and Automation Sciences (ICRAS), 2020, pp. 55-61, doi: 10.1109/ICRAS49812.2020.9134922.
  • [3] 使用OCR检测和识别字母[https://www.cnblogs.com/qsbye/p/18831983]
  • [4] 使用VGGT进行基于图像的室内建图[https://www.cnblogs.com/qsbye/p/18830067]
  • [5] 大模型函数调用[https://www.cnblogs.com/qsbye/p/18835026]
  • [6] 基于单目图像的深度估计[https://www.cnblogs.com/qsbye/p/18840431]
  • [7] Chi P, Wei J, Zhao J, et al. Autonomous UAV Exploration in Unknown Environments Using Octomap[C]//International Conference on Guidance, Navigation and Control. Singapore: Springer Nature Singapore, 2024: 357-365.
  • [8] H. Zhang, S. Wang, Y. Liu, P. Ji, R. Yu and T. Chao, "EFP: Efficient Frontier-Based Autonomous UAV Exploration Strategy for Unknown Environments," in IEEE Robotics and Automation Letters, vol. 9, no. 3, pp. 2941-2948, March 2024, doi: 10.1109/LRA.2024.3363531.
    keywords: {Quadrotors;Clustering algorithms;Planning;Autonomous aerial vehicles;Trajectory optimization;Three-dimensional displays;Computational efficiency;Aerial systems: applications;aerial systems: perception and autonomy;autonomous vehicle navigation},
  • [9] Zhang Z, Hu C, Lye S, et al. A VLM-Drone System for Indoor Navigation Assistance with Semantic Reasoning for the Visually Impaired[C]//2025 IEEE/SICE International Symposium on System Integration (SII). IEEE, 2025: 1260-1265.
posted @ 2025-04-25 12:08  qsBye  阅读(136)  评论(0)    收藏  举报