```
feat(iOS): 更新MNN后端模型配置优化性能 将MNN主模型从Qwen3.5-4B(~2.64GiB)降级为Qwen3.5-2B(~1.1GiB),因为4B版本 实测运行过慢,影响用户体验。iPhone17+/SME2设备使用2B模型,保留MLX 兜底方案用于模拟器和备用场景,确保AI推理性能和存储效率的平衡。 ```
This commit is contained in:
@@ -77,53 +77,6 @@ actor CaptureService {
|
||||
try await runVL(on: assets)
|
||||
}
|
||||
|
||||
/// 异常项快拍:对一张**局部照片**(JPEG data)跑 VL,只抽 indicators,不建 Report、不留图。
|
||||
/// - 临时文件落 `NSTemporaryDirectory`(`.completeFileProtectionUnlessOpen`),推理后 `defer` 删除 ——
|
||||
/// 符合「最后只存参数和异常值」(§ 需求)与隐私基线(§6),全程不写 Vault、不建 Asset。
|
||||
/// - 失败抛 `CaptureError`,UI 回退手动录入(§3.2 失败回退红线)。
|
||||
/// 调用方(MainActor)负责把识别结果落成独立 Indicator。
|
||||
func recognizeRegion(imageData: Data) async throws -> [ParsedReport.ParsedIndicator] {
|
||||
do {
|
||||
try await AIRuntime.shared.prepareVL()
|
||||
} catch {
|
||||
throw CaptureError.modelNotReady
|
||||
}
|
||||
|
||||
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("region-\(UUID().uuidString).jpg")
|
||||
do {
|
||||
// 用 .completeFileProtectionUnlessOpen 而非 .complete:VL 推理可能持续数秒,
|
||||
// 期间设备若锁屏,.complete 会让读/写抛 EPERM 使快拍在锁屏下必失败;
|
||||
// unlessOpen 允许已打开句柄继续访问,与 Vault(completeUnlessOpen)一致。
|
||||
try imageData.write(to: tmpURL, options: [.completeFileProtectionUnlessOpen, .atomic])
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("临时图片写入失败:\(error.localizedDescription)")
|
||||
}
|
||||
defer { try? FileManager.default.removeItem(at: tmpURL) }
|
||||
|
||||
let raw: String
|
||||
do {
|
||||
raw = try await AIRuntime.shared.analyzeReport(
|
||||
imageURLs: [tmpURL],
|
||||
prompt: VLPrompts.regionExtraction(),
|
||||
// 整张化验单可能含十余项,512 token 会截断 → 解析失败。给足额度。
|
||||
maxTokens: 2048
|
||||
)
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("\(error)")
|
||||
}
|
||||
#if DEBUG
|
||||
print("🔎 [recognizeRegion] image bytes=\(imageData.count), VL raw output:\n\(raw)\n--- end VL raw ---")
|
||||
#endif
|
||||
do {
|
||||
return try CaptureService.parseIndicatorsJSON(raw)
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
throw CaptureError.parseFailed(msg)
|
||||
} catch {
|
||||
throw CaptureError.parseFailed("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 「拍照识别」OCR 链路:把 Vision OCR 出的纯文本交给 LLM(Qwen3-1.7B)结构化抽指标。
|
||||
/// 不建 Report、不留图;失败抛 `CaptureError`,UI 回退手动录入(§3.2)。
|
||||
/// 调用方(MainActor)先做 OCR,再把文本传进来——OCR 不需进 actor,也避免 UIImage 跨 actor。
|
||||
@@ -149,12 +102,19 @@ actor CaptureService {
|
||||
// Qwen3 可能吐 <think>…</think>,先剥掉再抠 JSON。
|
||||
let cleaned = CaptureService.stripThink(collected)
|
||||
#if DEBUG
|
||||
print("🧠 [recognizeIndicators] LLM cleaned output:\n\(cleaned)\n--- end LLM ---")
|
||||
// 取证:原始输出(含可能未闭合的 <think>)+ strip 后,定位「空/非法 JSON」根因。
|
||||
// 用 NSLog(走统一日志)而非 print(stdout 被 Xcode lldb 接管,idevicesyslog 抓不到)。
|
||||
NSLog("KKDBG-VL RAW LLM output (%d chars):\n%@\n--- end RAW ---", collected.count, collected)
|
||||
NSLog("KKDBG-VL cleaned (%d chars):\n%@\n--- end cleaned ---", cleaned.count, cleaned)
|
||||
#endif
|
||||
do {
|
||||
return try CaptureService.parseIndicatorsJSON(cleaned)
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
throw CaptureError.parseFailed(msg)
|
||||
// 把模型实际输出的特征带到屏幕上,便于现场定位(原始长度 / strip 后长度 / 前缀)。
|
||||
let rawLen = collected.count
|
||||
let cleanLen = cleaned.count
|
||||
let preview = cleaned.isEmpty ? "(strip 后为空)" : String(cleaned.prefix(60))
|
||||
throw CaptureError.parseFailed("\(msg)〔raw \(rawLen)字/clean \(cleanLen)字·前缀:\(preview)〕")
|
||||
} catch {
|
||||
throw CaptureError.parseFailed("\(error)")
|
||||
}
|
||||
@@ -213,7 +173,7 @@ actor CaptureService {
|
||||
// 用 extractBalancedJSON(而非只认 {} 的 extractJSONObject):VL 多项时偶尔直接吐
|
||||
// 裸数组 [{...},{...}],只认对象会从第一个 { 配对,只截出第一个 indicator、静默丢掉
|
||||
// 其余 —— 这是影像档案核心卖点上的数据丢失。顶层是数组时整体视作 indicators。
|
||||
let jsonString = extractBalancedJSON(from: raw)
|
||||
let jsonString = repairJSON(extractBalancedJSON(from: raw))
|
||||
guard let data = jsonString.data(using: .utf8) else {
|
||||
throw CaptureError.parseFailed("非 UTF-8 输出")
|
||||
}
|
||||
@@ -259,7 +219,7 @@ actor CaptureService {
|
||||
/// 复用 `extractJSONObject` + `parseIndicator`。解析不到任何 indicator 返回空数组(不抛),
|
||||
/// UI 据此走「没读出指标,手动补充」分支。JSON 本身不合法才抛 `parseFailed`。
|
||||
static func parseIndicatorsJSON(_ raw: String) throws -> [ParsedReport.ParsedIndicator] {
|
||||
let jsonString = extractBalancedJSON(from: raw)
|
||||
let jsonString = repairJSON(extractBalancedJSON(from: raw))
|
||||
guard let data = jsonString.data(using: .utf8) else {
|
||||
throw CaptureError.parseFailed("非 UTF-8 输出")
|
||||
}
|
||||
@@ -324,6 +284,21 @@ actor CaptureService {
|
||||
return String(s[start...])
|
||||
}
|
||||
|
||||
/// 弱模型(2B)常见 JSON 畸形的安全修复,仅在 JSONSerialization 前兜底:
|
||||
/// - 中文弯引号 “ ” → 直引号 "(模型偶尔给 key/value 套全角引号)
|
||||
/// - 去对象/数组尾逗号(`,}` / `,]` → `}` / `]`)
|
||||
/// 修不好仍按原逻辑报错;只做结构性修正,不改字符串语义。
|
||||
static func repairJSON(_ s: String) -> String {
|
||||
var t = s
|
||||
t = t.replacingOccurrences(of: "\u{201C}", with: "\"") // “
|
||||
t = t.replacingOccurrences(of: "\u{201D}", with: "\"") // ”
|
||||
if let re = try? NSRegularExpression(pattern: ",\\s*([}\\]])") {
|
||||
t = re.stringByReplacingMatches(
|
||||
in: t, range: NSRange(t.startIndex..., in: t), withTemplate: "$1")
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/// 抠出第一段平衡的 JSON 值,`{...}` 或 `[...]` 以先出现者为准。
|
||||
/// 用于局部识别(模型可能输出 `{"indicators":[...]}` 或裸 `[...]`)。
|
||||
/// 失败返回去围栏后的原串(后续 JSONSerialization 报错)。
|
||||
|
||||
Reference in New Issue
Block a user