跳到主要内容

9 篇博文 含有标签「开发笔记」

查看所有标签

· 阅读需 18 分钟

近日,我叔为我弟高考志愿填报的事情焦头烂额。(江苏高考志愿填报竟然能多达 40 个了,你敢信!)

我心想,也许可以为他们做点数据分析工作。于是……

设计

高考志愿填报的核心难点,在于学校、专业众多,普通人很难抉择,尤其是中档学生,一本线以上、211 勉强,对于他们来说,可能选得好就是天堂选不好就是地狱(也只是可能,最终还得看自身)。

他正好位于这档。

(不过在日益强大的数据分析工具面前,这个问题变得不再有挑战性,回想起我曾经填志愿时还只能傻傻地拿着一本大书慢慢比对,不得不感叹当年的我还是年轻。)

在母校曹老师的援助下,我很快地拿到了权威的去年志愿数据(来源:江苏省 2021 年普通类本科批次投档线(物理等科目类) - 招考信息),里面有物理学科本科院校志愿最低分的数据情况,长如下:

picture 2

这样,很快地,我们的思路就出来了,只要把我弟的数据录进去,再和这些学校比对,筛选出在他分数上下的院校即可,最好是 985、211、双一流。

因此我们的工作大致有以下几个:

  1. 清洗这张表格
  2. 为这张表格加上学校标签标识
  3. 将分数录入,进行筛选

以下详细展示整个流程。

投档线数据清洗

首先查看表格,是传统的 excel,还有最让编程人员烦的跨行合并的多层标题栏,那么第一步就是重构标题栏。

根据经验,表格的跨行合并,实际上真实数据都在合并的第一行(其他为空),例如取消合并后如下:

picture 4

由此可以敏锐地发现,我们只需要把标题栏向下填充,使之全部对齐到第五行,就是可以被程序读取的标题栏了。而这个操作,就是 pandas 内所谓的frontfill(向前填充)。

picture 5

接着标题栏都是中文,并且命名过长,影响编程效率,所以有必要重命名。

此外,接着看内容,“院校、专业组(再选科目要求)”这一列信息量比较大,包含了学校名、专业组和专业限制。

其中专业组是指填报汇总后是根据大专业划分,几个比较近的属于某个专业组,例如计算机、软件、电子信息就很有可能属于同一个专业组,有点类似一级学科的意思,但也不太一样,主要是按招生对象不同,比如说中外合作的就可能在同一专业组,与其他的专业组可能专业内容上倒是相近的。这个就需要那本大书去查,网上还没有太好的信息。

专业限制是江苏专用的一套规则,按照“3+1+2”的要求,学生在语数外之外先要在“物理”和“历史”中首选一门,作为文理划分的依据;接着在剩余的 4 门(历史、政治、地理、生物)中任选两门,专业限制就是针对这两门,在曹老师的指导下我们了解到它分“不限”、“A”、“A 或 B”、“A 和 B”四种可能,分别属于不同的交并补关系(一开始我以为只有“不限”或者“A 或 B”两种可能,导致程序设计缺陷,这是我的疏忽,由于时间关系,没有做好前期统计的工作)。

因此,我们首先要做的就是把这列拆分,尤其对于专业组限制这块,一开始以为只有“或”的关系,我就把它拆成了一个数组,后来发现还有“和”的关系,这么做就不妥了,因此需要保留原字符串,后续以我弟的具体学科使用函数单独处理。

由此写出我们第一段程序,数据已经变成很干净的结构:

picture 7

picture 32

考分数据结构设计

为了通用性,我们在设计考分数据结构时,设计的相对比较保守,因为里面存在一些勾稽关系,所以使用@property保证数据的一致性。此外,由于每年分数线差异,因此需要做换算,所以加入了bias乘数。

picture 8

bias上,默认是 1 这个很好理解;考虑到我们可能有多次对标不同年份的需求(江苏没有,因为江苏改革目前才是第二年),所以需要不断地调整 bias,因此最好的做法,是调整后再返回类本身,这样可以实现链式操作。

picture 9

链式操作示范:

picture 10

我们还在数据分的基础上,加入了排名,不过实际没有用上,因为我们没有投档线对应的排名数据。

以上就是整体的数据结构设计。

考分录入、年份修正、风险偏好

在录入上,直接初始化即可。

picture 11

在年份修正上,则有些讲究。它本质的问题是确定,今年的分数在往年值多少分的问题。

我原先想用一本分数线做测度,但是江苏已经取消一本线了,只有本科线(也就是二本线)。

我想了一下,反正这块肯定有误差,在非对标顶级 985 的情况下,直接用二本线比值作为乘数似乎也没什么不妥。

比如去年本科线是 417,今年是 429,比值是 0.97,也就是今年的 1 分等于去年的 0.97 分。

picture 12

在年份修正的前提下,我们还得考虑一下风险偏好,目前我们看到的都是最低志愿投档线,因此实际已经很有风险,也即是现在看到的贵校的 N 分,实际进入该专业的学生可能都是 N*k 分,k 显著大于 1。

所以正常情况下,我们得在折合分上再乘以一个系数,降低一下自身的体位,可能 0.98 比较合适,但是不排除有些人特别 risky,就想搏一搏,单车变摩托,所以我们在筛选的时候也可以适当调高点范围,这就是风险系数,我把它设置成了最高 1.02,这样能多看十分(基数是五百多)的学校。

picture 13

看的时候,以风险调整后的分为最高分,从上往下筛学校即可。

基于学科进行筛选

在学科筛选上,可以写的很复杂,也可以写的很简单。

比较复杂的是一遍遍地 for 循环,然后用 if 去判断,到底符不符合。

我后来想了一种比较简单的,就是用集合的思想去设计。

如果目标专业组要求是“A 和 B”的关系,那么在集合上,我们考生的学科集合(也就是{C, D})应该等于{A, B}

如果目标专业组要求是“A 或 B”的关系,那么在集合上,我们考生的学科集合(也就是{C, D})应该与{A, B}有交集,也就是 ${C, D} \cap {A, B} \ne \varnothing$。

如果目标专业只有一个,也就是必须要选修它,则是子集关系。

如果目标专业为“不限”,则是恒真关系。

于是,我们可以写成这样:

picture 15

这样,我们就把 2200 多条筛成了 17000 多条了,减少了 20%,是很不错的。

基于 985、211 筛选

我们首先关注的是 985、211 类大学,看看有没有什么机会。

我们要先拿到一个标签表。

简单搜索后,我们决定使用来自这份博客对照表 - 用心整理了一份国内 985/211 大学名单及其一流学科_黄饱饱_bao 的博客-CSDN 博客的数据。

picture 16

这是一张排版很标准的表格,如果你用 F12 去检查的话确实是标准的table标签。

我们如何把这个数据提取下来呢?

这可是表格,而非文字,直接复制处理起来很麻烦(但有时也未必不可以)。

要用爬虫吗?又觉得杀鸡焉用牛刀。

这时候,我突然想起来很多软件都自带表格数据提取工具,比如 excel 就有网站源数据刷新功能。

重点是,pandas 也不例外!

一行代码,轻松搞定,只要喂一下网址即可!

picture 17

看到这,你是否会很想知道它的内部实现?没错,我也想,有时间再研究研究,估计是requests + beautifulsoup工作流?

简单清洗后,再与原先的那张表做左联操作:

picture 19

左联操作,就是以左表(投档线表)为基础,加上额外的右表信息(不存在的就是标记缺失)。这个操作,也是数据分析中最常用的。

我们还额外地简单封装了这个染色操作,将不同风险偏好的分数段做染色处理,逻辑如下:

picture 20

这样风险偏好保守的就看绿色部分就可以,高的可以看看红色(或者作为参考)。

不过很遗憾的是,我弟这个分数 985、211 只能去到很偏远的地方,或者都是中外合作办学项目。

此路似乎不通。

也许我们可以试一试双一流。

基于双一流筛选

我们同样需要双一流的标签数据,之前那个 985/211 的表里虽然也有双一流信息,但是不全,因为有些双非也是有双一流的。

我们在“第一轮双一流”建设高校及建设学科名单_中国教育在线这里找到了我们要的数据。

于是,使用 pandas 梅开二度:

picture 21

这样就有 140 所学校了,可选性大了些(比 92 多了 20 所左右)。

不过,既然是基于双一流专业,我们肯定更以专业为主(而非学校),所以我们需要额外加一点基于专业的筛选操作。

比如,我们预设几个计算机相关的专业,然后在双一流学科名单中筛选,看看有没有相关的关键字,再结合分数,结果……

picture 22

Emmm.....

我陷入了良久的沉思……

既然专业好也可以,那我们为何不试试专业排名(而非紧盯双一流呢?)

这么一想,思路就开阔了,那就是软科排名!

基于软科排名筛选

软科的排名可以在官网找到:权威发布|2022 软科中国大学专业排名|中国大学本科最好专业|就业率最高的专业

picture 23

我们可以在这里搜索不同的学科排名。

我们通过 F12 分析后端接口,发现有结构良好的 json 数据:

picture 24

因此,可以考虑直接用爬虫的手段,毕竟每个搜索结果都要翻页十二十次,这显然是必须走自动化了。

经过测试,如果不加 cookie,则获取时只会返回前 20 条,加了 cookie 就可以返回完整结果。

cookie 也就是这个:

picture 25

我们把它复制出来,封装一下,就可以设计出得到任意学科的排名数据的接口了:

picture 26

此外,我们注意到这个接口需要传入学科的编码,因此我们还要设计一个接口获得完整的编码列表。

经过分析,发现任何一个排名接口里已经包含了这份数据(的拷贝),因此我们可以首先调用任意一个专业的排名接口,然后解析出编码列表,代码如下:

picture 28

但这只是我们编码好的字典,还不具备输入一个学科名就返回一个学科编号的功能,因此还要封装一个检索函数:

picture 29

手动修正所有目标专业的准确名称后,我们就可以正式进行分析工作了。

picture 30

此处唯一多需要注意的是,软科 api 里的ranking是字符串格式的整数,然后到我们 pandas 中也默认是字符串。

如果我们需要对ranking进行排序,就要先转换成整数;但如果我们在联表的的时候没有把非排名院校给筛掉(每份排名名单只有小几百所学校),就会导致数据缺失(变成pd.NaN),从而无法转换成整数,这是需要注意的。

解决办法就是要么直接把那些学校筛掉(比如本例);要么就是使用pd.numeric函数,里面有两个参数errorsdowncast可以控制异常如何处理。

技术部分就是这样!

复盘

其实本期最大的收获就是:

  • 对于有 211 分的,直接对照 985、211、双一流名单去选就好了;

  • 对于有一本分的,建议对照软科排名去选

  • 对于其他的……

我今天终于了解到了二本怎么选的了:

picture 31

哈哈哈,不得不说这是一个很好的思路!

以上!

(全部ipynb程序文件可以后台回复“使用 pandas 研究高考志愿填报”。)

最后感谢我叔提供的素材,以及再次感谢曹老师的热心援助,希望我弟和天下学子最终都能选到心仪的学校,学有所成,不枉此生!

· 阅读需 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)】

· 阅读需 32 分钟
  1. 概要
  2. 秒换装概念与分析
    1. 秒换装的难度阶梯
    2. 备战出装换装的优点
    3. 备战出装换装的缺点
    4. 换指定装的实现分析
  3. iOS 秒换装实现之语音控制(Voice Control)实现自定义手势
  4. iOS 秒换装实现之越狱、第三方应用
  5. 秒换装实现之基于外设硬件
  6. iOS 秒换装实现之基于 iOS 自动化:facebook-wda(WebDriverAgent)
  7. UI 自动化的缺点(行走中的触控集成问题)
    1. 多点触控集成
    2. 封号的风险
    3. 需要 USB 接入

概要

本篇主要讲解在 iOS 平台上如何实现王者荣耀一键换装。安卓由于有adb的存在,自动化远比 iOS 简单的多得多,因此不在本文探讨范畴内,感兴趣地可以参阅以前发的笔记:

【文章:安卓系统】

(其实主要是自己目前用 iOS 比较多,所以懒得再写 Android 部分)

本文核心参考:

秒换装概念与分析

众所周知,王者荣耀的高端局经常涉及到两个骚操作:秒换装、三指操作。本篇主要将秒换装的操作,三指的话随缘说吧。

秒换装的难度阶梯

不同的换装顺序难度是不同的。

最简单的是换复活甲,因为复活甲的缓冲时间是 2 秒,并且大多数情况下复活甲是用在对方一波技能打完之后:

picture 1

其次是换金身,缓冲时间是 1.5 秒。但直到现在,我金身换其他(复活甲、名刀)等都换不来,原因是金身是主动释放,一般都是躲技能的紧急时刻,手忙脚乱的。

picture 2

接着是名刀,缓冲时间只有 1 秒,远程英雄(例如我的诸葛)直接减半。0.5 秒是真地只能换个锤子了。不过名刀其实比金身要好一些,因为触发名刀的场景大多数情况下是在跑路的时候,这个时候不需要什么其他操作,边走边换就可以。

picture 3

而换装的核心操作,就是首先先在商店预选好下一件装备(通常走在路上的时候),或者基于预先设置好的出装顺序(缺点等会说)。然后就可以通过四次点击实现换装,在不引起混淆的情况下,我们称这种换装为“一键换装”:

  1. 点击商店
  2. 点击卖出
  3. 点击关闭
  4. 点击购买(第一个推荐装备,如果钱够的话)

换的核心是卖掉一个再买进一个,如果格子还没满,钱又多,就不存在换的问题,直接就到第四步,这个没啥操作。但如果格子满了,或者钱不够,就需要极快地时间内精准地按下四个键,这个要求是很高的。反正我也曾经尝试在训练营中练过会,表示紧张时刻并不能跟的上。

因此需要自动化!毕竟,为这样一款游戏苦练换装的手速是不值得的。

备战出装换装的优点

备战出装换装的优点也是很显著的,我们可以很简单地备战最后一套出装顺序是复活甲、金身、名刀、血手、炽热支配者。

这样,假设我们有一键换装脚本,即可轻轻松松地按顺序秒换复活甲、金身、名刀、血手、炽热支配者,十秒换五装。

picture 5

picture 4

picture 6

备战出装换装的缺点

首先是不灵活,局越高端,就越需要针对对方阵容出装,此时的备战出装如果只有一套的话,就很不灵活了。

我很久以前喜欢玩中单诸葛的时候,就预设了三套出装:暴力(回响+帽子+大书)、防近身(帽子换面具、大书换法穿棒)、防回血(回响换梦魇)。现在比较喜欢在局中随机应变了,但这就引发了新的问题:顺序问题,即如果局中先买了其他装备,则有可能会影响预期顺序。

例如假设原先六神装最后两件一次是法穿杖、复活甲,由于逆风局中途先出了个魔女抗压,然后快六神的时候,按照顺序第六件变成了法穿杖,第七件才是复活甲,所以按照默认顺序的话,危机关头一键换装就没有换到想要的复活甲。

所以”手动“预选下一件换装装备的必要性还是有的。

换指定装的实现分析

有些场景下,我们需要换指定装,尤其是复活甲。

比如无论你在玩什么英雄,并且玩的很顺风,突然不小心有个失误被集火了,如果能瞬间换个复活甲,等待队友的输出,可能就对局势有至关重要的影响;或者在最后守水晶或者冲水晶时需要自己用复活甲换对面的输出。

换指定装的核心难题,最主要的难题是要卖掉哪件装备。

大多数情况下,最合适的选择,是卖掉鞋子,因为太便宜了,后期的附属属性就不高;其次是最贵的装备,否则可能买不起指定装。

由于格子是顺序装装备的,因此实际上,我们不太清楚要卖的装备一定在哪个格子里。所以市面上有比如说预先设置好卖掉第六个格子然后买新装备的这种,我认为必要性不高。个人认为,换指定装最佳的方式,还是在和平时提前选好要卖的装备与要买的装备,然后在战时直接使用一键换装。

iOS 秒换装实现之语音控制(Voice Control)实现自定义手势

在 iOS 平台上,目前最适合小白用户使用的官方办法,是基于语音控制。

所谓的语音控制,倒不是各种 AI 或者 siri 之类的智能语音控制,而是 iOS 自带的辅助功能里的语音控制,用户可以自己自定义一条语音指令,并未其搭配上手机要执行的输入或者点击动作。

于是我可以指定一条比如“乌拉”,然后设置其的动作为在屏幕上依次点击四个点(一键换装)的位置。保存之后,就可以通过大喊一声“乌拉”去触发它。

听起来很酷,但这种办法有几个缺点。

第一个缺点,是在配置这个点击动作时,它展示的是一个纯白的界面,实际上你不知道自己点的位置对应游戏里的哪个点。

这几乎只能从硬件上解决:比如我撕了几块小胶带,对照着截图提前贴在屏幕上的对应位置上,有些 B 站视频是用了记号笔,我没有这种可以画在贴膜上的记号笔。

第二个缺点,是一键换装的点击位置比较靠近边缘(购买键、关闭键),而 iOS 的手势定制时则必须先点击下方的“隐藏控制”,然后才能完整录入四个点的位置,并等待十秒后才能退出(虽然不影响后续操作,但每次都要运行 10 秒的脚本确实不够理想,从完美主义角度来说)。而最关键的就是即使屏幕上有四个标识好的坐标,手指去按时,也很难按照顺序按到最快,毕竟人手始终有反应速度的问题。因此理论上可能至少要 0.5 秒,实际在 1 秒左右。

picture 1

如果前两个缺点还只是可以克服的小问题,那么第三个缺点就无法忽视了,那就是启动延时。由于 iOS 的语音控制,需要经过一套算法去识别你有没有喊“乌拉”这个词,因此需要时间,而这个时间,在恒定的一秒左右。所以通过这个办法,至少我测试下来,当我从喊乌拉时到换装完成,实际耗时要在 2.5 秒左右。这个时间,说实话,很不可以接受。

比如我玩诸葛时有套“富贵险中求”的连招就是“2 冲刺+1+3+金身+2 追击或者撤退”,一套操作都是几乎瞬间完成的,此时如果我们在用完金身后再语音控制进行换装,那基本上是不现实了。

考虑到这种情况,我后来想了个办法,就是提前喊口令。因为在喊完口令后,要大概一秒才会触发换装,所以我就在准备冲锋时先喊“乌拉”,再按“2+1+3+金身”,此时等待换装完成,然后再后续操作。

这听起来有点奇怪,非常奇怪,有种蒙太奇的感觉,但它确实是某些场景的有效的解决方案。

哦对了,最后,说一下语音控制中英文的问题,由于中文指令至少要四个字起步,因此建议配置英文指令,可以很简单的比如说用“f**k”完成,乌拉不属于英文,但你可以自己配置“wula”的词汇。再者,中文的识别难度肯定比英文大,苹果毕竟是美国的公司,用英文都已经 1 秒延迟了,别说中文了。

iOS 秒换装实现之越狱、第三方应用

刚刚讲了用苹果官方的语音控制来实现自定义手势以完成一键换装的办法,缺点最主要的是执行时的延迟。

那有没有其他的软件可以帮助我们更快的反应呢?

有。但如果我们不越狱的话,几乎没有可用的,比如按键精灵之类。

反正病毒风险很高,也基本都要收费。

越狱是不需要收费的,但越狱代价是很高的,普通小白肯定没必要考虑,不然手机就不保修了。

另一种办法是通过外设实现。

秒换装实现之基于外设硬件

就我所了解到的,国内至少有飞智和北通两家公司在做基于王者荣耀、吃鸡等游戏的外设生态开发,用户只需要购买他们的手柄接上,就能在软件里自定义每个按键的宏动作,从而实现一键换装、英雄连招等操作了。

我实际体验过北通 H2 和飞智黄蜂 2 两款外设,首先值得肯定的是他们的外设确实是有效的,完全没有上述语音控制的延时问题了。

但接着个手柄(单手柄或者双手柄)玩王者之类的游戏,还是有点奇怪,因为实际没有完全脱离屏幕,比如比较畅销的黄蜂 2 左手手柄,确实右手的放技能还得在屏幕上比较方便,毕竟每个技能都要摇动,不是一般手柄能够实现的。一般的游戏手柄只有两个摇杆。

但也有不少问题。北通 H2 的硬件连接、手感、颜值上要逊色于飞智黄蜂 2(当然北通价格也更便宜点),此处暂时不展开对北通 H2 的评价了,是我去年体验的,不太记得了。飞智黄蜂 2 最近体验了一下,说一下它的优缺点。

首先是优点吧,颜值、做工(据说是日本摇杆)、连接、软件上整体做的都非常不错,体验很好。

缺点的话,苹果的横屏游戏默认是顺时针旋转 90 度,也就是屏幕顶部是左边。而苹果 13 的摄像头是凸起的,此时如果接黄蜂飞智 2 的左手手柄,会发现不够稳定(如果是接右边则非常贴合),当然由于上下还是能够用力压住,所以还是能用,只是摄像头那部分被压着,感觉不太好。

这里完全不建议用户把手机开启自动旋转后,逆时针转 90 度后搭配飞智黄蜂 2 玩,否则会发现按键位置完全反了,没法用。飞智官方是推荐大家关闭旋转使用的。但我实际体验下来,通过先逆时针旋转 90 度然后锁定方向后,再去飞智里重新配置(进去再保存再出来,不用更改),其实也能用,只是偶尔会转来转去,这点上是飞智自己的软件没优化到位,或者不愿意担风险,因为硬件操控屏幕的原理是物理坐标点固定的,旋转屏幕后坐标点没变,但游戏中却变了。飞智毕竟是通过软件提前固定好坐标点,所以如果不关闭自动旋转,用户在游戏中旋转手机后则必然出现问题。

不过对于我来说,还有几个小问题。

首先是性能过剩,飞智黄蜂 2 配备了摇杆、A 键、B 键、LT 键、LB 键、扳机键,键位对我来说太多了,我其实只要一个键就可以了,这款手柄更适合玩吃鸡。更适合我的其实是飞智的另一款手柄:壹柄,它只有一个摇杆和按键。

picture 3

另一个黄蜂 2 的问题,就是摇杆的位置太偏上了,导致我的手型不是特别舒服,不像直接在屏幕上玩,虎口的角度大概在 45 度,用飞智直接变成 5 度了。

综上这些考虑,我还是把黄蜂 2 退掉了,毕竟那个价格给我的体验达不到期望,性能也绝对过剩。

壹柄的话后续我也没有继续考虑,因为我在研究飞智的软件时,同步研究了不越狱情况下对 iOS 自动化的实现办法,而这最终成功了,那就是大名鼎鼎的 wda:WebDriverAgent。

iOS 秒换装实现之基于 iOS 自动化:facebook-wda(WebDriverAgent)

wda 是什么?从名字来看:WebDriverAgent,网页驱动代理,这个对于我(一名专业的爬虫工程师)来说太熟悉不过了,我们平常用的 selenium 自动化框架其实背后都需要一个 driver,比如 phantom、google 的 chromedriver 或者 firefox 的 geckodriver。

那 wda 的作用就是把整个手机系统的 ui 操作,集中到这个 wda 身上,通过 wda 向下调用系统的 ui 操作指令,从而完成自动化。

在 iOS 平台上,这个系统 ui 操作指令的框架,是苹果自带的UiAutomation(已废弃)和XCTest.framwork

在我配置 wda 的过程中,经历了漫长而又痛苦的调试过程,一开始我是直接用 facebook 官方的WebDriverAgent进行配置的,结果各种 bug,主要是我的 xcode、我的手机系统(iOS15)太新了。

picture 4

爬坑的过程可以参考:- WebDriverAgent 重签名爬坑记 - 温一壶清酒 - 博客园

当时就是卡在了第二步,百般搜索都得不到解决办法,结果竟然找到一个国人把四步都走完了。

picture 5

但尽管如此,基于 facebook 的这个 wda,我最终还是有一步没有搞定,那就是inspector不出截屏图片,我看了原因,还是有一句代码没过,所以这个代码确实太老了。

最终,我选择了文章里说的方法二,也就是基于 appium 里面的 wda,一下就成功了,果然代码还是得用新的。

新的 wda 里也没有inspector,而且session默认返回null了,我以为又是我的操作的问题,后来在 返回的 json 串 sessionId 为什么是 null · TesterHome确认了是新的版本设计。

picture 6

最后一切 ok 之后,使用facebook-wda的 python 客户端进行连接,果然成功了,然后就可以通过代码控制屏幕的触摸手势了,一切都非常丝滑。

不过使用WebDriverAgent也有一些问题,最大的问题是由于底层使用了 iOS 自带的 Test 框架,所以会有两行字“Automation Running, Hold...”在屏幕上随机浮动,有时会长时间不提示,有时就会一直都在,虽然是层“Overlay”半透明的,但是很烦,这个没法去除。

另外有一个问题是屏幕没动作每隔两三秒就会自动变暗,于是我在执行脚本的时候启动了两个线程,一个线程捕获键盘动作执行脚本,另一个线程每隔 2 秒钟点击一下屏幕上的(0, 0)处坐标,保持屏幕常量,实测有效。

以下是现成的执行代码脚本:

import wda
import time

# wda.DEBUG = True # default False
wda.HTTP_TIMEOUT = 5.0 # default 60.0 seconds

DURATION = 0.01

IS_RUNNING = False

# url = "http://169.254.101.229:8100"
# url = "http://192.168.1.3:8100"
url = "http://localhost:8100"
# session_id = 'B957CEB2-26D7-489E-A659-B37F24823ABD'
session_id = '00008110-000634163E9B801E'
# c = wda.Client(url=url)
# c = wda.Client()

# 如果只有一个设备也可以简写为
# If there is only one iPhone connected
c = wda.USBClient()

# Show status
print(c.status())

# Wait WDA ready
c.wait_ready(timeout=300) # 等待300s,默认120s

CHOOSE_Y = 0.8837606837606837

CHOOSE_1 = (0.34360189573459715,
CHOOSE_Y)

CHOOSE_6 = (0.6994470774091627,
CHOOSE_Y)

CHOOSE_X_INTERVAL = 0.0711690363349131 # (CHOOSE_6 - CHOOSE_1) / 5

IS_SHOPPING_OPEN = False


def open_shop():
global IS_SHOPPING_OPEN
if not IS_SHOPPING_OPEN:
print("打开商店")
IS_SHOPPING_OPEN = True
c.click(0.06872037914691943,
0.4111111111111111, DURATION)
# else:
# print("商店已打开")


def sell():
print("卖出装备")
c.click(0.8270142180094787,
0.8017094017094017, DURATION)


def buy():
print("购买装备(在商店中)")
c.click(0.8270142180094787,
0.9017094017094017, DURATION)


def close_shop():
global IS_SHOPPING_OPEN
if IS_SHOPPING_OPEN:
IS_SHOPPING_OPEN = False
print("关闭商店")
c.click(0.8609794628751974,
0.08034188034188035, DURATION)
# else:
# print("商店已关闭")


def buy_first_recommended():
print("购买第一件推荐装备")
c.click(0.13361769352290679,
0.40615384615384615, DURATION)


def select_k_bought(i):
"""
选中第i个已买的装备
:param i: 从1到6
:return:
"""
print(f"选择第{i}格已买装备")
c.click(CHOOSE_1[0] + (i - 1) * CHOOSE_X_INTERVAL, CHOOSE_Y, DURATION)


def select_move():
print("筛选【移动】(鞋子)")
c.click(0.13230647709320695,
0.6068376068376068, DURATION)


def select_attacks():
print("筛选【攻击】")
open_shop()
c.click(0.12717219589257503,
0.30256410256410254, DURATION)


def select_defense():
print("筛选【防御】")
open_shop()
c.click(0.12717219589257503,
0.55056410256410254, DURATION)


def buy_mingdao():
"""
购买名刀
:return:
"""
print("购买名刀")
open_shop()
select_attacks()

# 滑动到名刀页
c.swipe(500, 300, 500, 0, DURATION)
time.sleep(0.1)
c.click(0.45, 0.32, DURATION)

buy()


def buy_fuhuo():
"""
购买复活甲
:return:
"""
print("购买复活甲")
open_shop()
select_defense()

# 滑动到复活甲页
c.swipe(500, 300, 500, 0, DURATION)
time.sleep(0.1)
c.click(0.65, 0.18, DURATION)

buy()


def run_buy_fuhuo():
print(">>> 一键购买复活甲")
buy_fuhuo()
close_shop()


def run_buy_mingdao():
print(">>> 购买名刀")
buy_mingdao()
close_shop()


def run_one_switch_for_first_recommended():
"""
一键换预选装
:return:
"""
print(">>> 一键换装")
open_shop()
sell()
close_shop()
buy_first_recommended()


def run_one_clear():
"""

:return:
"""
print(">>> 清空装备")
open_shop()
for i in range(1, 7):
select_k_bought(i)
sell()
close_shop()


def run_one_buy_attacks():
"""

:return:
"""
print(">>> 购买推塔装")

def buy_gongsuqiaing():
"""
速击之枪
:return:
"""
c.click(0.42101105845181674,
0.8008547008547009, DURATION)
buy()

def buy_fengbaojujian():
"""
风暴巨剑
:return:
"""
c.click(0.42338072669826227,
0.1811965811965812, DURATION)
buy()

open_shop()

# 买攻速鞋
select_move()
c.click(0.42101105845181674,
0.8257264957264957, DURATION)
buy()

# 再买攻速装
select_attacks()
buy_gongsuqiaing()
buy_gongsuqiaing()
buy_fengbaojujian()
buy_fengbaojujian()

close_shop()


def process_keep_awake():
while True:
c.tap(0, 0)
time.sleep(2)


def process_monitor_input():
import keyboard

while True:
if keyboard.is_pressed('ESC'):
return

elif keyboard.is_pressed(' '):
run_one_switch_for_first_recommended()

elif keyboard.is_pressed("f"):
run_buy_fuhuo()

elif keyboard.is_pressed("m"):
run_buy_mingdao()

elif keyboard.is_pressed("q"):
run_one_clear()

elif keyboard.is_pressed("g"):
run_one_buy_attacks()


if __name__ == '__main__':
from threading import Thread

p1 = Thread(target=process_monitor_input)
p2 = Thread(target=process_keep_awake, daemon=True)
p1.start()
p2.start()
p1.join()

这些硬编码的坐标也需要通过一个脚本获取,我是基于 opencv 实现的:

"""
ref:
- [Displaying the coordinates of the points clicked on the image using Python-OpenCV - GeeksforGeeks](https://www.geeksforgeeks.org/displaying-the-coordinates-of-the-points-clicked-on-the-image-using-python-opencv/)
- [How to draw Chinese text on the image using `cv2.putText`correctly? (Python+OpenCV) - Stack Overflow](https://stackoverflow.com/questions/50854235/how-to-draw-chinese-text-on-the-image-using-cv2-puttextcorrectly-pythonopen)
"""

# importing the module
import cv2
import jstyleson
json = jstyleson

coors = []


# function to display the coordinates of
# of the points clicked on the image
def click_event(event, x, y, flags, params):
# checking for left mouse clicks
if event == cv2.EVENT_LBUTTONDOWN:
# displaying the coordinates
# on the Shell
print(x, ' ', y)

# displaying the coordinates
# on the image window
font = cv2.FONT_HERSHEY_SIMPLEX
cv2.putText(img, str(x) + ',' +
str(y), (x, y), font,
1, (0, 255, 0), 3)
coors.append((x / w, y / h))
cv2.imshow('image', img)

# checking for right mouse clicks
if event == cv2.EVENT_RBUTTONDOWN:
# displaying the coordinates
# on the Shell
print(x, ' ', y)

# displaying the coordinates
# on the image window
font = cv2.FONT_HERSHEY_SIMPLEX
b = img[y, x, 0]
g = img[y, x, 1]
r = img[y, x, 2]
cv2.putText(img, str(b) + ',' +
str(g) + ',' + str(r),
(x, y), font, 1,
(255, 255, 0), 2)
cv2.imshow('image', img)


# driver function
if __name__ == "__main__":

fn = input("脚本名:")

# reading the image
img = cv2.imread('assets/攻击.png', 1)

h, w, channels = img.shape
print(f"w: {w}, h: {h}")

# displaying the image
cv2.imshow('image', img)

# setting mouse handler for the image
# and calling the click_event() function
cv2.setMouseCallback('image', click_event)

# wait for a key to be pressed to exit
cv2.waitKey(0)

# close the window
cv2.destroyAllWindows()

json.dump(coors, open(f"{fn}.json", "w"), indent=2)

UI 自动化的缺点(行走中的触控集成问题)

最后说说 UI 自动化的缺点。

多点触控集成

首先是影响可行性的一个问题:多点触控集成。

这个问题在我去年做安卓触控开发的时候就发现了,为此我当时写了一个触控集成中心,当做系统触控事件的代理人、中间人,实测有效,但有点 bug,由于时间有限最后也没有去修复。

这个问题到底是啥呢?那就是安卓与苹果系统的触控事件单元,不是基于手指的,而是基于时间戳的。

啥意思呢?

意思就是,当我手指在屏幕上点击一个点时,系统报的是一个单指点击的动作;而如果是两根手指点击了两个点时,系统报的是一个双指点击的动作。那么问题来了,当我在游戏中使用左手控制方向盘移动时,系统报的是一个单指按压移动的动作;此时如果我用自动化控制脚本再发送一个点击动作,则不可行,因为此时应该是一个双指动作,但现在缺少那么一个中枢告诉系统,我现在是双指了,因为屏幕是屏幕,脚本是脚本,如果用脚本,那么手指在屏幕上最好别动。否则脚本在动,手指也在动,系统就会犯迷糊,这是最大的问题。

我当时做的就是首先监控屏幕上的手指,把它拦截;同时监控外设的输出,也把它拦截,然后把两个根据安卓的规则再封装,再往下传,这是可行的。但我们的 iOS 平台上的 wda,我目测下来是不行的。

所以平常我可以边移动,边用另一根手指打开商店进行换装。

但现在,使用脚本,你只能移动停止后,再启动脚本换装完成,然后再恢复移动,中间一定不要碰屏幕,否则轻则导致换装失败,重则脚本直接挂掉,因为会让 iOS 觉得这个触控事件输入有问题。

好在,这个问题其实不大,我之前用一开始的脚本录了一个视频,这里面可以看到换装的时间还比较长的。

【视频:一键换装】

后来考虑到了这个问题,不能长,所以我修改了程序里面每个按键的默认时长,都改为了 0.01 秒完成按键动作,然后换装就直接飞快,这绝对不是人手可以点出来的速度,但它就是有效的。

picture 7

封号的风险

那点这么快,会不会有封号风险?

哈哈哈,其实不会,以我多年的经验,可以肯定的说,首先我们基于的是苹果自带的自动化控制框架,因此每个点击动作都是有苹果系统官方背书的,一个处于应用层的游戏,只有老老实实接受系统输入的份。

并且,我们的换装,基本上只占游戏全程极其小的一部分,有时候甚至一局都没有一次换装,因此即使 TX 的反作弊数据分析部分再牛掰,怕也是无法找到你自动化的蛛丝马迹。尤其是如果我再在程序里加入随机抖动算法的话,尽管显然没那个必要。

需要 USB 接入

我非常希望能像安卓平台上的 adb 一样,先用有线接上,再断开用无线。

我看到facebook-wda里面提供了无线连接的方式,但很可惜我没能实现,无论是使用iproxy端口转发的操作还是啥,我都必须有线连着脚本才能正常运行,这让我打游戏时的感觉很怪。

因为其实我已经接了一个散热背夹,没错,其实比起自动化脚本,散热才是更更更重要的关注点,否则这个夏天,一局还没打完就已经手机发热然后掉帧、烫手,然后你就输了。

推荐这款,我用下来感觉还不错:

picture 8

不过由于最近已经不玩游戏了,所以退了所有的游戏外设。

要说为啥,因为找到了新的乐趣来源,那就是这今年一直要做的另一件事:读尽世间书,阅尽天下片!

picture 9

毕竟游戏玩的再好,也只是一盘一盘的重复,但人生却是无止尽的向前。我们,未来再见!

picture 15
picture 16

picture 17
picture 18
picture 14

【音乐:Saturn】

· 阅读需 4 分钟

picture 1

今日在 Apple Music 中导入逼哥的音乐库时,出现了信息乱码:

picture 2

可以看到,主要是titleauthor列出现了问题,这不禁引起了我的兴趣。

我第一反应便是去对应的原素材文件夹,右键 get info,可以看到确实是有两个属性是乱码(左边是原来的,右边是后续修复的)。

picture 3

我原以为可以直接在属性窗上修改,结果不行。

于是我就想到,可能需要借助一些专业软件。果然,涉及到 apple music 列表显示的关键信息,是 mp3 文件的 meta 数据

picture 4

按照老经验,去我们的破解专用软件站去下载:

picture 5

使用起来很简单,导入我们的文件夹,然后一个个修改 mp3 原数据的信息即可:

picture 6

但既然了解了背后的原理,一个个手动修改元数据未免太慢了。

考虑到我导入的音乐,其文件名本身是良好定义的,所以直接写个程序去批量化处理比较好。

picture 7

它们用的是eyeD3这个库:

picture 8

使用方法也非常简单,一看就明白:

picture 9

那就轻松了,我们直接写程序即可。

import eyed3
import re
import os


def edit_mp3_meta(fp, title, author):
print("fp_: ", fp)
audiofile = eyed3.load(fp)
# ref: https://stackoverflow.com/a/67541983/9422455
audiofile.tag.artist = u'%s' % author
audiofile.tag.title = u'%s' % title
# ref: https://stackoverflow.com/a/32908607/9422455
audiofile.tag.save(version=eyed3.id3.ID3_DEFAULT_VERSION, encoding='utf-8')


if __name__ == '__main__':
author = "李志"

# 处理第一个乱码文件夹,它们内部的命名是 “李志 - 歌名(专辑).mp3",用正则提取比较方便
# dir = '/Volumes/Disk2/resources/乐/lizhi-18砖/2015年《i.O》 (2014-2015李志跨年音乐会)'
# for fn in os.listdir(dir):
# fp_ = os.path.join(dir, fn)
# song_name_match = re.match(f'{author} - (.*?)\(2014i/O版\).mp3', fn)
# assert song_name_match, fn
# song_name = song_name_match.groups()[0]
# edit_mp3_meta(fp_, song_name, author)

# 处理第二个乱码文件夹,里面有两个CD文件夹,因此使用`walk`迭代,音乐命名是其本身,因此直接提取
for root, dirs, fns in os.walk('/Volumes/Disk2/resources/乐/lizhi-18砖/2013《108个关键词》'):
for fn in fns:
if fn.endswith("mp3"):
fp = os.path.join(root, fn)
song_name = fn[:-4]
edit_mp3_meta(fp, song_name, author)

处理完后,我们重新导入这些音乐,就发现,apple music 中的属性都正常了:

picture 10

希望对你有用~

查询参考: