项目介绍:
本项目参考M1s Dock官方案例,实现通过python控制M1s Dock进行联网以及抓取摄像头数据进行网络推流。
在数据接收端,使用PyQT5编写接收程序,通过界面可以实时查看摄像头画面,并可以进行拍照和录像。
设计思路:
经过了解官方的资料以及演示程序和代码,了解到M1s Dock本身具有网络功能,同时提供了直接抓取摄像头数据并进行mjpeg推流的底层能力。
官方案例中的camera_streaming_through_wifi,演示了如何联网,以及如何进行mjpeg推流。
案例中的pikascript_demo,则演示了基础的pikascript的python环境。
在camera_streaming_through_wifi中,连接WiFi的信息,以及连接推流服务器的信息,都在c代码中写死了,非常的不方便。因此,考虑结合pikascript_demo,通过python调用WiFi连接和摄像头mjpeg推流,让使用变得方便起来。
同时,原有的推流服务端,有一个基础的使用opencv显示的例子。而我手头原有一个使用本地摄像头的PyQT5的应用,将两者结合,使得其能够支持通过socket推流过来的mjpeg数据流,并进行呈现。
要完成以上的工作,需要进行下面的N项工作:
- 了解pikascript的基础功能,以及如何进行模块开发
- 了解camera_streaming_through_wifi中进行网络连接和摄像头mjpeg推流的集体调用
- 在pikascript中添加模块,实现对2中具体功能的调用
- 修改原有的PyQT5的应用,接收mjpeg数据推流
工作1,经过了解,pikascript小巧精干,结构模块化,非常易于移植和二次开发。另外,在学习过程中,也得到了pikascript的作者李昂大佬以及梦程大神的手把手指点,表示特别的感谢。
工作2,M1s_BL808 SDK已经做好了封装,只要调用对应的函数,就能够使用对应的功能。
工作3,有了1、2的基础,这一步并不是很复杂,实现起来虽然有一点点难度,都是仔细下来都可以实现。
工作4,opencv很好的提供了对多种数据来源的支持,所以这一步,难度也不是非常大。
硬件介绍:
本项目使用的硬件,由硬禾提供。
另外,也自购了一个外壳:
硬件信息也不用多说,直接看官方提供的资料即可:
作为一款结构紧凑的开发板,自身已经能够对绝大部分应用提供了玩好的支持,因此也就基本不需要外部设备的支持了。
实现功能:
通过结合pikascript_demo和camera_streaming_through_wifi,本项目最终实现如下的功能:
- M1s Dock运行pikascript,通过串口连接REPL环境,进行操作
- 在pikascript环境中,使用python进行WiFi连接
- 在pikascript环境中,使用python开启摄像头数据采集
- 在pikascript环境中,使用python开启数据上传推流
- 在桌面环境中运行接收端程序,可以接收M1s Dock的mjpeg推流
- 在桌面程序中,可以点击拍照按钮,保存当前画面的照片
- 在桌面程序中,可以点击录像按钮,录制当前显示的画面
代码说明:
在文件M1s_BL808_example/c906_app/pikascript_demo/pikascript/BL808.pyi中,添加WiFi和摄像头调用的python接口定义:
import PikaStdDevice
class GPIO(PikaStdDevice.GPIO):
def platformHigh(self): ...
def platformLow(self): ...
def platformEnable(self): ...
def platformDisable(self): ...
def platformSetMode(self): ...
def platformRead(self): ...
class Time(PikaStdDevice.Time):
def sleep_s(self, s: int): ...
def sleep_ms(self, ms: int): ...
class WiFi(PikaStdDevice.BaseDev):
def init(self): ...
def connect(self, ssid: str, passwd: str): ...
def upload_stream(self, ip: str, port: int): ...
class Camera(PikaStdDevice.BaseDev):
def mipi_mjpeg_init(self): ...
在文件M1s_BL808_example/c906_app/pikascript_demo/pikascript/pikascript-lib/BL808/BL808_WiFi.c中,添加WiFi接口的具体实现:
#include "BL808_WiFi.h"
#include <FreeRTOS.h>
#include <task.h>
/* bl808 c906 std driver */
#include <bl808_glb.h>
#include <bl_cam.h>
#include <m1s_c906_xram_wifi.h>
// void BL808_WiFi_connect(PikaObj *self, char* ssid, char* passwd);
// void BL808_WiFi_init(PikaObj *self);
// void BL808_WiFi_upload_stream(PikaObj *self, char* ip, int port);
void BL808_WiFi_init(PikaObj* self) {
vTaskDelay(m1s_xram_wifi_init());
}
void BL808_WiFi_connect(PikaObj* self, char* ssid, char* passwd) {
vTaskDelay(m1s_xram_wifi_connect(ssid, passwd));
}
void BL808_WiFi_upload_stream(PikaObj* self, char* ip, int port) {
vTaskDelay(m1s_xram_wifi_upload_stream(ip, port));
}
在文件M1s_BL808_example/c906_app/pikascript_demo/pikascript/pikascript-lib/BL808/BL808_Camera.c中,添加摄像头调用接口的具体实现:
#include "BL808_Camera.h"
#include <FreeRTOS.h>
#include <task.h>
/* bl808 c906 std driver */
#include <bl808_glb.h>
#include <bl_cam.h>
#include <m1s_c906_xram_wifi.h>
// void BL808_Camera_mipi_mjpeg_init(PikaObj *self);
void BL808_Camera_mipi_mjpeg_init(PikaObj* self) {
vTaskDelay(bl_cam_mipi_mjpeg_init());
}
以上功能,参考了 M1s_BL808_example/c906_app/camera_streaming_through_wifi/main.c的代码:
#include <stdbool.h>
#include <stdio.h>
/* FreeRTOS */
#include <FreeRTOS.h>
#include <task.h>
/* bl808 c906 std driver */
#include <bl808_glb.h>
#include <bl_cam.h>
#include <m1s_c906_xram_wifi.h>
void main()
{
vTaskDelay(1);
bl_cam_mipi_mjpeg_init();
m1s_xram_wifi_init();
m1s_xram_wifi_connect("OpenBSD", "************");
m1s_xram_wifi_upload_stream("192.168.1.15", 8888);
}
其中:
-
m1s_xram_wifi_init():表示初始化WiFi设备
-
m1s_xram_wifi_connect(ssid, passwd):表示连接到WiFi路由器
-
m1s_xram_wifi_upload_stream(ip, port):表示连接到推流服务器
-
bl_cam_mipi_mjpeg_init():表示初始化摄像头mjpeg数据读取
在picascript中,其分别对应:
- BL808.WiFi.init()
- BL808.WiFi.connect(ssid, passwd)
- BL808.WiFi.upload_stream(ip, port)
- BL808.Camera.mipi_mjpeg_init()
最后是桌面pyqt5程序:
from PyQt5 import QtWidgets
from PyQt5.QtGui import QImage, QPixmap, QKeySequence
from PyQt5.QtCore import QThread
import sys, cv2, threading, random, signal
import numpy as np
import socket
import time, datetime
# 0-摄像头 1-socket
CAMERA_SOURCE = 1
CAMERA_LOCAL_INDEX = 0 # 如果使用本地摄像头,则表示其videoN的N
CAMERA_SOCKET_PORT = 8888 # 如果视同socket,设置端口
# 应用定义
app = QtWidgets.QApplication(sys.argv)
window_w, window_h = 640, 480 # 窗口宽度和高度
scale = 0.58 # 视频信息宽高比
# 界面定义
Form = QtWidgets.QWidget()
Form.setWindowTitle('网络图像流' if CAMERA_SOURCE == 1 else 'USB摄像头')
Form.resize(window_w, window_h)
# 窗口大小改变时自动调整按钮
def windowResize(self):
global window_w, window_h, scale
window_w = Form.width() # 窗口宽度
window_h = Form.height() # 窗口高度
label.setGeometry(0,0, window_w, int(window_w*scale)) # 调整 QLabel 尺寸
btn1.setGeometry(10, window_h-40,70,30) # 调整按钮位置
btn2.setGeometry(80, window_h-40,70,30) # 调整按钮位置
btn3.setGeometry(window_w - 80, window_h-40,70,30) # 调整按钮位置
Form.resizeEvent = windowResize # 设置窗口大小改变时触发
# 关闭应用时的处理
ocv = True # 设置是否处理视频
def closeOpenCV(self):
global ocv, output
ocv = False # 关闭窗口时,停止处理视频
print("关闭程序")
try:
output.release() # 关闭窗口时,释放视频处理资源
except:
pass
Form.closeEvent = closeOpenCV # 窗口关闭时触发
label = QtWidgets.QLabel(Form)
label.setGeometry(0,0, window_w, int(window_w*scale)) # 设置 QLabel 的位置和大小
# 存储文件时使用的文件名
def rename():
# return str(random.random()*10).replace('.','')
return datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
photo = False # 按下拍照按钮时,设置处于拍照状态
# 按下拍照按钮时的处理
def takePhoto():
global photo
photo = True # 设定拍照状态为True
print("马上拍照")
btn1 = QtWidgets.QPushButton(Form)
btn1.setGeometry(10, window_h-40,70,30) # 设置拍照按钮的位置和大小
btn1.setText('拍照')
btn1.clicked.connect(takePhoto) # 按下拍照按钮时触发
fourcc = cv2.VideoWriter_fourcc(*'mp4v') # 设置视频中的存储格式
recorderType = False # 按下录像按钮时,设置处于录像状态
# 按下录像按钮时的处理
def recordVideo():
global recorderType, output
if recorderType == False:
# 如果按下按钮时没有在录像,则开始录像
# 设定存储的视频信息
output = cv2.VideoWriter(f'videos/{rename()}.mp4', fourcc, 20.0, (window_w, int(window_w*scale)))
recorderType = True # 设置正在录制状态
btn2.setGeometry(80, window_h-40,200,30) # 根据显示内容设置大小
btn2.setText('录像中,点击停止保存')
else:
# 如果按下按钮时正在在录像,则停止录像
output.release() # 释放视频存储资源
recorderType = False # 设置非录制状态
btn2.setGeometry(80, window_h-40,70,30) # 根据显示内容设置大小
btn2.setText('录像')
btn2 = QtWidgets.QPushButton(Form)
btn2.setGeometry(80, window_h-40,70,30) # 设置录像按钮的位置和大小
btn2.setText('录像')
btn2.clicked.connect(recordVideo) # 按下录像按钮时触发
# 按下退出按钮时的处理
def quitApp():
global video_server
print("退出程序")
closeOpenCV(False)
app = QtWidgets.QApplication.instance()
app.quit()
btn3 = QtWidgets.QPushButton(Form)
btn3.setGeometry(window_w-80, window_h-40,70,30) # 设置退出按钮的位置和大小
btn3.setText('退出')
btn3.clicked.connect(quitApp) # 按下退出按钮时触发
# 本地摄像头处理服务
def video_opencv_server():
global window_w, window_h, scale, photo, output, recorderType, ocv
cap = cv2.VideoCapture(CAMERA_LOCAL_INDEX)
if not cap.isOpened():
print("Cannot open camera")
exit()
while ocv:
ret, frame = cap.read() # 读取摄像头视频帧
if not ret:
print("Cannot receive frame")
break
try:
frame = cv2.resize(frame, (window_w, int(window_w*scale))) # 修改帧大小
if photo == True:
name = rename() # 设置文件名称
cv2.imwrite(f'photos/{name}.jpg', frame) # 存储图片
photo = False # 拍照完,设置非拍照状态
if recorderType == True:
output.write(frame) # 按下录像按钮时,输出到存储文件
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # 设置为 RGB
height, width, channel = frame.shape
bytesPerline = channel * width
img = QImage(frame, width, height, bytesPerline, QImage.Format_RGB888)
label.setPixmap(QPixmap.fromImage(img)) # 显示
except:
pass
if CAMERA_SOURCE == 0:
# video_server = threading.Thread(target=video_opencv_server) # 使用线程执行
video_server = QThread()
video_server.run = opencv
video_server.start()
# mjpeg数据流处理服务
def mjpeg_socket_server():
global window_w, window_h, scale, photo, output, recorderType, ocv
# 创建tcp服务端套接字
# 参数同客户端配置一致,这里不再重复
tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 设置端口号复用,让程序退出端口号立即释放,否则的话在30秒-2分钟之内这个端口是不会被释放的,这是TCP的为了保证传输可靠性的机制。
tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True)
# 给客户端绑定端口号,客户端需要知道服务器的端口号才能进行建立连接。IP地址不用设置,默认就为本机的IP地址。
tcp_server.bind(("", CAMERA_SOCKET_PORT))
# 设置监听
# 128:最大等待建立连接的个数, 提示: 目前是单任务的服务端,同一时刻只能服务与一个客户端,后续使用多任务能够让服务端同时服务与多个客户端
# 不需要让客户端进行等待建立连接
# listen后的这个套接字只负责接收客户端连接请求,不能收发消息,收发消息使用返回的这个新套接字tcp_client来完成
tcp_server.listen(128)
print("等待客户端连接...")
# 等待客户端建立连接的请求, 只有客户端和服务端建立连接成功代码才会解阻塞,代码才能继续往下执行
# 1. 专门和客户端通信的套接字: tcp_client
# 2. 客户端的ip地址和端口号: tcp_client_address
tcp_client, tcp_client_address = tcp_server.accept()
# 代码执行到此说明连接建立成功
print("客户端的ip地址和端口号:", tcp_client_address)
count = 0
while ocv:
if True:
# 接收客户端发送的数据, 这次接收数据的最大字节数是4
recv_data = tcp_client.recv(4)
mjpeg_len = int.from_bytes(recv_data, 'little')
count+=1
if count % 1000 == 1:
print("\trecv len: ", mjpeg_len)
tcp_client.send(recv_data)
recv_data_mjpeg = b''
remained_bytes = mjpeg_len
while remained_bytes > 0:
recv_data_mjpeg += tcp_client.recv(remained_bytes)
remained_bytes = mjpeg_len - len(recv_data_mjpeg)
if count % 1000 == 1:
print("\trecv stream success")
if recv_data_mjpeg[:2] != b'\xff\xd8' \
or recv_data_mjpeg[-2:] != b'\xff\xd9':
continue
mjpeg_data = np.frombuffer(recv_data_mjpeg, 'uint8')
img = cv2.imdecode(mjpeg_data, cv2.IMREAD_COLOR)
# cv2.imshow('stream', img)
if count==1:
sp = img.shape
sz1 = sp[0] #height(rows) of image
sz2 = sp[1] #width(colums) of image
sz3 = sp[2] #the pixels value is made up of three primary colors
print('网络图像: width=%d \theight=%d \tnumber=%d' % (sz1, sz2, sz3))
scale = sz1/sz2
frame = cv2.resize(img, (window_w, int(window_w*scale))) # 改变帧大小
if photo == True:
name = rename() # 设置文件名称
name_save = f'photos/{name}.jpg'
print("照片存储:%s" % name_save)
cv2.imwrite(name_save, frame) # 存储图片
photo = False # 拍照完,设置非拍照状态
if recorderType == True:
output.write(frame) # 按下录像按钮时,输出到存储文件
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # 设置为 RGB
height, width, channel = frame.shape
bytesPerline = channel * width
img = QImage(frame, width, height, bytesPerline, QImage.Format_RGB888)
label.setPixmap(QPixmap.fromImage(img)) # 显示
# except:
# pass
# 关闭服务与客户端的套接字, 终止和客户端通信的服务
tcp_client.close()
# 关闭服务端的套接字, 终止和客户端提供建立连接请求的服务 但是正常来说服务器的套接字是不需要关闭的,因为服务器需要一直运行。
# tcp_server.close()
if CAMERA_SOURCE == 1:
# mjpeg_server = threading.Thread(target=mjpeg_socket_server) # 使用线程执行
video_server = QThread()
video_server.run = mjpeg_socket_server
video_server.start()
Form.show()
sys.exit(app.exec_())
该程序中,有较为详细的注释,基本逻辑为:
- 使用pyqt5,显示桌面应用界面
- 启动socket接收数据流的线程
- 新的线程中,接收到mjpeg推流数据,界面,并使用opencv显示到界面上
操作步骤:
- 参考M1s Dock官方说明,下载好源码和SDK,配置好开发编译环境
- 按照如上的代码说明,修改 M1s_BL808_example/c906_app/pikascript_demo 下对应的源码
- 使用pikaCompiler进行预编译
- 下载pikapython源码:https://gitee.com/Lyon1998/pikapython
- 在pikapython/tools/pikaCompiler目录中,运行cargo build进行编译;运行cargo,需要事先准备好rust开发环境
- 将生成的pikapython/tools/pikaCompiler/target/debug/rust-msc文件,拷贝到M1s_BL808_example/c906_app/pikascript_demo/pikascript/目录下
- 运行M1s_BL808_example/c906_app/pikascript_demo/pikascript/rust-msc
- 在M1s_BL808_example/c906_app/目录下,运行./build.sh pikascript_demo,编译完成后,将M1s_BL808_example/c906_app/build_out/d0fw.bin文件拷贝出来备用
- 参考官方文档,刷 firmware_20230227.bin 固件
- 参考官方文档,U盘刷d0fw.bin固件,然后重新连接,确保串口可用
- 打开桌面接收程序
- 使用串口连接到pikascript,并设置网络和开启推流:
- 连接成功后,桌面接收程序命令行将会提示:
- 桌面程序开始显示摄像头画面:
- 点击拍照,或者点击录像后:
- 命令行输出对应信息
- 存储目录里面对应的文件存储:
- 命令行输出对应信息