前言
最近在做一个 AI 原生相册项目Memoria / 智能影记。项目本身已经具备相册扫描、事件聚类、AI 打标、地点解析、照片图谱等能力。
在一次真机调试中,我发现一个很有价值的方向:
既然照片已经能通过 GPS 和高德逆地址解析得到城市、区县、地点名,那么这些信息不应该只用于“显示照片在哪里拍的”,还可以进一步用于理解用户的生活轨迹。
比如:
用户平时长期在城市 A 某几天突然连续出现在城市 B 之后又回到城市 A这就很可能是一段旅行、出差、返校、短途游或异地记忆。
于是这次给项目新增了一个规则版的旅行记忆检测 TravelMemoryDetector。它不依赖大模型,不修改数据库结构,也不会阻塞 UI,而是利用已有的照片时间、事件聚类和逆地址解析结果,检测出“短时间异地停留”的记忆片段。
一、为什么要做旅行记忆检测?
传统相册通常只能做到:
这张照片拍摄于某城市 这组照片拍摄于某地点但智能相册更应该进一步理解:
你平时主要在城市 A 但 1 月 18 日 - 1 月 19 日去了城市 B 这段时间拍了 6 张照片 主要地点包括某车站、某商圈这就是从“地点标签”升级到“生活轨迹理解”。
这类能力可以用于很多产品场景:
1. 首页提示:发现一次可能的旅行记忆 2. 相册筛选:查看旅行照片 3. 故事生成:自动生成旅行回忆 4. 照片图谱:高亮异地城市照片簇 5. 年度总结:生成城市足迹时间线相比单纯展示地点,旅行检测更有“智能感”。
二、已有数据基础
项目里已经有逆地址解析链路。通过高德地图逆地址 API,可以从照片或事件中心点得到:
province city district locationName formattedAddress adcode lat/lon eventId其中:
PhotoEntity: province city district locationName formattedAddress adcode latitude longitude eventId EventEntity: province city district locationName formattedAddress avgLatitude avgLongitude startTime endTime photoCount photoIds这次没有新增字段,也没有重新生成 Isar schema。citycode虽然可以从高德结果里拿到,但当前实体里没有持久化字段,所以本次检测没有依赖它。
这样做的好处是风险低:
先把能力做成服务接口,验证规则有效后,再考虑是否扩展数据库结构。
三、这次实现的目标
本次实现目标如下:
1. 不修改数据库结构。 2. 不等待所有地址解析完成。 3. 当前有多少已解析的照片/事件,就基于多少做检测。 4. 优先按事件聚合,减少逐照片误判。 5. 自动识别常驻城市 baseCity。 6. 检测 1 - 14 天的外地连续片段。 7. 根据照片数、事件数、前后是否回到常驻城市计算置信度。 8. 数据库读取保持 async。 9. 规则计算放入 Isolate.run(),避免阻塞 UI。 10. 增加开发者调试入口,方便真机验证。 11. 脱敏逆地址日志,不再打印完整高德 JSON、经纬度和详细地址。四、整体架构
这次新增了两个核心类:
TravelMemoryService 负责从 Isar 异步读取事件和照片数据 转换成轻量快照 调用 isolate 进行规则计算 对外提供 detectRecentTravelMemories() 和 buildDebugSummary() TravelMemoryDetector 纯 Dart 规则检测核心 不依赖 Flutter UI 不依赖 Isar 实体 负责构建每日地点观察值、识别常驻城市、检测旅行片段整体流程如下:
Isar 数据库 ↓ 读取最近窗口内 EventEntity / PhotoEntity ↓ 转换为 TravelEventSnapshot / TravelPhotoSnapshot ↓ Isolate.run() ↓ TravelMemoryDetector.detectFromSnapshots() ↓ 输出 TravelMemoryCandidate这样 UI 层不会死等地址解析,也不会被规则计算卡住。
五、为什么要用轻量 Snapshot?
Dart isolate 之间传数据有要求,不能随便把复杂对象传进去。
Isar Entity、BuildContext、Widget、Image 这些对象都不适合直接传入 isolate。
因此这里引入轻量快照对象:
class TravelEventSnapshot { const TravelEventSnapshot({ required this.id, required this.startTime, required this.endTime, required this.photoCount, this.province, this.city, this.district, this.locationName, }); final int id; final int startTime; final int endTime; final int photoCount; final String? province; final String? city; final String? district; final String? locationName; }照片也类似:
class TravelPhotoSnapshot { const TravelPhotoSnapshot({ required this.id, required this.timestamp, this.eventId, this.province, this.city, this.district, this.locationName, this.adcode, }); final int id; final int timestamp; final int? eventId; final String? province; final String? city; final String? district; final String? locationName; final String? adcode; }这样 isolate 里只处理简单数据,避免数据库对象跨线程问题。
六、不再全量扫描:只读取最近时间窗口
第一版最直接的写法是:
final events = await isar .collection<EventEntity>() .where() .sortByStartTime() .findAll(); final photos = await isar .collection<PhotoEntity>() .where() .sortByTimestamp() .findAll();这在照片数量较少时没问题,但如果用户有几千甚至几万张照片,就会带来 I/O 和内存压力。
所以后续优化为:
1. 先找最新 event/photo 时间; 2. 根据 lookbackDays 计算窗口开始时间; 3. 只读取最近 90 天或 180 天数据; 4. 默认检测最近 90 天,调试入口使用 180 天。伪代码如下:
Future<List<TravelMemoryCandidate>> detectRecentTravelMemories({ int lookbackDays = 90, }) async { final latestTimestamp = await _findLatestTimestamp(); if (latestTimestamp == null) { return const []; } final windowStart = _calculateWindowStart( latestTimestamp, lookbackDays, ); final events = await _loadEventsAfter(windowStart); final photos = await _loadPhotosAfter(windowStart); final eventSnapshots = events .map(TravelEventSnapshot.fromEntity) .toList(growable: false); final photoSnapshots = photos .map(TravelPhotoSnapshot.fromEntity) .toList(growable: false); return Isolate.run( () => TravelMemoryDetector.detectFromSnapshots( events: eventSnapshots, photos: photoSnapshots, lookbackDays: lookbackDays, ), ); }这一步非常关键。
异步只能避免阻塞等待,但如果你异步读了全量数据,依然可能造成性能压力。
限制时间窗口后,这个服务才更适合长期运行。
七、按事件优先聚合,减少误判
旅行检测不应该直接逐照片判断。
因为单张照片可能存在:
1. GPS 漂移; 2. 地点解析不准; 3. 用户转发或保存了异地照片; 4. 一天内跨多个区县; 5. 少量孤立照片不代表旅行。所以这次采用“事件优先”的策略。
事件本身已经是项目里的时空聚类结果。
一个 EventEntity 通常表示某段时间内同一批相关照片,因此比单张照片更稳定。
核心思路:
1. 先把有 eventId 的照片归到事件下; 2. 事件优先使用自身 city/district/locationName; 3. 如果事件缺少 city,则从事件内照片取最高频城市; 4. 没有事件归属的照片再按天聚合; 5. 最后统一转换为 TravelDayObservation。每日观察值结构类似:
class TravelDayObservation { const TravelDayObservation({ required this.day, required this.city, required this.photoCount, required this.eventIds, required this.locationNames, this.district, this.adcode, }); final TravelDay day; final String city; final String? district; final String? adcode; final int photoCount; final Set<int> eventIds; final List<String> locationNames; }八、识别常驻城市 baseCity
要判断“旅行”,首先要知道“平时在哪里”。
因此需要识别常驻城市:
baseCity = 最近窗口内出现天数最多的城市这里没有用照片数最多作为唯一指标,因为照片数可能被一次旅行或一次活动放大。
按“天数”统计更接近生活常驻状态。
伪代码:
String? detectBaseCity(List<TravelDayObservation> observations) { final cityDayCounts = <String, int>{}; for (final observation in observations) { cityDayCounts[observation.city] = (cityDayCounts[observation.city] ?? 0) + 1; } final ranked = cityDayCounts.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); if (ranked.isEmpty) { return null; } return ranked.first.key; }例如:
城市 A:45 天 城市 B:2 天 城市 C:1 天那么城市 A 就是常驻城市。
城市 B 和城市 C 的短期连续片段就可能是旅行候选。
九、检测外地连续片段
有了 baseCity 后,检测逻辑就很清晰:
1. 按日期排序; 2. 取每天的主城市; 3. 如果当天城市 != baseCity,则加入 travelDays; 4. 把城市相同且日期连续的外地天合并为 segment; 5. segment 长度在 1 - 14 天之间才算候选; 6. photoCount >= 3 或 eventCount >= 1 才保留。核心规则:
class TravelMemoryDetector { static const int minTripDays = 1; static const int maxTripDays = 14; static const int minPhotosForTrip = 3; }为什么最大 14 天?
因为这次目标是识别“旅行记忆 / 短期异地停留”,不是搬家、实习、长期驻留。
超过 14 天的外地连续片段暂时不作为旅行处理,避免误判。
十、置信度评分
为了区分“高置信旅行”和“可能旅行”,检测器会给每个候选片段打分。
评分考虑因素包括:
1. 是否不同于 baseCity; 2. 连续天数是否合理; 3. 照片数量是否足够; 4. 是否有事件聚类支撑; 5. 旅行前是否出现过 baseCity; 6. 旅行后是否回到 baseCity; 7. 是否有明确地点名。示例:
double scoreTravelSegment(...) { var score = 0.0; if (city != baseCity) { score += 0.35; } if (dayCount >= 1 && dayCount <= 7) { score += 0.20; } if (photoCount >= 5) { score += 0.15; } if (hasBaseCityBefore) { score += 0.15; } if (hasBaseCityAfter) { score += 0.15; } return score.clamp(0.0, 1.0); }最终输出结构:
class TravelMemoryCandidate { const TravelMemoryCandidate({ required this.city, required this.startDay, required this.endDay, required this.score, required this.photoCount, required this.eventIds, required this.mainLocationNames, }); final String city; final TravelDay startDay; final TravelDay endDay; final double score; final int photoCount; final Set<int> eventIds; final List<String> mainLocationNames; }十一、调试入口:开发者设置里触发检测
这次还增加了一个轻量开发者入口:
我的 -> 开发者设置 -> 旅行记忆检测点击后调用:
TravelMemoryService().buildDebugSummary(lookbackDays: 180)然后用 Dialog 展示前 5 个候选片段。
真机结果类似:
TravelMemoryDetector: 2 candidate(s) - 某城市A 2026-01-18..2026-01-19 score=0.76 photos=6 events=1 locations=某区县/某车站 - 某城市B 2026-01-26..2026-01-27 score=0.64 photos=6 events=1 locations=某区县/某小区/某地点这说明检测器已经能从真实相册中识别出短期异地停留片段。
需要注意:
这个入口目前仍然是 debug 性质,正式 UI 应该把文案产品化,例如:
发现 2 段可能的旅行记忆 1. 1月18日 - 1月19日 · 某城市A 置信度:较高 照片:6 张 主要地点:某车站 2. 1月26日 - 1月27日 · 某城市B 置信度:可能 照片:6 张 主要地点:某小区 / 某地点十二、日志脱敏:不再打印完整高德返回值
这次另一个重要改动是日志脱敏。
原来逆地址解析时会打印完整高德返回 JSON,里面包含:
经纬度 完整详细地址 道路 POI AOI 区县 城市 adcode 周边地点这在调试阶段很方便,但风险很高。
如果日志被上传到 CSDN、GitHub issue、交流群或截图里,会暴露用户轨迹。
因此这次把日志改成只打印粗粒度状态:
print( "高德逆地址响应: status=${body['status'] ?? '-'} " "hasRegeocode=${body['regeocode'] is Map<String, dynamic>} " "extensions=$extensions", );事件地址解析成功时,只保留:
print( "事件地址解析成功: id=${event.id} city=${city ?? '-'} " "district=${district ?? '-'} adcode=${adcode ?? '-'} " "citycode=${citycode ?? '-'}", );照片地址解析成功时,只保留:
print( "照片地址解析成功: id=${photo.id} city=${city ?? '-'} " "district=${district ?? '-'} adcode=${adcode ?? '-'}", );不再打印:
1. 完整高德 JSON; 2. 原始经纬度; 3. 完整 formattedAddress; 4. POI 详细名称; 5. 道路和门牌号。对于智能相册项目来说,日志脱敏不是可选项,而是必须项。
十三、测试覆盖
本次新增了travel_memory_detector_test.dart,覆盖了三个核心场景。
1. 能识别短途外地旅行
常驻城市 A 短期连续出现在城市 B 照片数足够 前后有城市 A => 应识别为旅行候选2. 超过 14 天不算旅行
连续 20 天都在城市 B => 更像长期驻留,不作为旅行候选3. 没有事件也可以用照片识别
部分照片没有 eventId 但同一天/连续几天照片数量足够 且城市不同于 baseCity => 仍然可以作为候选验证命令:
flutter test test\service\travel_memory_detector_test.dart结果通过。
同时项目总测试集合也通过:
powershell -ExecutionPolicy Bypass -File tool\run_test_suite.ps1稳定测试数量从之前的 39 个增加到 42 个,全部通过。
十四、真机验证结果
本次在 Android 真机上进行了调试。
App 成功构建、安装、启动,并通过开发者入口触发旅行检测。
真机 Dialog 返回了 2 个旅行候选片段:
TravelMemoryDetector: 2 candidate(s) - 某城市A 2026-01-18..2026-01-19 score=0.76 photos=6 events=1 - 某城市B 2026-01-26..2026-01-27 score=0.64 photos=6 events=1这说明:
1. 数据库读取正常; 2. 时间窗口过滤正常; 3. isolate 规则计算正常; 4. 真实相册数据可以产生候选结果; 5. UI 没有因为旅行检测而卡死; 6. 开发者入口可用于后续调试。调试日志里仍然有 vivo 系统的 SELinux 噪声:
avc: denied { ioctl } for path="/proc/fas/render"这类日志更像厂商 ROM 权限噪声,不是本次旅行检测逻辑导致的错误。
只要没有 Flutter 红屏、Dart exception、进程退出,就可以暂时忽略。
十五、这次实现的核心经验
1. 逆地址解析不只是为了显示地点
很多相册项目拿到地址后,只做了:
照片地点:某城市某区但更有价值的是进一步推理:
用户平时在哪里 什么时候去了外地 去了几天 拍了多少照片 前后是否回到常驻城市这才是智能相册应该做的事。
2. 先做规则,不急着上大模型
旅行检测这个问题,用规则就能拿到不错的 V1 效果。
不需要一开始就让 LLM 参与。
规则版的优势是:
1. 可解释; 2. 成本低; 3. 不需要联网; 4. 不依赖模型输出稳定性; 5. 方便写单元测试; 6. 易于逐步调参。后续可以让 LLM 做文案生成,而不是让 LLM 做底层检测。
3. 不要把重计算放在 UI isolate
Flutter 中async/await并不等于多线程。
如果只是异步等待 I/O,确实不会阻塞 UI;但如果在主 isolate 里做大量排序、聚合、规则计算,仍然可能造成卡顿。
这次采用:
数据库读取:async 数据转换:轻量 snapshot 规则计算:Isolate.run() UI 展示:Dialog这是比较稳的结构。
4. 日志脱敏必须尽早做
调试地址解析时,完整 JSON 很诱人,因为信息非常全。
但这类日志包含用户隐私轨迹,必须尽早脱敏。
尤其是要避免打印:
经纬度 完整地址 门牌号 小区名 学校名 车站名 原始 API 响应公开博客、截图、issue 中更应该使用“某城市 / 某地点”替代。
十六、后续优化方向
当前只是 V1 规则检测,后续还可以继续升级。
1. 过滤行政区作为 locationName
现在候选里有时会出现:
locations=某区县/某车站区县更像行政区域,不是具体地点。
后续可以过滤掉:
province city district adcode优先展示 POI、AOI、建筑物、小区、车站、景点等更具体的地点。
2. 正式 UI 产品化
现在是开发者 Dialog,后续可以做成正式卡片:
发现一次可能的旅行记忆 1月18日 - 1月19日 · 某城市 共 6 张照片 主要地点:某车站 [查看照片]3. 接入首页和故事生成
旅行候选可以进入故事生成模块:
标题:某城市两日记忆 时间:1月18日 - 1月19日 照片:6 张 地点:某车站、某商圈然后生成:
旅行回忆标题 短视频脚本 朋友圈文案 相册章节摘要4. 与照片图谱结合
在照片图谱中新增“旅行模式”:
常驻城市照片:弱显示 旅行城市照片:高亮 同一次旅行:形成照片簇 城市切换:用弧线连接这样就能把地点检测、旅行识别和视觉图谱结合起来。
5. 加入更多信号
后续可以加入:
1. 距离常驻城市的地理距离; 2. 是否跨省; 3. 是否有车站、机场、酒店、景点 POI; 4. 拍摄时间密度; 5. 是否有连续多天夜间照片; 6. 是否和节假日重合; 7. 是否出现“旅行、酒店、车票”等 OCR 关键词。这些可以让旅行检测更准确。
结语
这次实现的「旅行记忆检测」并不是一个复杂模型,而是一个非常实用的规则系统。
它利用已有的照片时间、事件聚类和逆地址解析结果,完成了一个从“地点展示”到“轨迹理解”的升级:
不是只知道“照片拍在某城市” 而是知道“你平时在城市 A,但这几天去了城市 B”本次实现中,我尽量保持了几个原则:
1. 不改数据库 schema; 2. 不阻塞 UI; 3. 不等待所有地址解析完成; 4. 优先使用事件聚合; 5. 规则计算放入 isolate; 6. 日志脱敏; 7. 增加单元测试; 8. 先做开发者入口验证真实数据。最终真机上成功识别出 2 段候选旅行记忆,说明这个方向是成立的。
对于智能相册来说,这类能力比单纯堆 UI 更重要。
因为它让 App 开始真正理解用户的生活,而不只是存储用户的照片。