家里那台PS5买回来半年后,我第一个受不了的不是游戏容量,而是截图和录像的管理。官方相册只能按时间倒序看,想搜某一场Boss战、某个游戏的地标截图,只能一页页翻。更头疼的是,我习惯把素材备份到家里的NAS,导出来以后文件名全是IMG_0001这种,根本分不清来源。后来实在忍不了,就花了一个月断断续续写了个小工具,取名AnyPS5。意思很直白:不管手边是PS5、PS5 Slim还是Pro,只要在同一局域网里,都能用同一套逻辑把主机上的截图、录像、游戏记录自动整理成网页画廊。
AnyPS5不是一个模拟器,也不碰系统内部数据,更不涉及任何在线账号的敏感信息。它做的事情很简单:扫描你指定的媒体目录(比如从PS5拷出来或者NAS里挂载的文件夹),识别每个文件属于哪个游戏,去除重复截图,然后生成一个可以手机、电脑随时看的网页。如果后面你还想找素材,打开浏览器搜索一下关键词就行。
适合谁看?如果你手上有一台PS5,又喜欢截图、录像,或者家里有NAS,想搞一个游戏媒体库,这篇文章适合你;如果你是刚开始接触FastAPI或者图片识别的新手,也可以照着我的思路搭一个自己用的工具。接下来我会从需求梳理讲到技术选型,再到实际代码和踩坑记录,尽量把每个“为什么这么做”都说清楚。
1. 项目动机与整体思路
1.1 为什么不做串流,而是做素材管家
很多人一听PS5相关工具,第一反应是远程串流。串流确实是刚需,但官方客户端已经做得不错,而且我不是搞协议分析的料,更不想去碰主机系统权限那套灰色地带。我真正恼火的场景很日常:
- 打完一个章节,想回看一下自己拍的截图,主机相册里只有时间排序,没有搜索,没有标签,跨设备浏览更是想都别想;
- 素材导出到电脑后,文件名全是IMG_20250503_213456这种,和游戏名毫无关系;
- 玩家经常连拍好几张差不多的图,一次Boss战能拍出几十张高度重复的照片;
- 想按游戏建文件夹,但时间久了根本记不清某张模糊截图是哪个游戏里的。
所以我把AnyPS5的定位收窄成一个“游戏素材管家”:专注管理PS5产生的截图、短视频和游玩记录,不做串流、不做系统监控、不做在线账号同步。边界清晰之后,很多技术选型就变得简单了。
1.2 核心功能清单和产品边界
经过半个多月的使用和迭代,最终保留的功能如下:
- 自动扫描指定目录,兼容jpg、png、webp、gif、mp4、mov、webm等常见媒体格式;
- 用感知哈希对图片去重,保留清晰度更高的一张,重复项自动归档;
- 用OCR识别截图里的游戏名,结合本地游戏别名表,把素材自动归入对应游戏;
- 生成一个网页画廊,支持按游戏、时间、文件类型、是否重复筛选;
- 支持批量下载、批量打标签、批量删除,所有操作都走本地接口;
- 做一个简单的统计面板,展示素材总量、存储占用、各游戏占比。
我不做登录鉴权,因为是在家庭局域网内使用的;不做云端同步,因为NAS本来就承担了同步职责;不做PSN账号登录,因为我只需要读取本地文件,不想碰任何账号体系。这个取舍让整个项目的安全体积小很多。
1.3 技术选型背后的取舍逻辑
技术栈最终定为Python + FastAPI + SQLite + Vue 3,加ffmpeg和PaddleOCR作为辅助工具。
选Python的原因很简单:文件处理和图片识别生态太成熟了,Pillow、imagehash、watchdog、ffmpeg-python这些库都能省掉大量底层工作。FastAPI是因为它自带异步支持和OpenAPI文档,前端联调非常方便,不需要额外写接口文档。
前端用Vue 3 + Vite,纯粹是为了组件化开发舒服,画廊页面的筛选栏、卡片网格、懒加载实现起来都很顺手。数据库用SQLite,因为素材量在几万个文件以内时SQLite完全够用,而且零配置文件备份也方便。如果用PostgreSQL,反而增加了部署复杂度。
ffmpeg是用来处理视频缩略图的,PaddleOCR是用来识别截图文字的。这两个工具单独拎出来说,是因为它们在整个项目里承担的是“脏活累活”,后面实战部分我会重点讲。
2. 核心模块拆解:文件发现、去重和识别
2.1 文件发现:定时扫描加目录监听
AnyPS5面对的第一个问题是:怎么知道新素材进来了?我试过纯定时扫描,缺点是响应慢,而且全量扫描很耗CPU;我也试过纯监听,缺点是程序第一次启动时看不到历史文件。最后采用了“初始全量扫描 + watchdog监听”的双轨方案。
初始化时,服务端会对配置的媒体根目录做一次深度扫描,把已有文件的路径、大小、修改时间记录到数据库。之后交给watchdog监听目录事件,新增文件会立刻进入处理队列。为了避免重复处理,我在数据库里用“文件路径+文件大小+修改时间”组成指纹字段,同一个文件不管触发多少次事件,都只处理一次。
这里有个容易踩的坑:PS5通过U盘或局域网拷贝文件时,可能是先写一个临时文件,再重命名成正式文件。如果监听rename事件不加以判断,很容易把半截文件当成完整文件处理。我的做法是:收到事件后不立刻处理,而是等5秒,再检查文件大小是否稳定,稳定了才入队。
核心扫描代码类似这样:
from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler class MediaHandler(FileSystemEventHandler): def on_created(self, event): if event.is_directory: return self.schedule(event.src_path) def on_modified(self, event): if event.is_directory: return self.schedule(event.src_path) def schedule(self, path): # 延迟5秒,等待文件写入完成 self.pending[path] = time.time() def flush_pending(self): # 定时器里调用,比对文件大小是否稳定 for path, t in list(self.pending.items()): if time.time() - t > 5: size = os.path.getsize(path) if size == self.last_size.get(path): process_queue.add(path) self.last_size[path] = size这个延迟逻辑也适用于NAS挂载目录,毕竟网络文件系统的事件触发比本地更不稳定。
2.2 感知哈希去重:用指纹代替肉眼
去重是我第一个实现的功能,因为连拍带来的重复图片实在太多了。去重方案我对比过MD5、感知哈希和神经网络特征,最后选了感知哈希。
MD5只对一模一样的文件有效,玩家截图只要缩放、压缩过就认不出来。神经网络特征准确但重,跑在一个常驻后台服务里太浪费。感知哈希的优点是速度快、对轻微缩放和压缩不敏感,适合截图这种“内容相同但编码不同”的情况。
我用的是pHash,具体做法是:先把图片缩放成32x32,转灰度,做离散余弦变换,取左上角8x8低频区域,计算它的均值,然后逐位比较得到64位哈希值。
from PIL import Image import imagehash def dhash(image_path, hash_size=8): img = Image.open(image_path).convert("L") return imagehash.phash(img, hash_size) def hamming_distance(h1, h2): return h1 - h2 # imagehash实现了汉明距离去重判断规则是这样的:两张图的汉明距离小于等于5,就认为是重复;如果一组图里有不同分辨率,保留分辨率最高的那张,其余标记为“重复”,可一键清理。
实际测试下来,这个阈值在截图场景下表现相当不错。默认阈值5既能识别“同一场景连拍”,又不会把两张Boss战不同阶段的图误判成重复。如果素材全是UI界面、页面背景比较单调,阈值可以降到3,避免误杀。
2.3 游戏信息识别:PaddleOCR加人工校正
去重之后的素材,还需要知道它是哪个游戏的。第一版我打算靠文件名识别,但PS5导出的文件名只有时间戳,行不通。后来发现截图里通常带着游戏UI,比如左上角的任务名、游戏内菜单标题,这些文字信息非常适合用OCR提取。
我选了PaddleOCR的中文模型,识别准确率比传统OCR高不少,但直接扔原图进去效果一般。主要问题是截图里UI文字太小、有半透明背景、还有抗锯齿。我的预处理流程是:先放大两倍,转灰度,再用自适应阈值去背景,最后才交给PaddleOCR。
import cv2 from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang="ch") def extract_game_name(image_path): img = cv2.imread(image_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) img = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC) img = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2) result = ocr.ocr(img, cls=True) # 拼接所有识别到的文字,和游戏别名表做匹配 text = " ".join(line[1][0] for line in result[0]) return match_game_by_text(text)识别出来的文本未必是游戏名,比如“设置”“地图”这种通用词汇会出现。我的办法是维护一个游戏别名表,把常见游戏的中英文名、缩写、以及本地化译名都放进去。OCR文本先和别名表做模糊匹配,命中就返回游戏名,没命中的进入待人工校正队列。使用一段时间后,这个表越来越可靠,识别率从最初的58%能提升到90%以上。
2.4 视频为什么不走同一套流程
视频文件没法像图片一样直接OCR,需要先从视频里抽帧。最开始我抽第一帧,结果发现很多视频第一帧是黑屏或者加载画面,导致识别结果一团糟。
后来改成用ffmpeg在视频时间轴上均匀抽三帧,分别OCR,取识别置信度最高的结果。如果三帧都识别不到内容,就用中间帧生成一张缩略图,放到“待分类”文件夹里,不强行猜测。
ffmpeg -i input.mp4 -ss 00:00:02 -frames:v 1 frame_1.jpg ffmpeg -i input.mp4 -ss 00:01:30 -frames:v 1 frame_2.jpg ffmpeg -i input.mp4 -ss 00:03:00 -frames:v 1 frame_3.jpg抽帧和OCR都是耗时操作,我放在一个独立的处理进程里,用队列控制并发数为4。并发太高的话,CPU会被占满,主机上同时玩游戏会卡顿。
3. 实操过程:从空文件夹到网页画廊
3.1 先搭一个最小的服务端骨架
AnyPS5的后端目录结构很简单,没有搞微服务那套,毕竟就是家庭自用工具:
anyps5/ ├── app/ │ ├── main.py # FastAPI入口 │ ├── scanner.py # 目录扫描和文件监听 │ ├── hash_utils.py # 感知哈希去重 │ ├── ocr_engine.py # OCR识别 │ ├── models.py # SQLAlchemy模型 │ └── api/ │ ├── media.py # 素材列表接口 │ └── tasks.py # 批量操作接口 ├── frontend/ │ └── src/ # Vue3源码 └── data/ └── anyps5.db # SQLite数据库FastAPI入口文件只做三件事:挂载静态文件、注册子路由、启动后台扫描任务。
from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from .api import media, tasks from .scanner import start_scan_once app = FastAPI(title="AnyPS5") app.include_router(media.router, prefix="/api/media", tags=["media"]) app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"]) app.mount("/static", StaticFiles(directory="../frontend/dist"), name="static") @app.on_event("startup") async def startup_event(): start_scan_once()3.2 扫描入库流程:把文件变成记录
扫描模块是把目录下的文件信息写入数据库。表结构关键字段有:原始路径、文件名、文件类型、大小、修改时间、感知哈希、OCR原始文本、游戏ID、去重状态。
入库逻辑要注意一个细节:不要把感知哈希计算放在扫描主流程里。因为几百个文件同时计算哈希,会让接口长时间无响应。我让扫描器先把文件元数据入库,状态设置为“待处理”,后台工作线程再从数据库里捞“待处理”记录,逐个计算哈希和识别游戏名。
INSERT INTO media (path, name, type, size, mtime, status) VALUES (?, ?, ?, ?, ?, 'pending');这样前端画廊可以立刻看到文件变多,识别结果则是一点一点回填的,体验上更自然。
3.3 OCR识别与元数据回填:让文件有名字
处理线程从数据库取出一条待处理记录,先判断文件类型。图片走pHash + OCR,视频走ffmpeg抽帧再OCR。识别到的游戏名通过别名表映射成统一名称,同时把文本结果单独存一列,方便人工搜索。
为了提升速度,识别结果做了缓存。同一个文件如果OCR文本相同,就直接沿用之前的游戏归属,避免重复跑模型。缓存键是“文件大小+修改时间+OCR文本前50字”,实测能减少大约35%的重复识别请求。
回填时的判断优先级是这样的:OCR命中游戏别名表 > 目录名包含游戏关键字 > 待人工分类。因为PS5素材导出时,用户可能已经手动建了以游戏命名的文件夹,这种目录信息比OCR更可靠。
3.4 前端画廊:筛选、搜索和无限滚动
前端核心是一个单页画廊,打开就是瀑布流卡片,每张卡片显示缩略图、游戏名、拍摄时间和文件类型。顶部是筛选栏,支持选择游戏、时间范围、图片/视频/全部、只看重复项。
接口设计成支持游标分页,避免大数据量下普通页数跳跃导致重复。
GET /api/media?game_id=12&type=image&cursor=xxx&limit=50返回结果里带一个next_cursor,前端滚动到页面底部时自动请求下一页。这样即使素材量达到几万张,滚动加载也不会有明显卡顿。
卡片点击后进入预览大图模式,支持键盘左右切换。预览时显示源文件路径、分辨率、是否重复。重复项会提供“查看相似图组”,把同一个哈希组的图片全部列出来,方便手动决定留哪张。
3.5 批量下载:zip打包的后台任务
批量下载看起来简单,但直接同步打包几个GB视频很危险,很容易超时。我改成异步任务方案:前端提交一批文件ID,后端创建一条任务记录,后台用zipfile把文件流式写入临时文件,执行完成后通过轮询接口通知前端。
import zipfile def build_zip(file_paths, output_path): with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_STORED) as zf: for fp in file_paths: # 用项目内相对路径作为压缩包内文件路径 arcname = f"PS5素材/{os.path.basename(fp)}" zf.write(fp, arcname)这里使用ZIP_STORED而不是ZIP_DEFLATED,因为视频和图片本身已经是压缩格式,再压缩只会浪费CPU,速度还慢。如果是文本文件或者记录文件,才建议用DEFLATED。
4. 常见问题与排查实录
4.1 OCR识别率上不去怎么办
这是被问得最多的问题。我实验下来,OCR识别率低通常不是模型问题,而是预处理没做到位。PS5截图里的文字经常叠加在复杂背景上,比如角色身后的天空、战斗特效,直接把原图丢给OCR,结果会有大量乱码。
解决办法是先把目标区域放大两倍再灰度化,同时用自适应阈值过滤背景。另外要在别名表里放足够多的“同义词”,比如“战神的黄昏”“战神5”“God of War Ragnarok”要映射到同一个规范名称。我的映射表一开始只有60个词条,后来加到300多,识别率才稳定下来。
如果截图里的文字确实很少,比如风景照、人物特写没有任何UI元素,不要强行分类,老老实实放到“未分类”列表里,靠人工轻轻点一下鼠标就行。
4.2 视频缩略图生成太慢
视频文件比图片难处理是因为ffmpeg解码整个文件很耗时。我最初的做法是每个视频抽三帧,并行处理,结果资源消耗很大。后来发现大部分视频只需要首尾和中段各抽一帧就够了,而且用ffmpeg的thumbnail滤镜能自动选出有代表性的帧,比固定时间戳更聪明。
ffmpeg -i input.mp4 -vf "thumbnail=100,scale=320:-1" -frames:v 1 thumb.jpg这个滤镜会在100帧里选一张质量最高的,通常不会选中黑屏或者纯字幕帧,识别率比固定抽帧还要高。
另外一定要注意限制并发。ffmpeg抽帧非常吃CPU,家里主机如果正在跑游戏,并发数过高会导致画面卡顿。我的经验是把处理队列的最大并发控制在CPU核心数的一半,比如8核机器就开4个worker。
4.3 去重阈值怎么调才合适
感知哈希阈值不是越大越好。阈值设成10时,同一游戏的不同截图经常被误判成重复,大量正常素材被归档。阈值设成0时,重命名过的文件、重新压缩过的图片又没法去重。
我建议先用3到5之间的值跑一批样本,统计一下重复组数量和人工抽查结果。如果重复组里出现了明显不相干的图片,就调小阈值;如果同样场景的连拍图没被识别出来,就调大一点。对PS5截图这种来源比较固定的素材,5已经足够稳定。
还有一个细节:视频缩略图和原图之间不要做跨类型去重。一张是jpg截图、一张是mp4抽出来的缩略图,内容可能很像,但一个是源文件一个是派生文件,混在一起会让你误删源素材。
4.4 NAS/SMB挂载权限和文件名乱码
我把媒体目录放在NAS上,通过SMB挂载到Linux服务器。一开始遇到两个问题:文件权限不足导致扫描器读不了某些目录;中文文件名在数据库里变成乱码。
权限问题好解决,挂载时指定正确的uid/gid,并给目录设置755权限。乱码问题要注意SMB挂载参数,要加上iocharset=utf8,确保内核把文件名转成UTF-8。
mount -t cifs //nas/ps5_media /mnt/ps5 -o username=xxx,password=xxx,uid=1000,gid=1000,iocharset=utf8,noatime另外SQLite对并发写支持比较弱,多个worker同时写数据库容易报“database is locked”。解决方法是连接字符串里加上?mode=ro给只读接口,写操作集中在单线程队列,或者开启WAL模式:
sqlite3.connect("anyps5.db", timeout=20) # 执行一次 PRAGMA journal_mode=WAL;4.5 数据库结构升级的迁移方案
项目迭代过程中肯定会加字段。我的做法是用Alembic做迁移脚本,虽然对自用小工具来说有点重,但总比改坏了几万条数据重来要好。
每次升级前先备份SQLite文件。备份时不要直接copy文件,要先用sqlite3 .backup命令,否则可能在写入过程中得到不一致的副本。迁移脚本尽量小步走,一次只加一个字段,方便回滚。
如果只是想加一个筛选标签,不需要改表结构的话,也可以直接新建一张关联表保存“文件ID+标签名”,完全避开ALTER TABLE带来的兼容问题。
现在AnyPS5已经在我那台NAS上连续跑了两周,积压了快半年的截图和录像被整理归位,找素材的效率比之前高了一个量级。我自己最大的感受:工具不怕小,怕的是不做。最后分享一个实用设置:如果你也用SMB挂载,建议打开file_mode=0755,dir_mode=0755,不然每次处理完后的文件属性会乱,影响后续要不要重新识别。后续我还在考虑把游戏奖杯时间线和视频高光片段自动拼接加进来,不过那要重新梳理任务队列,等这个版本再跑稳一点再说。