照片提取:参数解析与用户记录(NUAN5)
照片提取(extensions::infinity_nikki::photo_extract)是 SpinningMomo 面向《无限暖暖》(Infinity Nikki)照片资产的相机参数提取子系统:它把本地扫描得到的照片候选资产批量提交给 NUAN5.PRO 解析服务,解析出相机参数后按 UID 分组落库,形成"用户记录 + 参数记录",供摄影面板与数据统计消费。
Purpose and Scope
本页覆盖 src/extensions/infinity_nikki/photo_extract/ 目录下的完整提取机制,包括:
- 两个对外入口:手动任务
extract_photo_params与静默增量extract_photo_params_silent_incremental; - 共享的四阶段流水线
extract_photo_params_from_candidates(候选加载 → WorkerPool 并发准备 → 分批远端解析 → 按 UID 批量落库); - 进度上报协议(stage/percent/message 与节流策略);
- 与
infra层(候选查询、NUAN5 批量解析、upsert_photo_params_batch落库)和scan层(prepare_photo_extract_entry)的协作边界; - 结果统计语义(saved / skipped / failed / clothes_rows_written)与错误收集上限。
不在本页范围(留给兄弟页面):
- 候选照片如何被扫描/发现、
prepare_photo_extract_entry内部如何从图片字节提取载荷 —— 属于照片扫描与准备层(photo_extract/scan.*与资产扫描流程),本页仅引用其接口; - NUAN5 字典(相机参数字典
spinning-momo-camera.json)的下载与缓存 —— 见metadata_dict相关页面; - NUAN5 角色 API(
https://nuan5.pro/api/role/)与角色档案 —— 见角色档案(role_profile)页面; - 摄影面板 UI 如何展示结果 —— 见摄影面板(photography panel)相关页面;
- 数据库迁移
003_infinity_nikki_params_nuan5_columns.sql的具体 DDL —— 见数据库迁移页面(本页只说明其对参数列的影响)。
Overview
解决的问题
《无限暖暖》的照片文件内嵌了拍摄时的相机参数(镜头、焦距、姿态、服装等)。SpinningMomo 本地不实现解码,而是把这些照片数据交给第三方服务 NUAN5.PRO 解析(README 中明确致谢:"感谢 NUAN5.PRO 提供照片数据解析服务支持")。照片提取子系统负责:
- 选候选:从本地数据库挑出需要解析的照片资产(手动模式按
folder_id/only_missing过滤;静默增量模式按变更集给出的candidate_asset_ids直查)。 - 并发准备:把候选资产在
core::worker_pool中并发执行scan::prepare_photo_extract_entry,产出带uid与asset_id的PreparedPhotoExtractEntry。 - 批量解析:按固定批量(
kExtractBatchSize = 50,且一批可跨多个 UID)调用infra::extract_batch_photo_params,由其请求 NUAN5 并返回逐条记录。 - 按 UID 落库:解析成功的记录按 UID 分组,调用
infra::upsert_photo_params_batch一次性批量 upsert,并统计写入的服装行数(clothes_rows_written)。
两种运行形态
| 形态 | 入口函数 | 候选来源 | UID 来源 | 典型触发方 |
|---|---|---|---|---|
| 手动任务 | extract_photo_params | infra::load_candidate_assets(request)(folder_id + only_missing,默认 only_missing=true) | 资产自带 UID,或 uid_override 强制覆盖 | 用户在 UI 主动发起 |
| 静默增量 | extract_photo_params_silent_incremental | infra::load_candidate_assets_by_ids(request.candidate_asset_ids) | 仅资产自带 UID(uid_override 为 std::nullopt) | 后台监测到照片变更后自动补解析 |
源码注释明确了两者的边界语义:
// 手动任务保留原语义:候选由 folder_id/only_missing 决定。// 静默增量明确只解析本次变更集映射出的资产,不混入历史 missing。
执行层(extract_photo_params_from_candidates)只关心"候选集合",不关心候选如何产生 —— 这是把入口差异收敛到单一流水线的关键设计。
Architecture
设计要点:
- 入口薄、流水线厚:两个入口各自只做参数校验与候选加载(
manual_task/silent_incremental作为mode_tag透传给日志),重逻辑全部下沉到extract_photo_params_from_candidates,避免两套并行的提取路径。 - CPU 与 IO 分离:
prepare(本地字节处理)丢进 WorkerPool 并发执行;远端请求与落库留在协程里按序、按批执行。协程通过slot_ready原子数组按候选原始顺序"收割"结果,从而保证批量请求顺序稳定、进度可预测。 - 批量边界统一:网络请求批量上限与 DB 落库批量都围绕
ParsedPhotoParamsBatchItem组织,apply_batch_result再按 UID 重新分组调用upsert_photo_params_batch,使一次网络往返可覆盖多用户记录。
Core Flow:四阶段流水线详解
extract_photo_params_from_candidates 是整个子系统的核心,它的完整控制流如下(含并发与节流细节):
阶段 1:候选加载与前置校验
两个入口先做硬校验,再进入流水线:
!app_state.database→std::unexpected("Database is not initialized");- 手动模式:
only_missing = request.only_missing.value_or(true);uid_override若存在但为空串 →std::unexpected("UID override is empty"); - 候选加载失败直接把
load_candidate_assets*的错误原样返回; - 候选为空时跳过全部阶段,直接
stage="completed", percent=100.0, message="No candidate assets",返回零计数的InfinityNikkiExtractPhotoParamsResult。
阶段 2:WorkerPool 并发准备
这是最精巧的一段并发设计。实现里为每个候选维护两个共享状态:
outcomes(shared_ptr<vector<PrepareTaskOutcome>>):每个槽位写入entry或error;slot_ready(unique_ptr<atomic<bool>[]>):每槽完成标志,store(true, memory_order_release);completed_prepare(shared_ptr<atomic<size_t>>):全局已完成计数,用于把scanned_count映射到进度。
1auto* slot_ready_ptr = slot_ready.get();
2for (std::size_t index = 0; index < candidate_count; ++index) {
3 auto submitted = core::worker_pool::submit_task(
4 app_state, [outcomes, completed_prepare, slot_ready_ptr, candidate = candidates[index],
5 uid_override, index]() mutable {
6 auto prepared_result = scan::prepare_photo_extract_entry(candidate, uid_override);
7 if (prepared_result) {
8 (*outcomes)[index].entry = std::move(prepared_result.value());
9 } else {
10 (*outcomes)[index].error = prepared_result.error();
11 }
12
13 slot_ready_ptr[index].store(true, std::memory_order_release);
14 completed_prepare->fetch_add(1, std::memory_order_relaxed);
15 });
16
17 if (!submitted) {
18 (*outcomes)[index].error = "Failed to submit prepare task to worker pool";
19 slot_ready_ptr[index].store(true, std::memory_order_release);
20 completed_prepare->fetch_add(1, std::memory_order_relaxed);
21 }
22}Source: photo_extract.cpp
要点:任务提交失败不会中断整轮提取,而是把该候选记为 error 并立即置位 slot_ready,让收割循环照常推进。流水线开始前还会检查 core::worker_pool::is_running(app_state),未运行则整体失败返回 "Worker pool is not available for photo extract preparation"。
收割循环:协程按候选原始顺序 wait_for_slot_ready,用 asio::steady_timer 每 kPollIntervalMillis = 50ms 轮询,同时把 completed_prepare 的值刷进 scanned_count 并节流上报进度。为什么用轮询而不是每任务完成回调?因为这里需要"顺序语义"(第 i 个槽必须先于 i+1 被消费,批量分组才稳定),轮询 + release/acquire 标志是最简单且无锁的实现。
阶段 3:分批发送远端
攒满 kExtractBatchSize = 50 条就 send_extract_batch:
1auto send_extract_batch(
2 core::AppState& app_state, std::vector<scan::PreparedPhotoExtractEntry> batch,
3 InfinityNikkiExtractPhotoParamsResult& result, ExtractProgressState& progress,
4 const std::function<void(const InfinityNikkiExtractPhotoParamsProgress&)>& progress_callback)
5 -> asio::awaitable<void> {
6 if (batch.empty()) {
7 co_return;
8 }
9 std::unordered_set<std::string> uid_set;
10 for (const auto& entry : batch) {
11 uid_set.insert(entry.uid);
12 }
13 Logger().debug("extract_photo_params: sending batch (unique_uids={}, count={})", uid_set.size(),
14 batch.size());
15 BatchExtractOutcome batch_outcome;
16 batch_outcome.result = co_await infra::extract_batch_photo_params(app_state, batch);
17 apply_batch_result(app_state, batch, batch_outcome, result, progress, progress_callback);
18}Source: photo_extract.cpp
日志刻意打印 unique_uids 与 count 两个数:一批可同时包含多个 UID 的照片,这是"按用户记录"组织数据的前提。
阶段 4:消费结果与按 UID 落库
apply_batch_result 是失败语义的核心:
1if (!batch_outcome.result) {
2 Logger().error("apply_batch_result: extract batch failed: {}", batch_outcome.result.error());
3 fail_all(batch_outcome.result.error());
4 return;
5}
6
7std::unordered_map<std::string, std::vector<infra::ParsedPhotoParamsBatchItem>> items_by_uid;
8
9auto& records = batch_outcome.result.value();
10for (std::size_t index = 0; index < entries.size(); ++index) {
11 const auto& entry = entries[index];
12 if (!records[index].record.has_value()) {
13 auto reason = records[index].error_message.value_or("photo params unrecognized");
14 mark_candidate_failed(result, progress, entry.asset_id,
15 std::format("API returned null ({})", reason));
16 continue;
17 }
18
19 items_by_uid[entry.uid].push_back(infra::ParsedPhotoParamsBatchItem{
20 .asset_id = entry.asset_id,
21 .record = std::move(*records[index].record),
22 });
23}Source: photo_extract.cpp
分三层失败粒度:
| 层级 | 触发条件 | 结果处理 |
|---|---|---|
| 整批失败 | extract_batch_photo_params 返回 error | fail_all:批内全部候选 mark_candidate_failed,跳过落库 |
| 单条失败 | records[index].record 为空 | 该 asset_id 记 failed,原因 "API returned null (…)",其余照常 |
| 落库失败 | upsert_photo_params_batch(uid, items) 失败 | 该 UID 组内全部条目记 failed 并提前 return(放弃本批剩余 UID 组) |
落库成功后统计累加:
1std::size_t total_saved = 0;
2std::int32_t total_clothes = 0;
3for (const auto& [uid, items] : items_by_uid) {
4 if (items.empty()) {
5 continue;
6 }
7 auto save_result = infra::upsert_photo_params_batch(app_state, uid, items);
8 if (!save_result) { /* …该组全部 failed,return… */ }
9 total_saved += items.size();
10 total_clothes += save_result.value();
11}Source: photo_extract.cpp
upsert_photo_params_batch 返回 std::expected<std::int32_t, std::string>,value() 即本组写入的服装参数行数,汇入 result.clothes_rows_written。注意"用户记录"的形态:items_by_uid 的键就是记录分组键,每个 UID 一次 upsert,天然对应"用户记录 + 该用户的多条照片参数"。
数据契约(infra 层)
流水线依赖的三个核心类型定义在 photo_extract/infra.hpp:
1struct ParsedPhotoParamsRecord {
2 std::optional<std::string> camera_params;
3 // …(NUAN5 解析出的其余参数字段)
4};
5
6struct ParsedPhotoParamsBatchItem {
7 std::int64_t asset_id;
8 ParsedPhotoParamsRecord record;
9};
10
11struct ExtractBatchPhotoParamsRecord {
12 std::optional<ParsedPhotoParamsRecord> record;
13 std::optional<std::string> error_message;
14};Source: infra.hpp
infra.cpp 内部还有 NUAN5 原始 JSON 到 ParsedPhotoParamsRecord 的映射层(如 struct Nuan5Light、struct Nuan5RawTime、to_parsed_record(const Nuan5DecodedPhoto&)),把可选字段规整成统一记录后再进入落库。数据库侧,src/migrations/003_infinity_nikki_params_nuan5_columns.sql(迁移注册名 "Add nuan5 Infinity Nikki extract columns",版本 2.0.8.0)为参数列建表/加列;随后 2.0.9.0 迁移 "Rebuild Infinity Nikki user record as key-value" 将用户记录重建为键值形态 —— 这解释了为什么落库以 UID 为键、以 ParsedPhotoParamsBatchItem 列表为值。
进度上报协议
进度回调签名:const std::function<void(const InfinityNikkiExtractPhotoParamsProgress&)>&,结构包含 stage、current、total、percent(optional<double>)、message。上报由三个函数协作:
report_extract_progress:统一出口。percent未提供且total>0时自动按current/total计算,并clamp(0,100);回调为空则直接返回(允许无 UI 场景静默运行)。calculate_processing_percent:处理阶段把scanned_ratio与finalized_ratio各取一半加权,映射到[5.0, 99.0]区间(kProcessingStartPercent/kProcessingEndPercent)。为什么是两段各占 50%? 因为流水线里"准备完成"与"解析落库完成"是两个独立推进的计数(scanned_count由 WorkerPool 推进、finalized_count由结果消费推进),取平均能单调且真实地反映整体进度,不会在某一段卡住时假死。report_processing_progress:节流器。仅当floor(percent)比上次上报值严格更大,且距上次上报 ≥kMinProgressReportIntervalMillis = 200ms才上报(force=true绕过两者,用于阶段切换和终态)。
阶段字符串与百分比语义:
| stage | percent 区间 | 含义 |
|---|---|---|
preparing | 固定 2.0 | 正在加载候选(入口发出) |
processing | 5.0 → 99.0 | 并发准备 + 分批解析落库 |
completed | 固定 100.0 | 结束,message 为 Done: saved=…, skipped=…, failed=… |
处理阶段的 message 由 build_processing_message 生成:Scanned {s} / {t}, finalized {f} / {t}, saved {…}, skipped {…}, failed {…}。
结果统计语义
InfinityNikkiExtractPhotoParamsResult(定义于 extensions/infinity_nikki/types.hpp)的字段由三个计数器函数维护:
1auto mark_candidate_skipped(InfinityNikkiExtractPhotoParamsResult& result,
2 ExtractProgressState& progress, std::int64_t asset_id,
3 const std::string& reason) -> void {
4 result.skipped_count++;
5 result.processed_count++;
6 progress.finalized_count++;
7 add_error(result.errors, std::format("asset_id {} skipped: {}", asset_id, reason));
8}Source: photo_extract.cpp
| 计数器 | 递增场景 |
|---|---|
candidate_count | 候选总数,流水线开始时一次性设置 |
saved_count / processed_count / clothes_rows_written | 仅在 upsert_photo_params_batch 成功后按组累加(mark_candidates_saved) |
skipped_count | prepare 阶段失败(mark_candidate_skipped),例如 payload 提取失败或任务提交失败 |
failed_count | API 返回 null / 整批失败 / 落库失败(mark_candidate_failed) |
errors | skipped/failed 的原因文本,上限 kMaxErrorMessages = 50(add_error 超限静默丢弃) |
processed_count = saved + skipped + failed 是恒等式;最终日志以 mode={}, completed. candidates=… 汇总,mode 为 manual_task 或 silent_incremental。
常量与配置
本模块的调参全部是编译期常量,无运行时配置项(运行时开关由上层调用者/请求字段承担):
| 常量 | 值 | 作用 |
|---|---|---|
kMaxErrorMessages | 50 | result.errors 收集上限,防止海量失败撑爆结果 |
kExtractBatchSize | 50 | 每次远端批量解析的最大条数(可跨多 UID) |
kMinProgressReportIntervalMillis | 200 | 进度上报最小间隔(毫秒) |
kPollIntervalMillis | 50 | wait_for_slot_ready 轮询间隔 |
kPreparingPercent | 2.0 | preparing 阶段固定百分比 |
kProcessingStartPercent / kProcessingEndPercent | 5.0 / 99.0 | 处理阶段百分比映射区间 |
请求侧可配置项(InfinityNikkiExtractPhotoParamsRequest):folder_id、only_missing(默认 true,即默认只补解析缺失参数的照片)、uid_override(覆盖候选 UID,用于纠错归档);InfinityNikkiSilentExtractPhotoParamsRequest 仅含 candidate_asset_ids。
API Reference
extract_photo_params(app_state, request, progress_callback) -> asio::awaitable<std::expected<InfinityNikkiExtractPhotoParamsResult, std::string>>
参数:
app_state(core::AppState&):全局状态,必须已初始化database,且 WorkerPool 处于运行态。request(const InfinityNikkiExtractPhotoParamsRequest&):folder_id+only_missing+ 可选uid_override。progress_callback:进度回调,可为空。
返回: 成功时返回完整统计的 Result;失败时 std::unexpected,错误串可能是:Database is not initialized、UID override is empty、Worker pool is not available for photo extract preparation、候选加载错误。
extract_photo_params_silent_incremental(app_state, request, progress_callback) -> 同上
静默增量入口,按 candidate_asset_ids 精确解析,不接受 uid_override。注意语义差异:它不会把历史 missing 混入本批(源码注释明确),因此适合作为"变更即解析"的后台任务。
内部协作接口(infra 层)
infra::load_candidate_assets(app_state, request) -> std::expected<std::vector<scan::CandidateAssetRow>, std::string>infra::load_candidate_assets_by_ids(app_state, ids) -> 同上infra::extract_batch_photo_params(app_state, batch) -> asio::awaitable<std::expected<std::vector<ExtractBatchPhotoParamsRecord>, std::string>>(与入参 batch 下标一一对应)infra::upsert_photo_params_batch(app_state, uid, items) -> std::expected<std::int32_t, std::string>(返回写入服装行数)scan::prepare_photo_extract_entry(candidate, uid_override) -> std::expected<PreparedPhotoExtractEntry, std::string>
Failure Modes, Edge Cases & Concurrency
失败模式分级(详见 Core Flow 阶段 4 表格):整批失败 / 单条失败 / UID 组落库失败三级;任一失败都不会终止整个提取任务 —— 除落库失败会放弃当前批剩余 UID 组(提前 return)外,后续批照常发送。这是"尽力而为 + 明细可查"的取舍:错误逐条进入 result.errors(带上 asset_id),供 UI 呈现。
并发要点:
slot_ready用store(true, release)/load(acquire)建立跨线程的 happens-before,保证协程读到true时outcomes[index]已完整可见;completed_prepare仅用于进度展示,用relaxed序即可;- 收割循环按候选原顺序进行,因此批量内容与发送顺序是确定性的(便于复现问题与日志比对);
- WorkerPool 任务提交失败被降级为该候选的
skipped,不抛异常、不中断。
边界情况:
- 空候选集合:立即
completed,零副作用; candidate_count很大时,slot_ready是按候选数分配的atomic<bool>数组(unique_ptr持有),内存开销与候选数线性;- 进度百分比在
total<=0时直接给100.0(calculate_processing_percent的防御分支),避免除零。
对第三方的节制:项目对 NUAN5 的请求保持节流策略(角色 API 侧源码注释写明"与现有 NUAN5.PRO 照片解析请求保持同等节制"),照片解析侧通过 50 条/批的聚合降低请求次数。
Performance & Operational Notes
- 吞吐瓶颈在网络往返:本地 prepare 在 WorkerPool 并行,流水线实际按批串行等待远端响应;如需提速,方向是批内并行度或增大
kExtractBatchSize(当前为编译期常量)。 - 进度节流成本可控:200ms 最小间隔 + 百分比去重,保证 UI 在上万候选时也不会被回调淹没。
- 可观测性:
Logger输出模式级日志 —— 开始(mode=…, found N candidate assets)、每批 debug(unique_uids=…, count=…)、整批失败 warn、单条/落库失败 error、完成汇总 info。排障时按mode与asset_id过滤即可。 - 数据一致性:每 UID 一次
upsert_photo_params_batch是最小落库单元,组内失败整组标记 failed,不会出现"半组写入但仍计 saved"的情况。
Extension Points
- 新增运行形态:仿照
extract_photo_params_silent_incremental,写一个新的候选加载入口 + 复用extract_photo_params_from_candidates,即可接入任意来源的候选(如云端同步集、过滤器)。 - 更换解析后端:
infra::extract_batch_photo_params是唯一远端触点,返回与入参下标对齐的ExtractBatchPhotoParamsRecord向量;替换实现只需保持这一契约。 - 扩展统计维度:
ParsedPhotoParamsRecord(infra.hpp)是 NUAN5 字段的统一落点,新增字段需同步迁移(参考003_infinity_nikki_params_nuan5_columns.sql及后续 key-value 用户记录重建迁移)与to_parsed_record映射。
Related Links
- 候选扫描与本地准备:
src/extensions/infinity_nikki/photo_extract/scan.* - NUAN5 基础设施与落库:
src/extensions/infinity_nikki/photo_extract/infra.cpp/infra.hpp - NUAN5 相机字典下载与缓存:
src/extensions/infinity_nikki/metadata_dict.cpp - NUAN5 角色 API:
src/extensions/infinity_nikki/role_profile.cpp - 数据库迁移(nuan5 列 / 用户记录 key-value 化):
src/core/migration/scripts/scripts.cpp、src/migrations/003_infinity_nikki_params_nuan5_columns.sql - UI 展示层:
src/ui/photography_panel/photography_panel.cpp