Flutter 智能相册:基于逆地址解析实现「旅行记忆检测」
前言
最近在做一个 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 开始真正理解用户的生活,而不只是存储用户的照片。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)