前言

最近在做一个 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 开始真正理解用户的生活,而不只是存储用户的照片。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐