送个小纪念品吧(2)

结构介绍

续接上集,相机拍照和USB读卡已经能用了,但只能拍照片总觉得差点意思。本期主要给咱的相机进行了一些升级,除了增加2k视频录制功能之外,还要有配套的APP,所以本期博客就是在完成升级任务:

  • 视频录制
  • ffmpeg解码
  • Android13软件开发

整个架构分三块:H7负责取景、按键和屏幕显示,树莓派Zero 2W挂着官方相机专门负责高清拍照和录像,两边用串口通信,H7把按键命令发过去就行;录好的文件直接放进apache2的网站目录,手机和相机在同一网络下打开网页就能看,配套的APP其实就是个套了壳的网页,省得每次输网址。

视频录制

这里选择了树莓派zero 2W进行视频与图像处理,至于opencv可有可无,opencv录制直接输出mp4文件,而用picamera录制会输出.h264文件,可以通过ffmpeg转封装为mp4。h264是编码后的裸流,里面只有画面数据,没有封装容器,所以播放器直接打开是不认的;用ffmpeg把容器换成mp4,视频流本身不用重编码,很快就能转完,画质也不会损失。

脚本这边,串口和摄像头各初始化一次,之后就在一个死循环里等命令。命令是单字节的:’1’拍照,’2’开始录像,’0’停止录像,处理完清一下输入输出缓冲,免得旧数据串味。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import serial
from picamera import PiCamera
import os
import datetime

ser = serial.Serial("/dev/ttyAMA0", 115200)
camera = PiCamera()

path = "/var/www/html/"
filename_gb = ""

def savePhoto(filename):
camera.resolution = (3280, 2460)
print("Photo OK")
camera.capture(f'{filename}.jpg')
return 3

def startVideo(filename):
print("Video Start")
camera.resolution = (1920, 1080)
camera.framerate = 30
camera.video_stabilization = True
global filename_gb
filename_gb = filename
camera.start_recording(f'{filename}.h264')
return 8

def stopVideo():
print("Video Stop")
global filename_gb
camera.stop_recording()
cmd = f'ffmpeg -i {filename_gb}.h264 -vcodec copy -acodec copy {filename_gb}.mp4'
os.system(cmd)
cmd = f'rm {filename_gb}.h264'
os.system(cmd)
return 4

def readData():
# 读取一个字节
recv_buffer = ser.read(1)
# 返回接收到的数据
return recv_buffer

def main():
data = readData()
filename = path + datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
if data == b'1':
savePhoto(filename)
elif data == b'2':
startVideo(filename)
elif data == b'0':
stopVideo()
else:
pass
ser.flushInput()
ser.flushOutput()




if __name__ == "__main__":
if os.path.isdir(path):
while True:
main()
pass
else:
print("Disk Error")

说明一下几个参数:拍照分辨率设成3280×2460,存的是接近满分辨率的原图;录像降到1920×1080@30帧,这个分辨率对Zero 2W来说压力小,还开了电子防抖;文件名用当前时间生成,年月日时分秒排下来天然有序,不会重名。文件统一放在/var/www/html/下面,录完就能从网页直接访问,不用再挪一次。有两个地方注意一下:树莓派的串口默认被控制台占用着,要在配置里把控制台关掉,不然读写/dev/ttyAMA0会打架;相机排线方向别插反,卡扣要按到位。

UI布局

本来打算在Android内完成的,但是想了一下发现自己对Android的掌握度不高,尤其是kotlin,只能用java写,所以倒不如把相册保存到apache2网站目录后,用php显示,所以下面是显示的php代码。这样一来不管是用浏览器还是用APP,看到的都是同一套页面,改版式也只动一个地方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
<?php
$path = "./albums";
$imgType = array('png','jpg','mp4');
$show_begin = 0;
$step = 20;

$handle = opendir($path);
while(($file = readdir($handle)) != false){
list($filename , $kzm) = explode('.', $file);
if (in_array($kzm, $imgType)) {
if (!is_dir('/'.$file)){
$filelist[] = $file;
}
}
}
if(sizeof($filelist) > 0)
$total_page = ceil(sizeof($filelist)/$step);
else $total_page = 0;

if(isset($_GET["p"])) $page = $_GET["p"];
else $page = 1;


function getFolderSize($dir) {
$size = 0;
if (is_dir($dir)){
$dh = opendir($dir);
while(false !== ($file = @readdir($dh))){
if($file !='.' and $file !='..'){
$path = $dir.'/'.$file;
if(is_dir($path)){
$size += dir_size($path);
}else if(is_file($path)){
$size += filesize($path);
}
}
}
closedir($dh);
}else{
echo "路径不是文件夹路径";
}
return $size;
}

$kb = 1024;
$mb = 1024 * 1024;
$gb = 1024 * 1024 * 1024;
$limitSize = "64GB";

$size = getFolderSize($path);
$r = "B";

$pre = $size / ($gb*$limitSize);
$pre = round($pre, 2, PHP_ROUND_HALF_UP);

if($size > $kb && $size < $mb){
$size = $size / $kb;
$size = round($size, 2, PHP_ROUND_HALF_UP);
$r = "KB";
}else if($size > $mb && $size < $gb){
$size = $size / $mb;
$size = round($size, 2, PHP_ROUND_HALF_UP);
$r = "MB";
}else{
$size = $size / $gb;
$size = round($size, 2, PHP_ROUND_HALF_UP);
$r = "GB";
}
?>

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Albums</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<link rel="stylesheet" href="mdui/css/mdui.min.css">
<?php if($page<$total_page) echo '<link rel="preload" href="/?p=' .$page+1 . '" as="style" />'; ?>
<script>var total = <?php echo $total_page;?>; var pagenow = <?php echo $page;?>;</script>
<script src="mdui/js/index.js"></script>
<script src="mdui/js/mdui.min.js"></script>
</head>
<body class="mdui-bottom-nav-fixed">
<div class="mdui-appbar mdui-color-grey-700">
<div class="mdui-toolbar mdui-color-theme">
<a href="https://blog.minloha.cn" class="mdui-typo-title">Micamera</a>
<a href="javascript:new mdui.Dialog('#pre_pre').open();" class="mdui-typo-title"><?php echo $size . $r; ?>/<?php echo $limitSize; ?></a>
</div>
</div>
<div class="mdui-container-fluid">
<?php
if(sizeof($filelist) != 0)
for($i = ($page-1)*$step; $i < $page*$step; $i++){
if($i < sizeof($filelist)){
$f = $filelist[$i];
list($filename , $type_file) = explode('.', $f);
echo '<div class="mdui-card">';
echo '<div class="mdui-card-media">';
if($type_file == "jpg")
echo '<a href="./albums/'.$f.'"><img src="./albums/'.$f.'" style="width=100%;"/></a>';
else
echo '<a href="./albums/'.$f.'"><video><source src="./albums/'.$f.'" type="video/mp4"></video></a>';
echo '<div class="mdui-card-media-covered">';
echo '<div class="mdui-card-primary">';
echo '<div class="mdui-card-primary-title">'.$f.'</div>';
echo '</div>';
echo '</div>';
echo '</div>';
echo '<div class="mdui-card-actions">';
echo '<button class="mdui-btn mdui-ripple" onclick="remove(\'' . $f . '\')">删除</button>';
echo '<a style="text-decoration: none;color: black;" href="./albums/'.$f.'" download="/albums/' . $f . '"><button class="mdui-btn mdui-ripple" >保存</button></a>';
echo '</div>';
echo '</div>';
}
}
else{
echo '<div class="mdui-dialog" id="dialog">';
echo '<div class="mdui-dialog-title">Micamera提示</div>';
echo '<div class="mdui-dialog-content">相册是空的,没有什么可以展示的呢~</div>';
echo '<div class="mdui-dialog-actions">';
echo '<button class="mdui-btn mdui-ripple">好吧</button>';
echo '</div>';
echo '</div>';
echo "<script>new mdui.Dialog('#dialog').open()</script>";
}
?>
<?php
if($pre > 0.9){
echo '<div class="mdui-dialog" id="dialog">';
echo '<div class="mdui-dialog-title">Micamera警告</div>';
echo '<div class="mdui-dialog-content">内存空间不足,请删除照片!</div>';
echo '<div class="mdui-dialog-actions">';
echo '<button class="mdui-btn mdui-ripple">收到</button>';
echo '</div>';
echo '</div>';
echo "<script>new mdui.Dialog('#dialog').open();</script>";
}
?>
</div>
<div class="mdui-bottom-nav mdui-bottom-nav-text-auto mdui-color-grey-700">
<a onclick="sub()" class="mdui-ripple mdui-ripple-white mdui-bottom-nav-active">
<i class="mdui-icon material-icons">keyboard_arrow_left</i>
<label>上一页</label>
</a>
<p>第<?php echo $page; ?> / <?php echo $total_page;?>页</p>
<a onclick="add()" class="mdui-ripple mdui-ripple-white mdui-bottom-nav-active">
<i class="mdui-icon material-icons">keyboard_arrow_right</i>
<label>下一页</label>
</a>
</div>
<div class="mdui-dialog" id="pre_pre">
<div class="mdui-dialog-title">内存消耗情况:</div>

<div class="mdui-p-a-2">
<p>使用空间:<?php echo $size . $r; ?></p>
<p>使用占比:</p>
</div>
<div class="mdui-progress">
<div class="mdui-progress-determinate" style="width: <?php echo round($pre*100,0,PHP_ROUND_HALF_UP);?>%;"></div>
</div>

<div class="mdui-dialog-actions">
<button class="mdui-btn mdui-ripple">我看懂了</button>
</div>
</div>
</body>
</html>

页面逻辑平平无奇,就是列目录、分页、显示:opendir()扫一遍albums目录,用扩展名过滤出png、jpg、mp4,每页20个,靠URL上的p参数翻页;getFolderSize()递归累加文件大小,换算成KB/MB/GB显示在顶栏,容量按64GB算,用到90%以上就弹窗提醒删东西;卡片列表里点图片或视频是打开原文件,前端用的是mdui这套Material风格的库,手机上看着还行。

除此之外还要有删除接口,只需要实现一个GET参数的文件删除即可:

1
2
3
4
5
6
7
8
<?php
$filename = $_GET['f'];
if(file_exists("./albums/" .$filename)){
unlink("./albums/" . $filename);
echo '1';
}else{
echo '0';
}

APP部分

因为视频文件放在apache2的网站二级目录,所以直接做一个WebView视图即可;由于WebView无法直接获取返回状态码,所以先用HttpURLConnection测试连接,连接成功后再让WebView加载。连不上或者返回非200的时候,就加载asset里的本地错误页,总比让用户对着白屏强。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
package cn.minloha.micamera;

import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.app.DownloadManager;
import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.net.http.SslError;
import android.os.Environment;
import android.os.StrictMode;
import android.util.Log;
import android.view.KeyEvent;
import android.webkit.*;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import androidx.core.content.FileProvider;

import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;

import static android.os.Environment.DIRECTORY_DCIM;
import static android.os.Environment.DIRECTORY_DOWNLOADS;

public class MainActivity extends AppCompatActivity {
private WebView browser;

@SuppressLint("SetJavaScriptEnabled")
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitAll().build();
StrictMode.setThreadPolicy(policy);

browser = (WebView)findViewById(R.id.Toweb);
browser.getSettings().setDomStorageEnabled(true);
browser.getSettings().setDatabaseEnabled(true);
browser.getSettings().setJavaScriptEnabled(true);
browser.getSettings().setSupportZoom(true);
browser.getSettings().setBuiltInZoomControls(true);
String path = "http://10.0.4.1/";
try {
URL url = new URL(path);
HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection();
httpURLConnection.setConnectTimeout(3000);
httpURLConnection.connect();
int code = httpURLConnection.getResponseCode();
if (code == 200) browser.loadUrl(path);
else{
browser.loadUrl("file:///android_asset/html/non.html");
Log.d("MicameraInfo","Http");
}
} catch (IOException e) {
browser.loadUrl("file:///android_asset/html/502.html");
Log.d("MicameraInfo","HttpError");
}

browser.setWebViewClient(new WebViewClient() {
@Override
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
super.onReceivedHttpError(view, request, errorResponse);
int statusCode = errorResponse.getStatusCode();
Log.d("MINFO","Code is:" + statusCode);
}

@SuppressLint("WebViewClientOnReceivedSslError")
@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
if (handler != null) {
handler.proceed();
}
}
});

browser.setDownloadListener((url, userAgent, contentDisposition, mimetype, contentLength) -> {
// 指定下载地址
String Filetype = url.substring(url.length()-3);
String filename = String.valueOf(System.currentTimeMillis()) + url.substring(url.length()-4);
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
request.setAllowedOverMetered(true);
request.setAllowedOverRoaming(true);
String info = "";
if(Filetype.equals("pdf")) {
filename = "产品说明书.pdf";
info = "在文件管理器中的文档中查看\"产品说明书.pdf\"";
request.setDestinationInExternalPublicDir(DIRECTORY_DOWNLOADS, filename);
}else {
request.setDestinationInExternalPublicDir(DIRECTORY_DCIM, filename);
info = "在相册中的DCIM下查看";
}
final DownloadManager downloadManager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
long downloadId = downloadManager.enqueue(request);
new AlertDialog.Builder(this).setTitle("下载成功!").setMessage(info).setPositiveButton("我知道了",null).show();
});

}

@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
WebView browser=(WebView)findViewById(R.id.Toweb);
if ((keyCode == KeyEvent.KEYCODE_BACK) && browser.canGoBack()) {
browser.goBack();
return true;
}
return super.onKeyDown(keyCode, event);
}

}

代码里有几个地方说明一下:StrictMode那两行是放开主线程的网络限制用的,正常开发不该这么干,但内网小工具图省事就放飞了;onReceivedSslError里直接proceed()是信任所有证书,自己搭的站点没证书索性放行,正式项目可别学;下载监听器里按文件后缀分流,pdf存到下载目录,照片和视频存到DCIM,这样相册和图库都能直接扫到;onKeyDown里让返回键优先走网页的历史记录,不然点一下返回整个应用就退了。

总结

软件这部分没啥难度,在外壳上花费的时间比较多,调通其实很快。录制出来的h264过一遍ffmpeg就能在手机上看,相册的浏览、下载、删除在网页端和APP里也都跑通了。目前还差面板没有3D打印,剩下就是打胶和组装了!


送个小纪念品吧(2)
https://blog.minloha.cn/posts/070111538e4ed2024020125.html
作者
Minloha
发布于
2024年2月1日
更新于
2024年2月1日
许可协议