跳到主要内容

2 篇博文 含有标签「GoPro」

查看所有标签

· 阅读需 22 分钟
  1. 方案 1:物理手段解决
  2. 方案 2:借助OpenCV基于文件创建时间进行推算
  3. 方案 3:借助GoPro Labs添加时间戳浮层
    1. 浮层大小
    2. 浮层内容
    3. 浮层位置
    4. 二维码生成
    5. 二维码使用
    6. 缺陷
    7. 其他用处
  4. *方案 3 个人笔记
    1. LAB01: how to install the labs firmware
    2. LAB02: QR Controls For Settings
    3. LAB03: add an overlay to viedeo
  5. 最后一点感受

在上一期【文章:gopro】中,我们提供了一种借助 gopro 基于延时摄影从而底层本高质量地全程记录个人生活的解决方案,实测中这个方案仍然有一些不足之处,本期就重点探讨时间戳相关的问题。

这个问题其实很关键,因为我们正在做的事情是长时间无感拍摄,目的是为了后期复盘,但在复盘过程中,由于延时时间跨度过长,我们希望知道视频里的每一帧所拍摄的实际时间,比如是某月某日的下午两点还是夜间十点,这是一个很自然的需求。

但很可惜,这个问题并不容易解决。

以下便基于这个问题,提供实际可行的三种方案。

方案 1:物理手段解决

如果你是一个比较传统的、具有较强时间观的人,也许这种方案特别适合你,那就是直接选购一款挂钟(这也是我在上篇文章中所采用的方法)。

例如,我们这里有一段使用挂钟时期的成品视频:

picture 3

将我们的挂钟摆在所拍摄视频中的合理位置,既增强了画面构图感,也能实时知道每一帧所对应的真实时间,这确实有一石二鸟之效。

但很可惜,这种方案,并未能坚持多久,原因是,廉价的挂钟机芯转动声音影响到了自己的睡眠。

按照我对睡眠无声的要求,在依次关闭空调、窗户、电脑散热器、硬盘柜之后,室内的声音强度耳测应该已经低于 30 分贝,此时挂钟的声音就显得尤为明显了。

尽管它不大,但确实也没那么动听,至少甚至不如星际穿越的曲子《Tick-Tock》的前奏。

除非我愿意花更多的时间与金钱去筛选到另一款真正无声的挂钟,但作为一名程序猿,也许有更廉价与炫酷的方案。

方案 2:借助OpenCV基于文件创建时间进行推算

本节参考:- GoPro TimeStamp - The Eminent Codfish

事实上,由于我们的延时摄影采用了固定时间间隔的方案(有关“自动时间间隔”与“固定时间间隔”方案的差异与优劣,也在上篇文章中有阐述),因此只要我们知道视频的任何一帧所对应的真实物理时间,我们就可以基于等差数列算法进行推算。

而这,恰恰可行,因为 gopro 的视频输出格式为 mp4,里面保存了文件的创建时间。

不过,这个文件创建时间,可不是我们在电脑上看到的那个文件创建时间,这不是同一个概念。

例如,我们这里有一段视频的开头显示是 6 月 15 日 10 点 05 分:

picture 4

但在我们的文件管理器上查看该文件的创建时间却是 6 月 16 日 10 点 57 分:

picture 5

这显然不是时区的问题,而是操作系统问题,即在操作系统里定义的文件创建时间是该文件在本地磁盘上首次产生的时间,而非文件本身的首次创建时间(对于该视频来说,尤指该视频开始拍摄的时间)。

但我们并非没有手段获取实际视频创建时间,答案就是基于软件解析的手段:

picture 6

上述代码使用了ffprobe这个指令解析了我们的 mp4 文件,从结果中的creation_time标签得到了我们想要的视频创建的真实时间,于是乎,最难的问题解决了。

接下来,我们就基于视频领域的一些基本概念为我们的视频加上时间戳信息。

第一步是关于帧的。

在之前文章中我们说我们的视频是每 5 秒延时拍摄一帧,也就是每过一帧实际时间就要大致加五秒,这是一个很重要的信息。

而整个流程就是使用 opencv 读取我们视频的每一帧,然后算出该帧的时间,然后将格式化的时间戳放到那一帧上面去,最后再输出成一个新的视频,这个过程就叫“烧录”。

核心代码如下:

# ref: - [GoPro TimeStamp - The Eminent Codfish](https://www.theeminentcodfish.com/gopro-timestamp)
import time
from datetime import datetime, timedelta

import cv2
import subprocess as sp
from tqdm import tqdm

from get_file_creation_time import get_creation_time

FONT_COLOR = (0, 0, 200) # blue, green, red


def get_output_file_path(file_path):
return file_path[:-4] + '_with-timestamp' + file_path[-4:]


def add_timestamp_for_video(file_path):
# Opens the video import and sets parameters
video = cv2.VideoCapture(file_path)

if not video.isOpened():
raise Exception(f"failed to open video of {file_path}")

FPS = video.get(cv2.CAP_PROP_FPS)
width = video.get(cv2.CAP_PROP_FRAME_WIDTH)
height = video.get(cv2.CAP_PROP_FRAME_HEIGHT)
frames_cnt = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) # float -> int
duration = frames_cnt / FPS

# Initializes time origin of the video
creation_time = get_creation_time(file_path)
cur_frame_time: datetime = creation_time
print(
f"frames_cnt: {frames_cnt}, FPS: {FPS}, initial_timestamp: {cur_frame_time}, dimension: {width} x {height}, duration: {duration}")

# Command to send via the command prompt which specifies the pipe parameters
command = ['ffmpeg',
'-y', # (optional) overwrite output file if it exists
'-f', 'rawvideo', # Input is raw video
'-vcodec', 'rawvideo',
'-pix_fmt', 'bgr24', # Raw video format
'-s', str(int(width)) + 'x' + str(int(height)), # size of one frame
'-r', str(FPS), # frames per second
'-i', '-', # The input comes from a pipe
'-an', # Tells FFMPEG not to expect any audio
'-vcodec', 'mpeg4',
'-b:v', '10M', # Sets a maximum bit rate
get_output_file_path(file_path) # output file path
]

# Open the pipe
pipe = sp.Popen(command, stdin=sp.PIPE, stderr=sp.PIPE)
print('====== OpenCV is processing =======')
process_time_start = time.time()

# Reads through each frame, calculates the cur_frame_time, places it on the frame and
# exports the frame to the output video.
for i in tqdm(range(frames_cnt)):
success, image = video.read()
if not success:
print(f"ERROR: failed to read frame of [{i + 1}/{frames_cnt}]")
continue

cur_frame_time += timedelta(seconds=5)
cv2.putText(image, 'Date: ' + str(cur_frame_time)[0:10], (50, int(height - 150)),
cv2.FONT_HERSHEY_COMPLEX_SMALL, 2,
FONT_COLOR, 3)
cv2.putText(image, 'Time: ' + str(cur_frame_time)[11:], (50, int(height - 100)), cv2.FONT_HERSHEY_COMPLEX_SMALL,
2,
FONT_COLOR, 3)
assert image is not None
pipe.stdin.write(image.tobytes())

print('====== finished =======')
process_used_time = time.time() - process_time_start
print(
f"used time: {process_used_time} seconds, video period: {duration} seconds, multiplier: {process_used_time / duration}")
video.release()
pipe.stdin.close()
pipe.stderr.close()


if __name__ == '__main__':
add_timestamp_for_video("/Volumes/Disk1/videos/gopro日常/gopro延时生活/2022-06-14/GH011701.MP4")

输出结果:

picture 7

可以看到,基本一比一的处理速度,也就是说原视频时间有多长,程序运行时间就有多长,对于延时摄影来说,一天的录制输出大概就是十分钟的视频,因此脚本每天的运行时长也就是在十分钟左右,这是可以接受的。

不过这个脚本也有一点点小问题。

第一个问题是每次读取到第二十帧的时候就会报错,之后就没问题,不明白什么原因,也似乎不影响程序的表现,这个属于 opencv 范畴的问题了,留到以后再考虑了。

第二个问题就是输出的视频文件比原文件要小很多,直接差了十倍……:

picture 8

我们放在一起比对了一下,发现分辨率是没区别的,区别可能是在编码上:

picture 9

实际看了一下,发现确实画质上有较大区别,比如原视频的白墙很光滑:

picture 10

但编码后的白墙就明显有那种类似波纹的效果了:

picture 11

从这个角度上说,如果不考虑修改脚本,使之输出原质量的视频,那么我们的脚本输出的视频就只能当做备份与参照视频了,毕竟十倍的比率确实也有一些用武之地。

然而,这个脚本实际上还有另一个问题,那就是时间不一定能对齐。

比如刚刚文件对比图上,一个是 20 秒,一个是 19 秒,当然这个其实没有太大所谓了(虽然很非遗所以,也许就是和上面那个有两帧无法处理的问题有关),我更关心的是另一个对齐问题,曝光与时间间隔。

就是,怎么说呢,我现在并不确定 gopro 的曝光与拍摄间隔之间的关系,比如说,假设我们设置拍摄间隔为 2 秒,曝光时长为 1 秒,那么它是每 3 秒拍一次照片还是每 2 秒拍一张呢?

如果是每 3 秒拍一次照片,那么我们的算法每一帧加 5 秒就有问题,因为没有算上曝光时间,这样就会导致累积时间变少。

但如果是每 2 秒拍一张照片,那么更极端的一个情况,例如拍摄间隔为 1 秒,曝光时长为 2 秒,则似乎就无法拍摄了……

所以,这里有研究一下的必要。

总之,这种借助 opencv 进行后期处理的手段还是有一定实用价值,适用于很多其他的领域,是一种通用技术手段,我们有必要掌握,并且我对熟练掌握 opencv 之后输出我们想要的视频结果的可能性抱有很高的期望,除了曝光与拍摄间隔对齐的问题之外。

但输出的画面质量不佳(若提高质量(假设可行),势必速度会下降)与额外脚本运行的不便等因素,使我最后选择了第三种更干净的方案:GoPro Labs

方案 3:借助GoPro Labs添加时间戳浮层

如果说第二种方案属于后期,那么本方案则属于前期了。

众所周知,防患于未然,能从前期着手的方案,基本都是最优的。

而 GoPro Labs 就能帮助我们实现这点。

我们要做的事情就是:

  1. 下载 GoPro Labs 的固件版本,完成固件升级(我的 GoPro 是 9,是支持的)
  2. 制作我们的时间戳浮层二维码,扫描后使用

主要通过这个网站: https://gopro.github.io/labs/control/overlays/ 生成我们所需的二维码,这里的每个参数都比较重要,我们一一解释:

浮层大小

picture 12

vertical size是浮层的整体高度,而非单个文字的高度,由于浮层支持多行文本,因此为了目标字体大小,多行文本时对应的竖直高度也要变高,我们设置为 60,对应三行文本,(也就是每行 20,单位大概是像素),效果不错

horizontal size是浮层的整体宽度,因为这个底层是按行排版文本,因此我们直接设置成 0,让它自动调整浮层的宽度即可,即所有行中最长的行的宽度

offset from the edge是与画面边缘(应该是 Y 轴,因为 X 轴距离可以通过下文的换行符控制)的距离, 目前使用的是 10,效果还不错

Limit dispaly time设置成 unlimited 即可。

浮层内容

picture 13

这个就是配置我们的自定义文本与时间、日期的地方,它是一行行下来的,可以自己用\n分隔符换行(但不可以换顺序,所以时间始终在日期之前)。

为了从简,我没有加任何个人自定义短语(一开始加了自己的昵称,后来觉得太冗余了,所以还是留空了),但我在尾部加了两个空行,这是为了主动地增加相机与底部(X 轴)的边距,这样输出的视频里的时间戳在我 mac 电脑上预览时不会被进度条挡住。

浮层位置

picture 16

我将时间戳放在了左下角,因为我平常会在右下角办公,而画面中上部则是主体区域。

我们的场景中不需要使用 GPS。

二维码生成

picture 17

在生成二维码附近还有是否选择active的选项,我之前一开始捣鼓 lab 时弄出了 bug,在 模式设置:GoPro Labs: QR Controls For Settings 中设置出了一个没有的模式,导致系统死掉,最后刷回 GoPro9 1.5 的稳定固件版本,然后重新升级到 lib 版本才恢复,这其中既有可能是模式问题,也有可能是这个浮层当时选了permanent选项,因此我现在已经完全不敢点这个选项了。但并不影响我目标功能的实现。

基于这些,最终的二维码就生成了,它对应的 QR Command 是:

g0oMBRNO=10oMBURN="(0,60)[BLHH:MM:SSaa mm-dd-yyyy]\n"

以后我直接基于这个 Command 字符串进行修改即可,还是很方便的,比如加上自定义前缀与后缀的结果如下:

g0oMBRNO=10oMBURN="(0,60)哈哈哈[BLHH:MM:SSaa mm-dd-yyyy]略略略\n"

如果我们用函数去表达这个字符串输出就是:

def gen_gopro_overlay(
overlay_edge: int,
overlay_width: int,
overlay_height: int,
content_leading: str,
content_ending: str,
datetime_format: str
) -> str:
return f'g0oMBRNO={overlay_edge}oMBURN="({overlay_width},{overlay_height}){content_leading}[BL{datetime_fornat}]{content_ending}'

注意到,我们的时间格式也是可以自定义的,并且如果我们基于从字符串(而非网页交互)出发去生成二维码,那么我们就可以定义日期在前时间在后的格式了,比如yyyyy-mm-dd HH:MM:SSaa。虽然这一点未经过测试,但想必是可行的(写文章时才意识到的,hh~)

二维码使用

只需要将升级成 lab 固件版本的 gopro 打开后对准我们的二维码,就会自动识别并应用了,然后“BOOM”,一个可爱的浮层就出现了!

picture 18

缺陷

  1. 除了上文提到的容易导致系统挂掉之外,正常操作不会有问题
  2. 经测试,我们的时间戳浮层对照片和夜间延时没有效果,对普通视频与正常延时有效果。经过思考,我这个项目一开始采用夜间延时的方案现在觉得不太适用,因为实测下来在夜间我屋子几乎全黑的环境下即使开了夜间延时也几乎无法拍出能看清的画面,而白天看投影时又会导致曝光过高,因此直接放弃夜间的效果即可。当初拍夜间是为了监测睡眠,那段时间也会在睡觉时留一盏小灯(戴眼罩)以获得最好的拍摄效果,现在睡眠项目已经结项了,因此不需要了。我也更喜欢无声+全黑的睡眠环境。
  3. 关于上一点,也好像不是没有解决方案,正如我笔记中记载的 Combining the video overlay and interval video feature. 这篇帖子里的小哥貌似碰到的就是和我差不多的问题并有一个解决方案,但我暂时已经不需要了,以后有机会再研究吧!

其他用处

GoPro Labs 的功能不仅限于添加浮层这一项,恰恰相反,这是里面最小的一个应用之一。

picture 19

它还有很多其他有趣的场景应用,必须运动检测、自动切换模式等,详情可参考:

*方案 3 个人笔记

LAB01: how to install the labs firmware

see Hero9 Black Product Update | GoPro, but I failed to generate the target firmware in this site.

So I downloaded it in other place, e.g. - Need HERO9 Black firmware update v1.5 | GoPro Forums, which directed me to the dropbox download page: - GoPro Hero 9 firmwares fishycomics.zip

And finally, I saved two different firmwares in local disk, one for stable version, one for the lib one:

picture 2

picture 1

ref:

LAB02: QR Controls For Settings

see:

LAB03: add an overlay to viedeo

see:

ref:

bugfix: gopro labs won't add timestamp to interval videos, see:

最后一点感受

国外的极客生态真地很繁荣,一个小小的 gopro 就能玩出这么多的花样,反观国内,似乎还停留在各种参数的堆砌与各种大饼的圈画上,实则看不到太多对待一个具体的事物(而非钱)研究的诚意与专注的热爱。

之前携国货之光大疆无人机奔赴川藏线,它真地很好。但现在身处帝都,最终还是无用武之地,幸好有位天体物理博士愿意收购它,让它继续发光发热。而我,则只有尽我可能,把一些有趣的东西搞的更明白一些、更有用一些、更好玩一些。

gopro 的话题,从上篇的 open-gopro 到本篇的 GoPro Labs,基本已经涉及了 gopro 核心开发生态的方方面面,朋友们需要时完全可以顺着这两篇一窥 gopro 的里子,进而玩出新的花样。

下一期,我们聊聊最近做的另外一个有趣的小东西:投影与手机遥控器。

Best Wishes to All of You.

· 阅读需 19 分钟

背景

北京难得下雨,因此,一旦下雨就会想起什么。

可是,人的记忆力总是有限的,也是虚无的。

人毕竟是肉体凡胎,人与人之间需要有实体的互动。

而最有效的实体,便是从古代的文字,到近代的照片,再到现在的短视频。

这是时代的进步,但我想,或许还可以更进一步,例如随着 CPU、硬盘等信息技术的升级,未来建立全息个人档案的可行性将会逐渐增大,以至于,人类或许能够拥有完整追溯一个人过往以及重生一个人的能力。

但这项技术落入大众消费的范畴还远不可敢想,然,并非绝无希望的可能。

最近,恰好因了疫情的肆虐,我偶然间探索出了一种以视频完整记录人生的解决方案。

那就是:全天候自动 4K 延时摄影!

概述

首先我们要明确我们的目标,我们想完整录制自己的人生,如果可行,需要什么条件。

首先,我想,分辨率不能太低,不然没眼看,因此至少也得是 1080p,最好是 4K,因为 4K 基本能看清我们任意想看的细节。

我们就以 4K 为例,探讨一下硬件要求。

以 GoPro 摄像机为例,4K30 帧录制 10 分钟的文件大小大约在 4.5G,于是一小时便是 27G,一天便是 648G,一年便是 231T。

picture 3

231T 是什么概念。。。

显然不可行。

有段时间,我下决心解决我睡眠质量不好的问题,我想到了一个方案,把自己每天的睡眠录制下来,那个时刻,我第一反应便是延时!

没错,延时摄影非常适合长时间观察某个对象的场景:睡眠监控、人生监控,都非常适合。

说干就干,我监控了自己一周的睡眠:

picture 4

不过一开始由于没有啥经验,因此每天复盘自己的拍摄成果,至少解决了以下几个问题:

  1. gopro 的电池容量并不大,4k 视频录制一小时就要关机了,因此需要配一根充电线保持实时输电状态录制。
  2. 一开始选用的是夜景延时的默认自动时长选项,这导致了拍摄出来的延时短片没光时帧少,有光时帧多,这是有好处的,对后期非常方便。但其实不太适合我们的需求,因为我们只需要平凡的记录,不需要做啥后期,忽快忽慢的拍摄不利于自己对时间的把握,尤其是有光的时候,gopro 拍的太快了,导致文件体积增大,也可能不太符合预期。后来摸索得出,把每帧时长控制在 5 秒,既可以控制 10 分钟内拍完一整晚,输出的文件不用分片。5 秒的帧间隔有啥意义呢,按照 30p 来算的话,一秒本来需要 30 帧,而现在 30 帧对应了真实世界的 150 秒,所以等于把实际世界压缩了一百多倍。回想我们之前说的 231T,如果除以 150,我们得到了 2,也就是一年的延时录制只需要 2T 的硬盘就可以了,我们买一张 16T 的机械硬盘,将允许我们录制 5 年以上!因此,硬盘方面的问题解决了。
  3. 尽管我们使用了固定的帧间隔,我们很多时候还是想知道拍摄的任意一帧是处于什么时间,我查阅了一些资料,有个比较复杂的 python 脚本可以允许对 gopro 的视频添加时间层信息,但我觉得没有必要。我想到了一个有意思的办法,那就是买了个挂钟,能同时显示日期和时间的挂钟(不需要很贵),这样就很直观了: 康巴丝(Compas)挂钟时尚卧室客厅办公时钟日历挂表简约创意石英钟表 2941Y 日历黑白色直径 30cm【图片 价格 品牌 报价】-京东
  4. gopro 的无线方案其实远比想象地要复杂,官网介绍的是通过手机 gopro quik 软件连接 gopro,并支持预览和操控 gopro,而 pc 端的话要用 usb 连接读卡器,从而完成文件管理。这个步骤其实很繁琐,我想尝试无线的方案。后来我找到了 gopro 的开源代码库:open-goprogoprocam,前者是 gopro 官方代码库,后者是某个人开发的 wifi api。前者功能比较全面与底层,后者 sdk 做的比较友好方便,但支持 wifi。从理论上说,我们使用goprocam的 wifi api 可以在电脑已经连接到 gopro 后,直接使用程序完成自动下载、清理存储的目标,但仍不够自动化,于是我又花了整整一天的时间研究明白了不需要手机 app 直接全程在电脑端控制 gopro 的原理与实现路径。
  5. 全自动化。由于我们最终设计出了完全可以自动的程序,所以又花了一下午研究清楚了基于crontab的定时工具,完成了全流程自动化,一切,是那样完美地运行着。

接下来,将详细展开该解决方案中的一些问题。

基于 GoPro 的全天候自动 4K 延时摄影系统设计

设备依赖

  1. 一台 gopro 9+设备(否则不支持open-gopro),可持续充电,固定房间某个角落
  2. 一台电脑,安装了python 3.8+,定时运行转储程序
  3. 一个 2T 以上空间的机械硬盘,定时转储视频
  4. 不需要网络,在转储时,gopro 会自动开启一个本地局域网(但为了不影响实际 wifi 工作,我们的程序还是做了无线网络切换模块)

全自动化流程

flowchart

Start[开始]

--> ConnectGoproViaBle["PC蓝牙连接gopro设备(将自动打开gopro)"]

--> ShutterOff["关闭gopro的摄影(否则无法执行文件传输)"]

--> EnableWifiAndConnect[打开gopro的wifi并连接]

--> TransferFiles[讲gopro的文件转储到PC磁盘]

--> DisableWifi["关闭gopro的wifi,复原PC的wifi连接"]

--> Switch2NightLapseMode["切换GoPro到夜景延时模式,设置好参数"]

--> ShutterOn[开启gopro的摄影]

-- "一天后(十分钟的延时输出)" --> ConnectGoproViaBle

PC 端直接控制 gopro 拍摄、下载、清空的实现方案

首先,我们要知道 gopro 的无线通信框架,参见:Open GoPro : Open GoPro可以知道 gopro 只可以被蓝牙唤醒,然后被蓝牙、wifi 或者 usb 控制,文件管理功能只允许 wifi 或者 usb。

picture 5

因此要先启动蓝牙连接,成功后再开启 wifi 进行文件传输。

核心代码如下:

import asyncio
import logging
import os
import json
import time
from binascii import hexlify
from datetime import datetime
from typing import List

import requests
from bleak import BleakScanner, BleakClient

from base.const import COMMAND_REQ_UUID, GOPRO_WIFI_BASE_URL, GOPRO_WIFI_GP_COMMAND, GOPRO_MEDIA_DIR
from base.config import CONFIG_WIFI_GOPRO, CONFIG_WIFI_HOME, CURRENT_DATA_PATH
from base.log import logger
from base.utils import get_media_online_path
from base.wifi import connect_wifi, is_wifi_connected

# suppress unnecessary mac ble error
logging.getLogger("bleak.backends.corebluetooth.scanner").setLevel(logging.CRITICAL)


class MyGoPro:

def __init__(self):
self._client = None
self._event = asyncio.Event()
self._current_data_path = CURRENT_DATA_PATH

async def connect_ble(self):

def notification_handler(handle: int, data: bytes) -> None:
logger.info(f'<--- Response: {handle=}: {hexlify(data, ":")!r}')
# Notify the writer
self._event.set()

logger.info(f"\n==== datetime: {datetime.now()}")
logger.warning("电脑第一次与gopro配对时,需要保持gopro处于设置面板选中quik应用连接界面,直到收到所有返回消息方可确认配对成功")
logger.warning("程序运行之时,请确保没有其他进程正在执行,手机quik不要处于预览状态,否则将导致连接不上!")
try:
gopro_device = None
count = 0
# 1. 扫描蓝牙设备。正常情况下,要扫描1-2遍。
while not gopro_device:
logger.info(f"---- Scanning for bluetooth devices... [#{count}]")
# 需要蓝牙权限,例如pycharm、terminal、iterm之类的应用
for device in await BleakScanner.discover(timeout=5):
logger.info(f"detected ble device: {device.name}")
if device.name.startswith("GoPro"):
gopro_device = device
count += 1
logger.info(f"found gopro:{gopro_device.name}")

# 2. 连接蓝牙设备。配对自动进行,mac平台跳过(待确认)。
logger.info(f"----- Establishing BLE connection to {gopro_device.name}...")
client = BleakClient(gopro_device)
await client.connect(timeout=30) # 太短的话会导致连接失败
logger.info("BLE Connected!")

# 3. 获取通知。
logger.info("---- Enabling notifications...")
for service in client.services:
for char in service.characteristics:
if "notify" in char.properties:
logger.info(f"Enabling notification on char {char.uuid}")
await client.start_notify(char, notification_handler)
logger.info("Done enabling notifications")

self._client = client
except Exception as e:
logger.error(f"Connection establishment failed: {e}")
raise e

async def _send_command(self, data: List[int], desc: str = None):
if not self._client:
await self.connect_ble()
if desc is None:
desc = f"Sending command of: {data}"
desc = "---> Requesting: " + desc
logger.info(desc)

self._event.clear()
await self._client.write_gatt_char(COMMAND_REQ_UUID, bytearray(data), True)
await self._event.wait() # Wait to receive the notification response
time.sleep(2) # 比如开启wifi之间就需要时间间隔

async def send_command_load_timelapse_preset(self):
# ref: https://gopro.github.io/OpenGoPro/ble_2_0#commands-quick-reference
await self._send_command([0x04, 0x3E, 0x02, 0x03, 0xEA], "Loading timelapse preset")

async def send_command_enable_shutter(self, on: bool):
await self._send_command([3, 1, 1, on], "Control shutter: " + ("ON" if on else "OFF"))

async def send_command_enable_wifi(self, on: bool):
"""
wifi名称和密码的程序化获取方式,参见`tutorial_5_connect_wifi/wifi_enable.py`
:param on:
:return:
"""
await self._send_command([0x03, 0x17, 0x01, on], "Control wifi: " + ("ON" if on else "OFF"))

def _check_wifi(func):
"""
对需要使用wifi的函数装饰检查与修复
:return:
"""

def wrapper(self, *args, **kwargs):
if not is_wifi_connected():
self.send_command_enable_wifi(True)
return func(self, *args, **kwargs)

return wrapper

@_check_wifi
def list_all_media(self):
url = GOPRO_WIFI_BASE_URL + "/gopro/media/list"
result = requests.get(url).json()
logger.debug(result)
date_ = datetime.today().isoformat()
with open(f"media_list_{date_}.json", "w") as f:
json.dump(result, f, indent=2)
return result

@_check_wifi
def download_single_media(self, fn: str):
logger.info(f"downloading file: {fn}")
result = requests.get(get_media_online_path(fn)).content
fp = os.path.join(self._current_data_path, fn)
with open(fp, "wb") as f:
logger.info(f"saving into: {fp}")
f.write(result)
logger.info("√")

def _delete_media(self, option):
url = GOPRO_WIFI_GP_COMMAND + "/storage/delete" + option
res = requests.get(url).json()
logger.info(res if res else '√')

@_check_wifi
def delete_single_media(self, media_name: str):
"""
删除单个文件时,网址结尾的"delete"不能带"/"
:param media_name:
:return:
"""
logger.warning(f"deleting media: {media_name}")
self._delete_media(option=f"?p={GOPRO_MEDIA_DIR}/{media_name}")

@_check_wifi
def delete_all_media(self):
logger.warning("deleting all the media")
self._delete_media("/all")


async def task_daily_download_all_the_videos():
my_gopro = MyGoPro()

# connect_ble part
await my_gopro.connect_ble()

# wifi part
await my_gopro.send_command_enable_shutter(False) # wifi打开之间需要停止拍摄
await my_gopro.send_command_enable_wifi(True)
connect_wifi(*CONFIG_WIFI_GOPRO)
logger.info("---- downloading all the media")
for dir in my_gopro.list_all_media()["media"]:
logger.info(f"traverse media in dir: {dir['d']}")
for file in dir["fs"]:
fn = file["n"]
my_gopro.download_single_media(fn)
my_gopro.delete_single_media(fn)
# TODO:将gopro无线桥接到路由器,从而化简该步
# 最终要保证wifi回来
connect_wifi(*CONFIG_WIFI_HOME)
await my_gopro.send_command_enable_wifi(False)

# continue part
await my_gopro.send_command_load_timelapse_preset()
await my_gopro.send_command_enable_shutter(True)


if __name__ == '__main__':
# 若以下任务是类的一个方法,则需要使用loop,如果是一个全局函数,则可以直接用run,参考:https://cloud.tencent.com/developer/article/1598240
asyncio.run(task_daily_download_all_the_videos())

该代码主要参考open-gopro仓库里的samples,做了不少的修改与自己的封装,核心难点是asyncio.Event的使用。

除去技术上的难点,在产品上也有不少的坑。首先官网和绝大多数文章都只告诉你怎么用手机连 gopro,而鲜有用电脑无线练的。

我也是抱着尝试的心态,在 gopro hero 9 中打开“连接设备-quik 应用”后,直接在电脑端启动脚本成功连上的,这说明,手机 gopro quik 软件的连接原理和open-gopro仓库是等同的。

另外,蓝牙连接要使用到bleak底层库,也有一些可以忽略的Error信息,这些都需要花时间去鉴别,幸而没有大问题。

紧接着,由于蓝牙属于系统的敏感权限,因此脚本启动时,需要确保有蓝牙权限,一般情况下会弹窗,否则得自己加(比如terminal.appiTerms.app/usr/sbin/cron等),这一步将直接劝退对操作系统不怎么熟的程序员,因为你几乎找不到任何相关的资料。

picture 6

基于crontab自动化的定时程序

自动化的必要性

尽管程序我们写好了,已经可以运行,自动控制 gopro 的开启、录制、转储等功能,但是得每天都运行一遍,否则 gopro 的存储卡(我放的是一张 128G 的存储卡)将很快被撑爆(尤其是录制视频而非延时的话)。

而每天手动执行是很烦的,并且由于执行过程中需要连接到 gopro 的无线网络,因此本地就没法联网了。所以最适合的办法是让电脑自动夜间执行,达到无感的目的。

我想了想,最佳的时间点是凌晨五点,此时大多数情况下环境已有微光,能看清,而自己一般都在熟睡,不会有太大的动作;而 gopro 自动操作的哔哔哔声,也一定程序上有益于自己的早起。

自动化的充分性

我们要回到项目的起点上,我们做这个工程的意义是什么?

是为了如实地记录自己的人生,而非是一场作秀,对不?

那就尽量做到无感,甚至忘记这个项目!

当一切做到自动化之后,很多时间里自己都不知道自己是处于被记录的状态,这才是我们最想要的。

因此,我们需要自动化

自动化方案选择

在 mac 平台上,至少有两种,一种是基于crontab,这也是 linux 通用的解决方案。

还有一种是使用launchtlplist的方案。

考虑到兼容性,以及自己在 linux 平台上的经验,我选择使用crontab

crontab自动化的核心难点

理论上当我们的程序已经写完之后,将程序喂给crontab然后自动化运行,是再自然不过的事情了。

然而,我们的项目却有点不一样。

首先就是我们刚刚说的蓝牙权限的问题。由于crontab是一个后台程序,因此不会弹窗提醒你要不要允许系统权限,而是直接报错。

所以我们首先要跟踪crontab的输出,这是写自动化脚本的基本素养。

其次在定位到问题之后,要把我们的蓝牙权限交给cron

另外还有一个问题,在我们的程序中,我们调用了 mac 的系统调用去控制 wifi,其中在 mac 平台上连接名称为 A 密码为 B 的 wifi 的命令如下:

networksetup -setairportnetwork en0 A B

但这里有两个问题,首先是如果我们的名称里有引号,则会被 unix 系统识别错误,我们需要这么写:

networksetup -setairportnetwork en0 "A's wifi" B

open-gopro项目里这部分代码就写的很粗糙,他们直接用单引号去包括 wifi 的名称和密码,这在 wifi 名称或密码里包含单引号时将导致错误。

picture 7

其次还有个问题:

➜  ~ which cron
/usr/sbin/cron
➜ ~ which networksetup
/usr/sbin/networksetup

虽然cronnetworksetup都处于/usr/sbin/目录下,但当cron运行时,系统路径里却只有少的可怜的PATH

picture 8

因此在cron程序中,无法直接调用networksetup

picture 9

最简单的做法,是在我们的程序中显式指定networksetup的路径:

/usr/sbin/networksetup en0 A B

这样,将原程序配置到crontab实现自动化的所有问题都解决了:

picture 10

说在最后的话

绝大多数人的生命都是平淡如水的,因为我们不是 Harry Porter,不是那个"The Chosen One",甚至连被 Voldemort 选中的资格都没有。

picture 14
picture 15

picture 16

我们有的只是当下:

picture 11

最后借一部短片 “cheems,你要去码头整点薯条吗?”_哔哩哔哩_bilibili 结束本文:

xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

【音乐:The Time To Run(Finale)】